From 9edc3c9a16db706fb06c9ad2a8b884c4ba351a5f Mon Sep 17 00:00:00 2001 From: Salihu Date: Sun, 28 Jun 2026 16:33:32 +0100 Subject: [PATCH 1/2] add functionality to forward pre-commit hook failures to the agent --- .../core/src/git-interaction/errorPrompts.ts | 21 +++++ .../session-update/CommitFailureActions.tsx | 57 ++++++++++++ .../session-update/ExecuteToolView.test.tsx | 93 +++++++++++++++++++ .../session-update/ExecuteToolView.tsx | 44 ++++++++- 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/features/sessions/components/session-update/CommitFailureActions.tsx create mode 100644 packages/ui/src/features/sessions/components/session-update/ExecuteToolView.test.tsx diff --git a/packages/core/src/git-interaction/errorPrompts.ts b/packages/core/src/git-interaction/errorPrompts.ts index 05d0683156..fb4994e2d9 100644 --- a/packages/core/src/git-interaction/errorPrompts.ts +++ b/packages/core/src/git-interaction/errorPrompts.ts @@ -5,6 +5,27 @@ export interface FixWithAgentPrompt { context: string; } +export function buildCommitHookErrorPrompt( + command: string, +): FixWithAgentPrompt { + return { + label: `Fix commit failure`, + context: `A \`git commit\` command failed, most likely because a pre-commit hook (e.g. lint, format, type-check, or secret scan) rejected the commit or exited with a non-zero status. The command that failed was: + +${command} + +The full hook output is below. + +Your task is to diagnose the root cause from the output and fix the underlying issue (e.g. lint/format violations, failing checks, missing dependencies) so the commit can succeed. + +IMPORTANT: +- Start by reading the output carefully to identify which hook failed and why. +- Fix the underlying issue rather than bypassing the hook. Do NOT use \`--no-verify\` unless the user explicitly asks for it. +- Confirm any destructive actions with the user first. +- Once the issue is resolved, you may retry the commit.`, + }; +} + export function buildCreatePrFlowErrorPrompt( failedStep: CreatePrStep | null, ): FixWithAgentPrompt { diff --git a/packages/ui/src/features/sessions/components/session-update/CommitFailureActions.tsx b/packages/ui/src/features/sessions/components/session-update/CommitFailureActions.tsx new file mode 100644 index 0000000000..15e534309c --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/CommitFailureActions.tsx @@ -0,0 +1,57 @@ +import { Check, Copy, Sparkle } from "@phosphor-icons/react"; +import { buildCommitHookErrorPrompt } from "@posthog/core/git-interaction/errorPrompts"; +import { useFixWithAgent } from "@posthog/ui/features/git-interaction/useFixWithAgent"; +import { Button, Flex } from "@radix-ui/themes"; +import { useState } from "react"; + +const ICON_SIZE = 12; + +/** + * Action footer shown under a failed `git commit` Bash tool call (e.g. a + * pre-commit hook rejected the commit). Lets the user hand the failure output + * to the agent to resolve, or copy it to paste into a new message. + */ +export function CommitFailureActions({ + command, + output, +}: { + command: string; + output: string; +}) { + const { canFixWithAgent, fixWithAgent } = useFixWithAgent(() => + buildCommitHookErrorPrompt(command), + ); + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(output); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard writes can reject when the document isn't focused; ignore. + } + }; + + return ( + + {canFixWithAgent && ( + + )} + + + ); +} diff --git a/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.test.tsx b/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.test.tsx new file mode 100644 index 0000000000..9d1d910ba6 --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.test.tsx @@ -0,0 +1,93 @@ +import type { ToolCall } from "@posthog/ui/features/sessions/types"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ExecuteToolView } from "./ExecuteToolView"; + +vi.mock("./CommitFailureActions", () => ({ + CommitFailureActions: ({ + command, + output, + }: { + command: string; + output: string; + }) => ( +
+ ), +})); + +function makeToolCall(overrides: Partial = {}): ToolCall { + return { + toolCallId: "tc-1", + title: "bash", + kind: "execute", + status: "completed", + rawInput: { + command: 'git commit -m "test"', + description: "run commit", + }, + content: [], + ...overrides, + }; +} + +function textContent(text: string): NonNullable { + return [{ type: "content", content: { type: "text", text } }]; +} + +function renderView(toolCall: ToolCall) { + return render( + + + , + ); +} + +describe("ExecuteToolView", () => { + it("shows commit failure actions for a git commit failure with hook output", () => { + renderView( + makeToolCall({ + status: "failed", + rawInput: { command: 'git commit -m "test"' }, + content: textContent(`Exit code 1\n╭───╮\n│ hook: pre-commit │\n╰───╯`), + }), + ); + + const actions = screen.getByTestId("commit-failure-actions"); + expect(actions).toHaveAttribute("data-command", 'git commit -m "test"'); + expect(actions).toHaveAttribute( + "data-output", + expect.stringContaining("hook: pre-commit"), + ); + }); + + it("still shows commit failure actions when the tool status is completed but the output has a non-zero exit signal", () => { + renderView( + makeToolCall({ + status: "completed", + rawInput: { command: "pre-commit run --all-files" }, + content: textContent("exit status 1\npre-commit failed"), + }), + ); + + expect(screen.getByTestId("commit-failure-actions")).toBeInTheDocument(); + }); + + it("does not show commit failure actions for unrelated failed bash output", () => { + renderView( + makeToolCall({ + status: "failed", + rawInput: { command: "git push origin main" }, + content: textContent("exit status 1\nnetwork error"), + }), + ); + + expect( + screen.queryByTestId("commit-failure-actions"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx b/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx index 2217f02bdb..393011e37a 100644 --- a/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx @@ -1,5 +1,6 @@ import { Terminal } from "@phosphor-icons/react"; import { compactHomePath } from "@posthog/shared"; +import { CommitFailureActions } from "./CommitFailureActions"; import { ToolRow } from "./ToolRow"; import { ContentPre, @@ -13,6 +14,29 @@ import { const ANSI_REGEX = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g"); const MAX_COMMAND_LENGTH = 120; +// git commit — allow flags / -c k=v / env-prefix between `git` and `commit` +const GIT_COMMIT_COMMAND_REGEX = + /(?:^|[\s;&|(])git(?:\s+-\S+|\s+-[cC]\s+\S+)*\s+commit(?:\s|$)/; + +// hook managers + direct hook execution + common hook bodies +const PRE_COMMIT_COMMAND_REGEX = + /\blefthook\b[\s\S]*?\bpre-commit\b|\bpre-commit\b(?:[\s\S]*?\brun\b|\s+run\b|$)|\bhusky\b|\bovercommit\b|\blint-staged\b|[./]*\.(?:git\/hooks|husky)\/pre-commit\b/i; + +const PRE_COMMIT_OUTPUT_REGEX = + /hook:\s*pre-commit\b|\blefthook\b.*\b(hook|run)\b|\bRunning hooks?\b.*\bpre-commit\b|\[pre-commit\]/i; + +const EXIT_STATUS_SIGNAL_REGEX = + /\b(?:exit status|exit code|exited with code)\s+(\d+)\b/gi; + +function hasNonZeroExitStatusSignal(text: string): boolean { + for (const match of text.matchAll(EXIT_STATUS_SIGNAL_REGEX)) { + const exitCode = Number(match[1]); + if (Number.isFinite(exitCode) && exitCode > 0) { + return true; + } + } + return false; +} interface ExecuteRawInput { command?: string; @@ -42,6 +66,15 @@ export function ExecuteToolView({ "", ); const hasOutput = output.trim().length > 0; + const isGitCommitCommand = GIT_COMMIT_COMMAND_REGEX.test(command); + const isPreCommitRunCommand = PRE_COMMIT_COMMAND_REGEX.test(command); + const hasPreCommitSignal = PRE_COMMIT_OUTPUT_REGEX.test(output); + const hasNonZeroExitSignal = hasNonZeroExitStatusSignal(output); + const hasFailureSignal = isFailed || hasNonZeroExitSignal; + const showCommitFailureActions = + hasFailureSignal && + hasOutput && + ((isGitCommitCommand && hasPreCommitSignal) || isPreCommitRunCommand); return ( {output} : undefined} + content={ + hasOutput ? ( + <> + {output} + {showCommitFailureActions && ( + + )} + + ) : undefined + } > {description && {description}} {command && ( From 5dc8c9d85d145c2327886104ed1e1baca05d73ae Mon Sep 17 00:00:00 2001 From: Salihu Date: Sun, 28 Jun 2026 21:10:15 +0100 Subject: [PATCH 2/2] improve regex --- .../components/session-update/ExecuteToolView.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx b/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx index 393011e37a..d87b9e8b97 100644 --- a/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx @@ -14,19 +14,18 @@ import { const ANSI_REGEX = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g"); const MAX_COMMAND_LENGTH = 120; -// git commit — allow flags / -c k=v / env-prefix between `git` and `commit` + const GIT_COMMIT_COMMAND_REGEX = - /(?:^|[\s;&|(])git(?:\s+-\S+|\s+-[cC]\s+\S+)*\s+commit(?:\s|$)/; + /(?:^|[\s;&|(])git(?:\s+-[cC]\s+\S+|\s+-\S+)*\s+commit(?:\s|$)/; -// hook managers + direct hook execution + common hook bodies const PRE_COMMIT_COMMAND_REGEX = - /\blefthook\b[\s\S]*?\bpre-commit\b|\bpre-commit\b(?:[\s\S]*?\brun\b|\s+run\b|$)|\bhusky\b|\bovercommit\b|\blint-staged\b|[./]*\.(?:git\/hooks|husky)\/pre-commit\b/i; + /\blefthook\b[\s\S]*?\bpre-commit\b|\bpre-commit\b(?:[\s\S]*?\brun\b|$)|\bhusky\b(?:\s+run\b|[\s\S]*?\bpre-commit\b)|\bovercommit\b[\s\S]*?(?:--run\b|\bpre-commit\b)|\blint-staged\b|[./]*\.(?:git\/hooks|husky)\/pre-commit\b/i; const PRE_COMMIT_OUTPUT_REGEX = - /hook:\s*pre-commit\b|\blefthook\b.*\b(hook|run)\b|\bRunning hooks?\b.*\bpre-commit\b|\[pre-commit\]/i; + /hook:\s*pre-commit\b|\blefthook\b.*\b(?:hook|run)\b|\bRunning\s+(?:pre-commit\b.*\bhooks?\b|\bhooks?\b.*\bpre-commit\b)|\[pre-commit\]|\bhusky\b.*\bpre-commit\b/i; const EXIT_STATUS_SIGNAL_REGEX = - /\b(?:exit status|exit code|exited with code)\s+(\d+)\b/gi; + /\b(?:exit\s+(?:status|code)|exited\s+with\s+(?:code|status))[:\s]+(\d+)\b/gi; function hasNonZeroExitStatusSignal(text: string): boolean { for (const match of text.matchAll(EXIT_STATUS_SIGNAL_REGEX)) {