feat: add git failed operations as failed chat messages (#371)

This commit is contained in:
Carlos Florêncio
2026-03-05 20:26:55 +00:00
committed by GitHub
parent 53dbd22dbf
commit 7fb2f0bcfa
8 changed files with 673 additions and 5 deletions
+23
View File
@@ -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>
);
});
+8
View File
@@ -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 = {
+10
View File
@@ -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();
}
});
});