feat: add git failed operations as failed chat messages (#371)
This commit is contained in:
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -116,6 +117,28 @@ func provideGateway(
|
||||
githubSvc.AssociatePRByURL(ctx, sessionID, taskID, prURL, branch)
|
||||
})
|
||||
}
|
||||
gitHandlers.SetOnGitOperationFailed(func(ctx context.Context, sessionID, taskID, operation, errorOutput string) {
|
||||
if _, err := taskSvc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
|
||||
TaskSessionID: sessionID,
|
||||
TaskID: taskID,
|
||||
Content: fmt.Sprintf("Git %s failed", operation),
|
||||
AuthorType: "agent",
|
||||
Type: "error",
|
||||
Metadata: map[string]interface{}{
|
||||
"git_operation_error": true,
|
||||
"operation": operation,
|
||||
"error_output": errorOutput,
|
||||
"session_id": sessionID,
|
||||
"task_id": taskID,
|
||||
"variant": "error",
|
||||
},
|
||||
}); err != nil {
|
||||
log.Error("failed to create git operation error message",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("operation", operation),
|
||||
zap.Error(err))
|
||||
}
|
||||
})
|
||||
gitHandlers.RegisterHandlers(gateway.Dispatcher)
|
||||
|
||||
passthroughHandlers := agenthandlers.NewPassthroughHandlers(lifecycleMgr, log)
|
||||
|
||||
@@ -17,6 +17,15 @@ import (
|
||||
// Parameters: ctx, sessionID, taskID, prURL, branch.
|
||||
type PRCreatedCallback func(ctx context.Context, sessionID, taskID, prURL, branch string)
|
||||
|
||||
// GitOperationFailedCallback is called when a git operation fails.
|
||||
// Parameters: ctx, sessionID, taskID, operation name, error output.
|
||||
type GitOperationFailedCallback func(ctx context.Context, sessionID, taskID, operation, errorOutput string)
|
||||
|
||||
// ExecutionLookup provides access to running agent executions by session ID.
|
||||
type ExecutionLookup interface {
|
||||
GetExecutionBySessionID(sessionID string) (*lifecycle.AgentExecution, bool)
|
||||
}
|
||||
|
||||
// SessionReader is a minimal interface for reading session metadata.
|
||||
// This is needed because git operations need to know the session's base commit SHA
|
||||
// to filter commits to only those made during the session.
|
||||
@@ -29,15 +38,16 @@ type SessionReader interface {
|
||||
// GitHandlers provides WebSocket handlers for git worktree operations.
|
||||
// Operations are executed via agentctl which runs in the worktree context.
|
||||
type GitHandlers struct {
|
||||
lifecycleMgr *lifecycle.Manager
|
||||
sessionReader SessionReader
|
||||
logger *logger.Logger
|
||||
onPRCreated PRCreatedCallback
|
||||
lifecycleMgr ExecutionLookup
|
||||
sessionReader SessionReader
|
||||
logger *logger.Logger
|
||||
onPRCreated PRCreatedCallback
|
||||
onGitOperationFailed GitOperationFailedCallback
|
||||
}
|
||||
|
||||
// NewGitHandlers creates a new GitHandlers instance.
|
||||
// sessionReader is required to look up session metadata (e.g., base commit SHA).
|
||||
func NewGitHandlers(lifecycleMgr *lifecycle.Manager, sessionReader SessionReader, log *logger.Logger) *GitHandlers {
|
||||
func NewGitHandlers(lifecycleMgr ExecutionLookup, sessionReader SessionReader, log *logger.Logger) *GitHandlers {
|
||||
return &GitHandlers{
|
||||
lifecycleMgr: lifecycleMgr,
|
||||
sessionReader: sessionReader,
|
||||
@@ -50,6 +60,32 @@ func (h *GitHandlers) SetOnPRCreated(cb PRCreatedCallback) {
|
||||
h.onPRCreated = cb
|
||||
}
|
||||
|
||||
// SetOnGitOperationFailed sets a callback invoked when a git operation fails.
|
||||
func (h *GitHandlers) SetOnGitOperationFailed(cb GitOperationFailedCallback) {
|
||||
h.onGitOperationFailed = cb
|
||||
}
|
||||
|
||||
// notifyGitOperationFailed fires the failure callback asynchronously if the result indicates failure.
|
||||
func (h *GitHandlers) notifyGitOperationFailed(sessionID, operation string, result *client.GitOperationResult) {
|
||||
if result == nil || result.Success || h.onGitOperationFailed == nil {
|
||||
return
|
||||
}
|
||||
execution, ok := h.lifecycleMgr.GetExecutionBySessionID(sessionID)
|
||||
if !ok || execution.TaskID == "" {
|
||||
return
|
||||
}
|
||||
taskID := execution.TaskID
|
||||
errorOutput := result.Error
|
||||
if errorOutput == "" {
|
||||
errorOutput = result.Output
|
||||
}
|
||||
go func() {
|
||||
callbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
h.onGitOperationFailed(callbackCtx, sessionID, taskID, operation, errorOutput)
|
||||
}()
|
||||
}
|
||||
|
||||
// RegisterHandlers registers git handlers with the WebSocket dispatcher
|
||||
func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) {
|
||||
d.RegisterFunc(ws.ActionWorktreePull, h.wsPull)
|
||||
@@ -182,6 +218,7 @@ func (h *GitHandlers) wsPull(ctx context.Context, msg *ws.Message) (*ws.Message,
|
||||
return nil, fmt.Errorf("pull failed: %w", err)
|
||||
}
|
||||
|
||||
h.notifyGitOperationFailed(req.SessionID, "pull", result)
|
||||
return ws.NewResponse(msg.ID, msg.Action, result)
|
||||
}
|
||||
|
||||
@@ -206,6 +243,7 @@ func (h *GitHandlers) wsPush(ctx context.Context, msg *ws.Message) (*ws.Message,
|
||||
return nil, fmt.Errorf("push failed: %w", err)
|
||||
}
|
||||
|
||||
h.notifyGitOperationFailed(req.SessionID, "push", result)
|
||||
return ws.NewResponse(msg.ID, msg.Action, result)
|
||||
}
|
||||
|
||||
@@ -233,6 +271,7 @@ func (h *GitHandlers) wsRebase(ctx context.Context, msg *ws.Message) (*ws.Messag
|
||||
return nil, fmt.Errorf("rebase failed: %w", err)
|
||||
}
|
||||
|
||||
h.notifyGitOperationFailed(req.SessionID, "rebase", result)
|
||||
return ws.NewResponse(msg.ID, msg.Action, result)
|
||||
}
|
||||
|
||||
@@ -260,6 +299,7 @@ func (h *GitHandlers) wsMerge(ctx context.Context, msg *ws.Message) (*ws.Message
|
||||
return nil, fmt.Errorf("merge failed: %w", err)
|
||||
}
|
||||
|
||||
h.notifyGitOperationFailed(req.SessionID, "merge", result)
|
||||
return ws.NewResponse(msg.ID, msg.Action, result)
|
||||
}
|
||||
|
||||
@@ -314,6 +354,7 @@ func (h *GitHandlers) wsCommit(ctx context.Context, msg *ws.Message) (*ws.Messag
|
||||
return nil, fmt.Errorf("commit failed: %w", err)
|
||||
}
|
||||
|
||||
h.notifyGitOperationFailed(req.SessionID, "commit", result)
|
||||
return ws.NewResponse(msg.ID, msg.Action, result)
|
||||
}
|
||||
|
||||
@@ -521,6 +562,7 @@ func (h *GitHandlers) wsRevertCommit(ctx context.Context, msg *ws.Message) (*ws.
|
||||
return nil, fmt.Errorf("revert commit failed: %w", err)
|
||||
}
|
||||
|
||||
h.notifyGitOperationFailed(req.SessionID, "revert", result)
|
||||
return ws.NewResponse(msg.ID, msg.Action, result)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/lifecycle"
|
||||
"github.com/kandev/kandev/internal/agentctl/client"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
// mockExecutionLookup implements ExecutionLookup for testing.
|
||||
type mockExecutionLookup struct {
|
||||
executions map[string]*lifecycle.AgentExecution
|
||||
}
|
||||
|
||||
func (m *mockExecutionLookup) GetExecutionBySessionID(sessionID string) (*lifecycle.AgentExecution, bool) {
|
||||
if m.executions == nil {
|
||||
return nil, false
|
||||
}
|
||||
exec, ok := m.executions[sessionID]
|
||||
return exec, ok
|
||||
}
|
||||
|
||||
// mockSessionReader implements SessionReader for testing.
|
||||
type mockSessionReader struct {
|
||||
baseCommits map[string]string
|
||||
}
|
||||
|
||||
func (m *mockSessionReader) GetSessionBaseCommit(_ context.Context, sessionID string) string {
|
||||
if m.baseCommits == nil {
|
||||
return ""
|
||||
}
|
||||
return m.baseCommits[sessionID]
|
||||
}
|
||||
|
||||
func TestNewGitHandlers(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
lookup := &mockExecutionLookup{}
|
||||
reader := &mockSessionReader{}
|
||||
|
||||
h := NewGitHandlers(lookup, reader, log)
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil handlers")
|
||||
}
|
||||
if h.lifecycleMgr != lookup {
|
||||
t.Error("expected lifecycleMgr to match")
|
||||
}
|
||||
if h.sessionReader != reader {
|
||||
t.Error("expected sessionReader to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGitHandlers_NilDependencies(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
h := NewGitHandlers(nil, nil, log)
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil handlers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGitHandlers(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
h := NewGitHandlers(nil, nil, log)
|
||||
d := ws.NewDispatcher()
|
||||
h.RegisterHandlers(d)
|
||||
|
||||
actions := []string{
|
||||
ws.ActionWorktreePull,
|
||||
ws.ActionWorktreePush,
|
||||
ws.ActionWorktreeRebase,
|
||||
ws.ActionWorktreeMerge,
|
||||
ws.ActionWorktreeAbort,
|
||||
ws.ActionWorktreeCommit,
|
||||
ws.ActionWorktreeStage,
|
||||
ws.ActionWorktreeUnstage,
|
||||
ws.ActionWorktreeDiscard,
|
||||
ws.ActionWorktreeCreatePR,
|
||||
ws.ActionWorktreeRevertCommit,
|
||||
ws.ActionWorktreeRenameBranch,
|
||||
ws.ActionWorktreeReset,
|
||||
ws.ActionSessionCommitDiff,
|
||||
ws.ActionSessionGitCommits,
|
||||
ws.ActionSessionCumulativeDiff,
|
||||
}
|
||||
for _, action := range actions {
|
||||
if !d.HasHandler(action) {
|
||||
t.Errorf("expected handler registered for %s", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyGitOperationFailed_NilResult(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
called := false
|
||||
h := NewGitHandlers(&mockExecutionLookup{}, nil, log)
|
||||
h.SetOnGitOperationFailed(func(_ context.Context, _, _, _, _ string) {
|
||||
called = true
|
||||
})
|
||||
|
||||
// Should not panic or call callback
|
||||
h.notifyGitOperationFailed("session-1", "commit", nil)
|
||||
if called {
|
||||
t.Error("callback should not be called for nil result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyGitOperationFailed_SuccessResult(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
called := false
|
||||
h := NewGitHandlers(&mockExecutionLookup{}, nil, log)
|
||||
h.SetOnGitOperationFailed(func(_ context.Context, _, _, _, _ string) {
|
||||
called = true
|
||||
})
|
||||
|
||||
h.notifyGitOperationFailed("session-1", "commit", &client.GitOperationResult{
|
||||
Success: true,
|
||||
})
|
||||
if called {
|
||||
t.Error("callback should not be called for successful result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyGitOperationFailed_NilCallback(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
h := NewGitHandlers(&mockExecutionLookup{}, nil, log)
|
||||
// No callback set — should not panic
|
||||
h.notifyGitOperationFailed("session-1", "commit", &client.GitOperationResult{
|
||||
Success: false,
|
||||
Error: "pre-commit hook failed",
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotifyGitOperationFailed_NoExecution(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
called := false
|
||||
h := NewGitHandlers(&mockExecutionLookup{}, nil, log)
|
||||
h.SetOnGitOperationFailed(func(_ context.Context, _, _, _, _ string) {
|
||||
called = true
|
||||
})
|
||||
|
||||
h.notifyGitOperationFailed("unknown-session", "commit", &client.GitOperationResult{
|
||||
Success: false,
|
||||
Error: "failed",
|
||||
})
|
||||
|
||||
// Give the goroutine a moment (it shouldn't fire)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if called {
|
||||
t.Error("callback should not be called when no execution found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyGitOperationFailed_EmptyTaskID(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
called := false
|
||||
lookup := &mockExecutionLookup{
|
||||
executions: map[string]*lifecycle.AgentExecution{
|
||||
"session-1": {ID: "exec-1", SessionID: "session-1", TaskID: ""},
|
||||
},
|
||||
}
|
||||
h := NewGitHandlers(lookup, nil, log)
|
||||
h.SetOnGitOperationFailed(func(_ context.Context, _, _, _, _ string) {
|
||||
called = true
|
||||
})
|
||||
|
||||
h.notifyGitOperationFailed("session-1", "push", &client.GitOperationResult{
|
||||
Success: false,
|
||||
Error: "rejected",
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if called {
|
||||
t.Error("callback should not be called when task ID is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyGitOperationFailed_UsesErrorField(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
var mu sync.Mutex
|
||||
var gotOperation, gotErrorOutput string
|
||||
lookup := &mockExecutionLookup{
|
||||
executions: map[string]*lifecycle.AgentExecution{
|
||||
"session-1": {ID: "exec-1", SessionID: "session-1", TaskID: "task-1"},
|
||||
},
|
||||
}
|
||||
h := NewGitHandlers(lookup, nil, log)
|
||||
h.SetOnGitOperationFailed(func(_ context.Context, _, _, operation, errorOutput string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
gotOperation = operation
|
||||
gotErrorOutput = errorOutput
|
||||
})
|
||||
|
||||
h.notifyGitOperationFailed("session-1", "commit", &client.GitOperationResult{
|
||||
Success: false,
|
||||
Error: "pre-commit hook failed",
|
||||
Output: "some output",
|
||||
})
|
||||
|
||||
// Wait for async callback
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if gotOperation != "commit" {
|
||||
t.Errorf("expected operation 'commit', got %q", gotOperation)
|
||||
}
|
||||
if gotErrorOutput != "pre-commit hook failed" {
|
||||
t.Errorf("expected error output 'pre-commit hook failed', got %q", gotErrorOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyGitOperationFailed_FallsBackToOutput(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
var mu sync.Mutex
|
||||
var gotErrorOutput string
|
||||
lookup := &mockExecutionLookup{
|
||||
executions: map[string]*lifecycle.AgentExecution{
|
||||
"session-1": {ID: "exec-1", SessionID: "session-1", TaskID: "task-1"},
|
||||
},
|
||||
}
|
||||
h := NewGitHandlers(lookup, nil, log)
|
||||
h.SetOnGitOperationFailed(func(_ context.Context, _, _, _, errorOutput string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
gotErrorOutput = errorOutput
|
||||
})
|
||||
|
||||
h.notifyGitOperationFailed("session-1", "push", &client.GitOperationResult{
|
||||
Success: false,
|
||||
Error: "",
|
||||
Output: "fatal: rejected",
|
||||
})
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if gotErrorOutput != "fatal: rejected" {
|
||||
t.Errorf("expected fallback to Output field, got %q", gotErrorOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyGitOperationFailed_PassesSessionAndTaskID(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
var mu sync.Mutex
|
||||
var gotSessionID, gotTaskID string
|
||||
lookup := &mockExecutionLookup{
|
||||
executions: map[string]*lifecycle.AgentExecution{
|
||||
"sess-42": {ID: "exec-1", SessionID: "sess-42", TaskID: "task-99"},
|
||||
},
|
||||
}
|
||||
h := NewGitHandlers(lookup, nil, log)
|
||||
h.SetOnGitOperationFailed(func(_ context.Context, sessionID, taskID, _, _ string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
gotSessionID = sessionID
|
||||
gotTaskID = taskID
|
||||
})
|
||||
|
||||
h.notifyGitOperationFailed("sess-42", "rebase", &client.GitOperationResult{
|
||||
Success: false,
|
||||
Error: "conflict",
|
||||
})
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if gotSessionID != "sess-42" {
|
||||
t.Errorf("expected sessionID 'sess-42', got %q", gotSessionID)
|
||||
}
|
||||
if gotTaskID != "task-99" {
|
||||
t.Errorf("expected taskID 'task-99', got %q", gotTaskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWsPull_InvalidPayload(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
h := NewGitHandlers(nil, nil, log)
|
||||
|
||||
msg := &ws.Message{
|
||||
ID: "test-1",
|
||||
Action: ws.ActionWorktreePull,
|
||||
Payload: json.RawMessage(`{invalid`),
|
||||
}
|
||||
|
||||
_, err := h.wsPull(context.Background(), msg)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWsPull_MissingSessionID(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
h := NewGitHandlers(nil, nil, log)
|
||||
|
||||
msg, _ := ws.NewRequest("test-1", ws.ActionWorktreePull, GitPullRequest{SessionID: ""})
|
||||
|
||||
_, err := h.wsPull(context.Background(), msg)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing session_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWsCommit_MissingMessage(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
h := NewGitHandlers(nil, nil, log)
|
||||
|
||||
msg, _ := ws.NewRequest("test-1", ws.ActionWorktreeCommit, GitCommitRequest{
|
||||
SessionID: "session-1",
|
||||
Message: "",
|
||||
})
|
||||
|
||||
_, err := h.wsCommit(context.Background(), msg)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWsPush_NoExecution(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
lookup := &mockExecutionLookup{}
|
||||
h := NewGitHandlers(lookup, nil, log)
|
||||
|
||||
msg, _ := ws.NewRequest("test-1", ws.ActionWorktreePush, GitPushRequest{SessionID: "session-1"})
|
||||
|
||||
_, err := h.wsPush(context.Background(), msg)
|
||||
if err == nil {
|
||||
t.Error("expected error when no execution found")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { ClarificationRequestMessage } from "@/components/task/chat/messages/cla
|
||||
import { ToolSubagentMessage } from "@/components/task/chat/messages/tool-subagent-message";
|
||||
import { AgentPlanMessage } from "@/components/task/chat/messages/agent-plan-message";
|
||||
import { AgentErrorRecoveryMessage } from "@/components/task/chat/messages/agent-error-recovery-message";
|
||||
import { GitOperationErrorMessage } from "@/components/task/chat/messages/git-operation-error-message";
|
||||
|
||||
type AdapterContext = {
|
||||
isTaskDescription: boolean;
|
||||
@@ -172,6 +173,12 @@ const adapters: MessageAdapter[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
matches: (comment) =>
|
||||
comment.type === "error" &&
|
||||
!!(comment.metadata as Record<string, unknown> | undefined)?.git_operation_error,
|
||||
render: (comment) => <GitOperationErrorMessage comment={comment} />,
|
||||
},
|
||||
{
|
||||
matches: (comment) =>
|
||||
(comment.type === "status" || comment.type === "error") &&
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, memo } from "react";
|
||||
import { IconGitCommit, IconSparkles } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import type { GitOperationErrorMetadata } from "@/components/task/chat/types";
|
||||
|
||||
export const GitOperationErrorMessage = memo(function GitOperationErrorMessage({
|
||||
comment,
|
||||
}: {
|
||||
comment: Message;
|
||||
}) {
|
||||
const [fixSent, setFixSent] = useState(false);
|
||||
const metadata = comment.metadata as GitOperationErrorMetadata | undefined;
|
||||
|
||||
const handleFix = useCallback(async () => {
|
||||
const client = getWebSocketClient();
|
||||
if (!client || !metadata) return;
|
||||
|
||||
setFixSent(true);
|
||||
const prompt = [
|
||||
`The git ${metadata.operation} command failed with the following error:`,
|
||||
"",
|
||||
"```",
|
||||
metadata.error_output,
|
||||
"```",
|
||||
"",
|
||||
"Please fix the issues reported above.",
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
await client.request("message.add", {
|
||||
task_id: metadata.task_id,
|
||||
session_id: metadata.session_id,
|
||||
content: prompt,
|
||||
});
|
||||
} catch {
|
||||
setFixSent(false);
|
||||
}
|
||||
}, [metadata]);
|
||||
|
||||
const operation = metadata?.operation || "operation";
|
||||
const title = comment.content || `Git ${operation} failed`;
|
||||
|
||||
return (
|
||||
<div className="w-full" data-testid="git-operation-error-message">
|
||||
<div className="flex items-start gap-3 w-full rounded px-2 py-1 -mx-2">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<IconGitCommit className="h-4 w-4 text-red-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<div className="text-xs font-medium text-red-600 dark:text-red-400">{title}</div>
|
||||
|
||||
{metadata?.error_output && (
|
||||
<pre className="mt-1.5 text-[11px] font-mono text-muted-foreground bg-muted/50 rounded p-2 overflow-auto max-h-[300px] whitespace-pre-wrap break-words">
|
||||
{metadata.error_output}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs cursor-pointer gap-1.5"
|
||||
disabled={fixSent}
|
||||
onClick={handleFix}
|
||||
data-testid="git-fix-button"
|
||||
>
|
||||
<IconSparkles className="h-3 w-3" />
|
||||
{fixSent ? "Fix requested" : "Fix"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -113,6 +113,14 @@ export type RecoveryMetadata = StatusMetadata & {
|
||||
has_resume_token: boolean;
|
||||
};
|
||||
|
||||
export type GitOperationErrorMetadata = StatusMetadata & {
|
||||
git_operation_error: true;
|
||||
operation: string;
|
||||
error_output: string;
|
||||
session_id: string;
|
||||
task_id: string;
|
||||
};
|
||||
|
||||
export type TodoMetadata = { text: string; done?: boolean } | string;
|
||||
|
||||
export type ContentBlock = {
|
||||
|
||||
@@ -315,6 +315,16 @@ export class SessionPage {
|
||||
await expect(this.sidebar).toBeVisible();
|
||||
}
|
||||
|
||||
/** Git operation error message in chat (shown when a git operation fails). */
|
||||
gitOperationErrorMessage(): Locator {
|
||||
return this.chat.getByTestId("git-operation-error-message");
|
||||
}
|
||||
|
||||
/** Fix button on a git operation error message. */
|
||||
gitFixButton(): Locator {
|
||||
return this.chat.getByTestId("git-fix-button");
|
||||
}
|
||||
|
||||
/** Locator for the VS Code dockview tab. */
|
||||
vscodeTab(): Locator {
|
||||
return this.page.locator(".dv-default-tab:has-text('VS Code')");
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { test, expect } from "../fixtures/test-base";
|
||||
import type { ApiClient } from "../helpers/api-client";
|
||||
import { KanbanPage } from "../pages/kanban-page";
|
||||
import { SessionPage } from "../pages/session-page";
|
||||
import type { Page } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers (same patterns as git-changes-panel.spec.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class GitHelper {
|
||||
constructor(
|
||||
private repoDir: string,
|
||||
private env: NodeJS.ProcessEnv,
|
||||
) {}
|
||||
|
||||
exec(cmd: string): string {
|
||||
return execSync(cmd, { cwd: this.repoDir, env: this.env, encoding: "utf8" });
|
||||
}
|
||||
|
||||
createFile(name: string, content: string) {
|
||||
fs.writeFileSync(path.join(this.repoDir, name), content);
|
||||
}
|
||||
|
||||
stageAll() {
|
||||
this.exec("git add -A");
|
||||
}
|
||||
|
||||
installFailingPreCommitHook(message: string) {
|
||||
const hooksDir = path.join(this.repoDir, ".git", "hooks");
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
const hookPath = path.join(hooksDir, "pre-commit");
|
||||
fs.writeFileSync(hookPath, `#!/bin/sh\necho "${message}"\nexit 1\n`);
|
||||
fs.chmodSync(hookPath, "755");
|
||||
}
|
||||
|
||||
removePreCommitHook() {
|
||||
const hookPath = path.join(this.repoDir, ".git", "hooks", "pre-commit");
|
||||
if (fs.existsSync(hookPath)) {
|
||||
fs.unlinkSync(hookPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createStandardProfile(apiClient: ApiClient, name: string) {
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const agentId = agents[0]?.id;
|
||||
if (!agentId) {
|
||||
throw new Error(`E2E setup failed: no agent available for profile "${name}"`);
|
||||
}
|
||||
return apiClient.createAgentProfile(agentId, name, {
|
||||
model: "mock-fast",
|
||||
auto_approve: true,
|
||||
cli_passthrough: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function openTaskSession(page: Page, title: string): Promise<SessionPage> {
|
||||
const kanban = new KanbanPage(page);
|
||||
await kanban.goto();
|
||||
|
||||
const card = kanban.taskCardByTitle(title);
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.click();
|
||||
await expect(page).toHaveURL(/\/s\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(page);
|
||||
await session.waitForLoad();
|
||||
return session;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Git commit pre-hooks", () => {
|
||||
test("failed commit shows error in chat with Fix button", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
const profile = await createStandardProfile(apiClient, "Git Hook Test Profile");
|
||||
|
||||
await apiClient.createTaskWithAgent(seedData.workspaceId, "Git Hook Test", profile.id, {
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
|
||||
const session = await openTaskSession(testPage, "Git Hook Test");
|
||||
|
||||
// Wait for agent to finish initial turn
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Set up git helper
|
||||
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
HOME: backend.tmpDir,
|
||||
GIT_AUTHOR_NAME: "E2E Test",
|
||||
GIT_AUTHOR_EMAIL: "e2e@test.local",
|
||||
GIT_COMMITTER_NAME: "E2E Test",
|
||||
GIT_COMMITTER_EMAIL: "e2e@test.local",
|
||||
};
|
||||
const git = new GitHelper(repoDir, gitEnv);
|
||||
|
||||
try {
|
||||
// Install a pre-commit hook that always fails
|
||||
git.installFailingPreCommitHook("lint check failed: missing semicolons");
|
||||
|
||||
// Open Changes tab first so WS subscription is active
|
||||
await session.clickTab("Changes");
|
||||
await expect(session.changes).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Create a file - it should appear as unstaged via git status polling
|
||||
git.createFile("hook-test.txt", "test content");
|
||||
await expect(session.changes.getByText("hook-test.txt")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Stage the file via the UI "Stage all" button
|
||||
await session.changes.getByRole("button", { name: "Stage all" }).click();
|
||||
|
||||
// Wait for the "Commit" button to appear (indicates staged section is ready)
|
||||
const commitButton = session.changes.getByRole("button", { name: "Commit" });
|
||||
await expect(commitButton).toBeVisible({ timeout: 15_000 });
|
||||
await commitButton.click();
|
||||
|
||||
// Fill commit message and submit
|
||||
const dialog = testPage.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await dialog.getByRole("textbox").fill("test commit message");
|
||||
await dialog.getByRole("button", { name: "Commit" }).click();
|
||||
|
||||
// The error message should appear in the chat
|
||||
await expect(session.gitOperationErrorMessage()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Verify the error output contains the pre-commit hook message
|
||||
await expect(
|
||||
session.gitOperationErrorMessage().getByText("lint check failed: missing semicolons"),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Click the Fix button
|
||||
await session.gitFixButton().click();
|
||||
|
||||
// The fix prompt should appear in the chat as a user message
|
||||
await expect(
|
||||
session.chat.getByText("git commit command failed", { exact: false }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The agent should process the fix prompt and respond
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Verify the agent responded (mock agent echoes back part of the prompt)
|
||||
await expect(session.chat.getByText("completed the analysis", { exact: false })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
} finally {
|
||||
git.removePreCommitHook();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user