From 7fb2f0bcfad4e4dec2a97c0b690515d124daea46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 5 Mar 2026 20:26:55 +0000 Subject: [PATCH] feat: add git failed operations as failed chat messages (#371) --- apps/backend/cmd/kandev/gateway.go | 23 ++ .../internal/agent/handlers/git_handlers.go | 52 ++- .../agent/handlers/git_handlers_test.go | 333 ++++++++++++++++++ .../components/task/chat/message-renderer.tsx | 7 + .../messages/git-operation-error-message.tsx | 80 +++++ apps/web/components/task/chat/types.ts | 8 + apps/web/e2e/pages/session-page.ts | 10 + .../e2e/tests/git-commit-pre-hooks.spec.ts | 165 +++++++++ 8 files changed, 673 insertions(+), 5 deletions(-) create mode 100644 apps/backend/internal/agent/handlers/git_handlers_test.go create mode 100644 apps/web/components/task/chat/messages/git-operation-error-message.tsx create mode 100644 apps/web/e2e/tests/git-commit-pre-hooks.spec.ts diff --git a/apps/backend/cmd/kandev/gateway.go b/apps/backend/cmd/kandev/gateway.go index a1438205bb..068a7f1fa3 100644 --- a/apps/backend/cmd/kandev/gateway.go +++ b/apps/backend/cmd/kandev/gateway.go @@ -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) diff --git a/apps/backend/internal/agent/handlers/git_handlers.go b/apps/backend/internal/agent/handlers/git_handlers.go index ae0f8d5e8f..a0c95e4e16 100644 --- a/apps/backend/internal/agent/handlers/git_handlers.go +++ b/apps/backend/internal/agent/handlers/git_handlers.go @@ -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) } diff --git a/apps/backend/internal/agent/handlers/git_handlers_test.go b/apps/backend/internal/agent/handlers/git_handlers_test.go new file mode 100644 index 0000000000..54879dfc23 --- /dev/null +++ b/apps/backend/internal/agent/handlers/git_handlers_test.go @@ -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") + } +} diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index f3629a2155..42777ce391 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -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 | undefined)?.git_operation_error, + render: (comment) => , + }, { matches: (comment) => (comment.type === "status" || comment.type === "error") && diff --git a/apps/web/components/task/chat/messages/git-operation-error-message.tsx b/apps/web/components/task/chat/messages/git-operation-error-message.tsx new file mode 100644 index 0000000000..7e2f5331f9 --- /dev/null +++ b/apps/web/components/task/chat/messages/git-operation-error-message.tsx @@ -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 ( +
+
+
+ +
+ +
+
{title}
+ + {metadata?.error_output && ( +
+              {metadata.error_output}
+            
+ )} + +
+ +
+
+
+
+ ); +}); diff --git a/apps/web/components/task/chat/types.ts b/apps/web/components/task/chat/types.ts index d7a56b2011..bcf2bfaf9a 100644 --- a/apps/web/components/task/chat/types.ts +++ b/apps/web/components/task/chat/types.ts @@ -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 = { diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index c507190aa8..b639734c5e 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -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')"); diff --git a/apps/web/e2e/tests/git-commit-pre-hooks.spec.ts b/apps/web/e2e/tests/git-commit-pre-hooks.spec.ts new file mode 100644 index 0000000000..4b28baf626 --- /dev/null +++ b/apps/web/e2e/tests/git-commit-pre-hooks.spec.ts @@ -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 { + 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(); + } + }); +});