diff --git a/packages/views/common/task-transcript/agent-transcript-dialog.test.tsx b/packages/views/common/task-transcript/agent-transcript-dialog.test.tsx new file mode 100644 index 0000000000..d8e70f0196 --- /dev/null +++ b/packages/views/common/task-transcript/agent-transcript-dialog.test.tsx @@ -0,0 +1,194 @@ +import { type ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../../locales/en/common.json"; +import enAgents from "../../locales/en/agents.json"; +import { AgentTranscriptDialog } from "./agent-transcript-dialog"; +import type { TimelineItem } from "./build-timeline"; +import type { AgentTask } from "@multica/core/types/agent"; + +vi.mock("@multica/core/api", () => ({ + api: { + getAgent: vi.fn().mockResolvedValue(null), + listRuntimes: vi.fn().mockResolvedValue([]), + }, +})); +vi.mock("@multica/core/hooks", () => ({ + useWorkspaceId: () => "ws-1", + useCurrentWorkspace: () => ({ id: "ws-1", name: "Test WS", slug: "test" }), +})); + +vi.mock("../actor-avatar", () => ({ + ActorAvatar: () => , +})); + +const TEST_RESOURCES = { + en: { + common: enCommon, + agents: enAgents, + }, +}; + +function I18nWrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return ( + + + {children} + + + ); +} + +function baseTask(): AgentTask { + return { + id: "task-1", + agent_id: "agent-1", + runtime_id: "runtime-1", + issue_id: "issue-1", + status: "completed", + priority: 1, + created_at: "2026-05-13T00:00:00Z", + started_at: "2026-05-13T00:00:10Z", + completed_at: "2026-05-13T00:00:20Z", + dispatched_at: "2026-05-13T00:00:00Z", + result: null, + error: null, + }; +} + +describe("AgentTranscriptDialog tool_use diff rendering", () => { + it("redacts secrets before rendering inline edit diffs", () => { + const rawSecret = "sk-proj-oldsecret1234567890abcdef"; + const items: TimelineItem[] = [ + { + seq: 1, + type: "tool_use", + tool: "edit_file", + input: { + file_path: "E:/workspace/tests/.env", + old_string: `OPENAI_API_KEY=${rawSecret}`, + new_string: "OPENAI_API_KEY=sk-proj-newsecret1234567890abcdef", + }, + }, + ]; + + render( + {}} + task={baseTask()} + items={items} + agentName="Claude" + />, + { wrapper: I18nWrapper }, + ); + + fireEvent.click(screen.getByText(".../tests/.env")); + + expect(screen.queryByText(rawSecret, { exact: false })).not.toBeInTheDocument(); + expect(screen.getAllByText((content) => content.includes("[REDACTED")).length).toBeGreaterThan(0); + }); + + it("renders diff for create-file tool_use with content + file_path", () => { + const items: TimelineItem[] = [ + { + seq: 1, + type: "tool_use", + tool: "write_file", + input: { + file_path: "E:/workspace/tests/readme.txt", + content: "hello\nworld\n", + }, + }, + ]; + + render( + {}} + task={baseTask()} + items={items} + agentName="Claude" + />, + { wrapper: I18nWrapper }, + ); + + fireEvent.click(screen.getByText(".../tests/readme.txt")); + + expect(screen.getByText("File changes")).toBeInTheDocument(); + expect(screen.getByText("--- E:/workspace/tests/readme.txt")).toBeInTheDocument(); + expect(screen.getByText("@@ -0,0 +1,2 @@")).toBeInTheDocument(); + expect(screen.getByText("+hello")).toBeInTheDocument(); + expect(screen.getByText("+world")).toBeInTheDocument(); + expect(screen.queryByText("+")).not.toBeInTheDocument(); + expect(screen.queryByText("No visual diff available for this file change.")).not.toBeInTheDocument(); + }); + + it("renders diff for replace tool_use with old_string + new_string", () => { + const items: TimelineItem[] = [ + { + seq: 1, + type: "tool_use", + tool: "edit_file", + input: { + file_path: "E:/workspace/tests/hello.txt", + old_string: "before", + new_string: "after", + replace_all: false, + }, + }, + ]; + + render( + {}} + task={baseTask()} + items={items} + agentName="Claude" + />, + { wrapper: I18nWrapper }, + ); + + fireEvent.click(screen.getByText(".../tests/hello.txt")); + + expect(screen.getByText("File changes")).toBeInTheDocument(); + expect(screen.getByText("-before")).toBeInTheDocument(); + expect(screen.getByText("+after")).toBeInTheDocument(); + expect(screen.queryByText("No visual diff available for this file change.")).not.toBeInTheDocument(); + }); + + it("renders non-diff edit tool results as text", () => { + const items: TimelineItem[] = [ + { + seq: 1, + type: "tool_result", + tool: "patch_apply", + output: "patched: src/app.ts", + }, + ]; + + render( + {}} + task={baseTask()} + items={items} + agentName="Codex" + />, + { wrapper: I18nWrapper }, + ); + + fireEvent.click(screen.getByText("patched: src/app.ts")); + + expect(screen.getAllByText("patched: src/app.ts").length).toBeGreaterThan(1); + expect(screen.queryByText("No visual diff available for this file change.")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/views/common/task-transcript/agent-transcript-dialog.tsx b/packages/views/common/task-transcript/agent-transcript-dialog.tsx index a315f7ae57..cbd22952e5 100644 --- a/packages/views/common/task-transcript/agent-transcript-dialog.tsx +++ b/packages/views/common/task-transcript/agent-transcript-dialog.tsx @@ -33,7 +33,8 @@ import { ActorAvatar } from "../actor-avatar"; import { api } from "@multica/core/api"; import type { AgentTask, Agent, AgentRuntime } from "@multica/core/types/agent"; import { redactSecrets } from "./redact"; -import type { TimelineItem } from "./build-timeline"; +import { isEditTool, looksLikeUnifiedDiff, type TimelineItem } from "./build-timeline"; +import { DiffViewer } from "./diff-viewer"; import { useT } from "../../i18n"; interface AgentTranscriptDialogProps { @@ -578,6 +579,55 @@ interface TranscriptEventRowProps { isSelected: boolean; } +interface ToolUseDiffPayload { + filePath?: string; + oldText?: string; + newText?: string; +} + +function toolUseDiffPayload(input: Record | undefined): ToolUseDiffPayload | null { + if (!input) return null; + + const filePath = + (typeof input.file_path === "string" && input.file_path) || + (typeof input.path === "string" && input.path) || + undefined; + + const oldText = typeof input.old_text === "string" + ? input.old_text + : typeof input.old_string === "string" + ? input.old_string + : undefined; + const newText = typeof input.new_text === "string" + ? input.new_text + : typeof input.new_string === "string" + ? input.new_string + : typeof input.content === "string" + ? input.content + : undefined; + + if (oldText == null && newText == null) return null; + return { filePath, oldText, newText }; +} + +function redactedToolUseDiffPayload(input: Record | undefined): ToolUseDiffPayload | null { + const parsed = toolUseDiffPayload(input); + if (!parsed) return null; + return { + filePath: parsed.filePath ? redactSecrets(parsed.filePath) : parsed.filePath, + oldText: parsed.oldText == null ? parsed.oldText : redactSecrets(parsed.oldText), + newText: parsed.newText == null ? parsed.newText : redactSecrets(parsed.newText), + }; +} + +function redactAndTruncate(text: string): string { + const redacted = redactSecrets(text); + if (redacted.length > 4000) { + return redacted.slice(0, 4000) + "\n... (truncated)"; + } + return redacted; +} + const TranscriptEventRow = ({ ref, item, @@ -587,9 +637,18 @@ const TranscriptEventRow = ({ const color = getEventColor(item); const label = getEventLabel(item); const summary = getEventSummary(item); + const parsedToolUseDiff = + item.type === "tool_use" && isEditTool(item.tool) && item.input + ? toolUseDiffPayload(item.input) + : null; + const toolUseHasInlineDiff = + item.type === "tool_use" && parsedToolUseDiff != null; const hasDetail = - (item.type === "tool_use" && item.input && Object.keys(item.input).length > 0) || + (item.type === "tool_use" && ( + (item.input && Object.keys(item.input).length > 0) || + toolUseHasInlineDiff + )) || (item.type === "tool_result" && item.output && item.output.length > 0) || (item.type === "thinking" && item.content && item.content.length > 0) || (item.type === "text" && item.content && item.content.split("\n").length > 1) || @@ -664,20 +723,26 @@ const TranscriptEventRow = ({ function EventDetailContent({ item }: { item: TimelineItem }) { switch (item.type) { - case "tool_use": + case "tool_use": { + if (isEditTool(item.tool) && item.input) { + const parsed = redactedToolUseDiffPayload(item.input); + if (parsed) { + return ; + } + } return (
           {item.input ? redactSecrets(JSON.stringify(item.input, null, 2)) : ""}
         
); + } case "tool_result": + if (item.output && looksLikeUnifiedDiff(item.output)) { + return ; + } return (
-          {item.output
-            ? item.output.length > 4000
-              ? redactSecrets(item.output.slice(0, 4000)) + "\n... (truncated)"
-              : redactSecrets(item.output)
-            : ""}
+          {item.output ? redactAndTruncate(item.output) : ""}
         
); case "thinking": diff --git a/packages/views/common/task-transcript/build-timeline.test.ts b/packages/views/common/task-transcript/build-timeline.test.ts new file mode 100644 index 0000000000..c1b08edef9 --- /dev/null +++ b/packages/views/common/task-transcript/build-timeline.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { isEditTool, looksLikeUnifiedDiff } from "./build-timeline"; + +describe("isEditTool", () => { + it("recognizes common edit tool names across backends", () => { + expect(isEditTool("patch_apply")).toBe(true); + expect(isEditTool("edit_file")).toBe(true); + expect(isEditTool("file_edit")).toBe(true); + expect(isEditTool("MultiEdit")).toBe(true); + expect(isEditTool("Write File")).toBe(true); + }); + + it("does not classify non-edit tools as edit tools", () => { + expect(isEditTool("exec_command")).toBe(false); + expect(isEditTool("terminal")).toBe(false); + expect(isEditTool("search_files")).toBe(false); + expect(isEditTool(undefined)).toBe(false); + }); +}); + +describe("looksLikeUnifiedDiff", () => { + it("returns true for valid unified diff text", () => { + const diff = [ + "--- a/file.txt", + "+++ b/file.txt", + "@@ -1 +1 @@", + "-old line", + "+new line", + ].join("\n"); + expect(looksLikeUnifiedDiff(diff)).toBe(true); + }); + + it("returns true for new-file style diff headers without hunks", () => { + const headerOnly = [ + "--- src/new-file.ts", + "+++ src/new-file.ts", + "(new file, 42 bytes)", + ].join("\n"); + expect(looksLikeUnifiedDiff(headerOnly)).toBe(true); + }); + + it("returns false for non-diff text", () => { + expect(looksLikeUnifiedDiff("plain output")).toBe(false); + expect(looksLikeUnifiedDiff("")).toBe(false); + expect(looksLikeUnifiedDiff(undefined)).toBe(false); + }); +}); diff --git a/packages/views/common/task-transcript/build-timeline.ts b/packages/views/common/task-transcript/build-timeline.ts index d0d97ba4f1..e7f4e7e675 100644 --- a/packages/views/common/task-transcript/build-timeline.ts +++ b/packages/views/common/task-transcript/build-timeline.ts @@ -11,6 +11,62 @@ export interface TimelineItem { output?: string; } +const EDIT_TOOL_NAMES = new Set([ + "patch_apply", + "patch", + "apply_patch", + "edit", + "edit_file", + "write", + "write_file", + "multiedit", + "multi_edit", + "file_edit", + "str_replace_editor", + "insert", + "replace", + "file_change", + "filechange", +]); + +function normalizeToolName(tool: string): string { + return tool + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); +} + +export function isEditTool(tool?: string): boolean { + if (!tool) return false; + const normalized = normalizeToolName(tool); + if (EDIT_TOOL_NAMES.has(normalized)) return true; + return ( + normalized.includes("edit") || + normalized.includes("patch") || + normalized.includes("write_file") + ); +} + +export function looksLikeUnifiedDiff(output?: string): boolean { + if (!output) return false; + let hasOldFileHeader = false; + let hasNewFileHeader = false; + let hasHunk = false; + let hasChangeLine = false; + + for (const line of output.split("\n")) { + if (line.startsWith("--- ")) hasOldFileHeader = true; + if (line.startsWith("+++ ")) hasNewFileHeader = true; + if (line.startsWith("@@ ")) hasHunk = true; + if ((line.startsWith("+") && !line.startsWith("+++ ")) || (line.startsWith("-") && !line.startsWith("--- "))) { + hasChangeLine = true; + } + } + + if (hasOldFileHeader && hasNewFileHeader) return true; + return hasChangeLine && hasHunk; +} + /** Build a chronologically ordered timeline from raw task messages. */ export function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] { const items: TimelineItem[] = []; diff --git a/packages/views/common/task-transcript/diff-viewer.test.tsx b/packages/views/common/task-transcript/diff-viewer.test.tsx new file mode 100644 index 0000000000..634db40f85 --- /dev/null +++ b/packages/views/common/task-transcript/diff-viewer.test.tsx @@ -0,0 +1,232 @@ +import { type ReactNode } from "react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../../locales/en/common.json"; +import enAgents from "../../locales/en/agents.json"; +import { DiffViewer } from "./diff-viewer"; + +const TEST_RESOURCES = { + en: { + common: enCommon, + agents: enAgents, + }, +}; + +function I18nWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +describe("DiffViewer", () => { + it("renders unified and split diff modes", () => { + render( + , + { wrapper: I18nWrapper }, + ); + + expect( + screen.getByRole("button", { name: "Switch to split diff view" }), + ).toBeInTheDocument(); + expect(screen.getByText("-old line")).toBeInTheDocument(); + expect(screen.getByText("+new line")).toBeInTheDocument(); + expect(screen.queryByText("old line")).not.toBeInTheDocument(); + expect(screen.queryByText("new line")).not.toBeInTheDocument(); + + render( + , + { wrapper: I18nWrapper }, + ); + expect( + screen.getByRole("button", { name: "Switch to unified diff view" }), + ).toBeInTheDocument(); + expect(screen.getByText("old line")).toBeInTheDocument(); + expect(screen.getByText("new line")).toBeInTheDocument(); + }); + + it("switches mode when clicking the toggle", () => { + render( + , + { wrapper: I18nWrapper }, + ); + + expect(screen.getByText("-old line")).toBeInTheDocument(); + expect(screen.queryByText("old line")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Switch to split diff view" })); + + expect(screen.getByText("old line")).toBeInTheDocument(); + expect(screen.getByText("new line")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Switch to unified diff view" }), + ).toBeInTheDocument(); + }); + + it("shows placeholder when no visual diff can be parsed", () => { + render(, { wrapper: I18nWrapper }); + + expect( + screen.getByText("No visual diff available for this file change."), + ).toBeInTheDocument(); + }); + + it("handles clipboard failures in the copy action", async () => { + const originalClipboard = navigator.clipboard; + const writeText = vi.fn().mockRejectedValue(new Error("denied")); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + try { + render( + , + { wrapper: I18nWrapper }, + ); + + fireEvent.click(screen.getByRole("button", { name: "Copy diff" })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Copy failed" })).toBeInTheDocument(); + }); + expect(writeText).toHaveBeenCalledOnce(); + } finally { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: originalClipboard, + }); + } + }); + + it("renders simplified diff card for new-file headers without +/- hunks", () => { + render( + , + { wrapper: I18nWrapper }, + ); + + expect(screen.getByText("File changes")).toBeInTheDocument(); + expect(screen.queryByText("No visual diff available for this file change.")).not.toBeInTheDocument(); + expect(screen.getByText("--- src/new-file.ts")).toBeInTheDocument(); + expect(screen.getByText("(new file, 42 bytes)")).toBeInTheDocument(); + }); + + it("does not emit a phantom deletion line for new-file writes", () => { + render( + , + { wrapper: I18nWrapper }, + ); + + expect(screen.getByText("+hello")).toBeInTheDocument(); + expect(screen.getByText("+world")).toBeInTheDocument(); + expect(screen.queryByText("-")).not.toBeInTheDocument(); + expect(screen.getByText("@@ -0,0 +1,2 @@")).toBeInTheDocument(); + }); + + it("does not emit a phantom addition line for full-file deletions", () => { + render( + , + { wrapper: I18nWrapper }, + ); + + expect(screen.getByText("-hello")).toBeInTheDocument(); + expect(screen.getByText("-world")).toBeInTheDocument(); + expect(screen.queryByText("+")).not.toBeInTheDocument(); + expect(screen.getByText("@@ -1,2 +0,0 @@")).toBeInTheDocument(); + }); + + it("keeps grouped deletion and addition blocks aligned in split view", () => { + render( + , + { wrapper: I18nWrapper }, + ); + + const rows = screen + .getAllByRole("row") + .map((row) => within(row).queryAllByRole("cell")) + .filter((cells) => cells.length === 2); + + expect(rows[0]![0]).toHaveTextContent("old one"); + expect(rows[0]![1]).toHaveTextContent("new one"); + expect(rows[1]![0]).toHaveTextContent("old two"); + expect(rows[1]![1]).toHaveTextContent("new two"); + }); + + it("preserves indentation in split diff cells", () => { + render( + , + { wrapper: I18nWrapper }, + ); + + const row = screen + .getAllByRole("row") + .map((tableRow) => within(tableRow).queryAllByRole("cell")) + .find((cells) => cells.length === 2 && cells[0]?.textContent?.startsWith(" old")); + + expect(row?.[0]).toHaveTextContent(" old: value", { normalizeWhitespace: false }); + expect(row?.[1]).toHaveTextContent(" new: value", { normalizeWhitespace: false }); + expect(row?.[0]).toHaveClass("whitespace-pre-wrap", "break-all"); + expect(row?.[1]).toHaveClass("whitespace-pre-wrap", "break-all"); + }); +}); diff --git a/packages/views/common/task-transcript/diff-viewer.tsx b/packages/views/common/task-transcript/diff-viewer.tsx new file mode 100644 index 0000000000..6f4013cc2d --- /dev/null +++ b/packages/views/common/task-transcript/diff-viewer.tsx @@ -0,0 +1,371 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + Check, + ChevronDown, + ChevronUp, + Copy, + SquareSplitHorizontal, + SquareSplitVertical, +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@multica/ui/components/ui/tooltip"; +import { useT } from "../../i18n"; + +type DiffViewMode = "unified" | "split"; + +interface DiffViewerProps { + output?: string; + oldText?: string; + newText?: string; + filePath?: string; + defaultMode?: DiffViewMode; +} + +interface DiffLine { + type: "add" | "del" | "context" | "hunk" | "file"; + text: string; +} + +interface SplitRow { + type: "add" | "del" | "context" | "pair" | "hunk" | "file"; + left: string; + right: string; +} + +const splitCellBaseClass = "w-1/2 whitespace-pre-wrap break-all px-1 py-0.5"; + +function parseUnifiedDiff(text: string): DiffLine[] { + const lines: DiffLine[] = []; + for (const line of text.split("\n")) { + if (line.startsWith("--- ") || line.startsWith("+++ ")) { + lines.push({ type: "file", text: line }); + continue; + } + if (line.startsWith("@@ ")) { + lines.push({ type: "hunk", text: line }); + continue; + } + if (line.startsWith("+") && !line.startsWith("+++ ")) { + lines.push({ type: "add", text: line }); + continue; + } + if (line.startsWith("-") && !line.startsWith("--- ")) { + lines.push({ type: "del", text: line }); + continue; + } + lines.push({ type: "context", text: line }); + } + return lines; +} + +function splitTextForDiff(text: string): string[] { + if (text.length === 0) return []; + + const lines = text.split("\n"); + if (lines[lines.length - 1] === "") { + lines.pop(); + } + return lines; +} + +function buildDiffFromOldNew(oldText: string, newText: string, filePath?: string): string { + const oldLines = splitTextForDiff(oldText); + const newLines = splitTextForDiff(newText); + const oldStart = oldLines.length > 0 ? 1 : 0; + const newStart = newLines.length > 0 ? 1 : 0; + const path = filePath ?? "file"; + const lines: string[] = [ + `--- ${path}`, + `+++ ${path}`, + `@@ -${oldStart},${oldLines.length} +${newStart},${newLines.length} @@`, + ...oldLines.map((line) => `-${line}`), + ...newLines.map((line) => `+${line}`), + ]; + return lines.join("\n"); +} + +function stripDiffPrefix(line: string, type: DiffLine["type"]): string { + if (type === "add" || type === "del") { + return line.slice(1); + } + if (type === "context" && line.startsWith(" ")) { + return line.slice(1); + } + return line; +} + +function buildSplitRows(lines: DiffLine[]): SplitRow[] { + const rows: SplitRow[] = []; + let i = 0; + while (i < lines.length) { + const current = lines[i]!; + + if (current.type === "file" || current.type === "hunk") { + rows.push({ + type: current.type, + left: current.text, + right: current.text, + }); + i += 1; + continue; + } + + if (current.type === "del") { + const deletions: DiffLine[] = []; + const additions: DiffLine[] = []; + while (lines[i]?.type === "del") { + deletions.push(lines[i]!); + i += 1; + } + while (lines[i]?.type === "add") { + additions.push(lines[i]!); + i += 1; + } + + const rowCount = Math.max(deletions.length, additions.length); + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const deletion = deletions[rowIndex]; + const addition = additions[rowIndex]; + if (deletion && addition) { + rows.push({ + type: "pair", + left: stripDiffPrefix(deletion.text, "del"), + right: stripDiffPrefix(addition.text, "add"), + }); + } else if (deletion) { + rows.push({ + type: "del", + left: stripDiffPrefix(deletion.text, "del"), + right: "", + }); + } else if (addition) { + rows.push({ + type: "add", + left: "", + right: stripDiffPrefix(addition.text, "add"), + }); + } + } + continue; + } + + if (current.type === "add") { + const additions: DiffLine[] = []; + while (lines[i]?.type === "add") { + additions.push(lines[i]!); + i += 1; + } + for (const addition of additions) { + rows.push({ + type: "add", + left: "", + right: stripDiffPrefix(addition.text, "add"), + }); + } + continue; + } + + rows.push({ + type: "context", + left: stripDiffPrefix(current.text, "context"), + right: stripDiffPrefix(current.text, "context"), + }); + i += 1; + } + return rows; +} + +export function DiffViewer({ + output, + oldText, + newText, + filePath, + defaultMode = "unified", +}: DiffViewerProps) { + const { t } = useT("agents"); + const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); + const [copyFailed, setCopyFailed] = useState(false); + const [mode, setMode] = useState(defaultMode); + const nextMode: DiffViewMode = mode === "unified" ? "split" : "unified"; + + const diffText = useMemo(() => { + if (oldText != null || newText != null) { + return buildDiffFromOldNew(oldText ?? "", newText ?? "", filePath); + } + if (output && output.length > 0) return output; + return ""; + }, [output, oldText, newText, filePath]); + + const lines = useMemo(() => parseUnifiedDiff(diffText), [diffText]); + const hasDiffStructure = lines.some( + (line) => + line.type === "add" || + line.type === "del" || + line.type === "file" || + line.type === "hunk", + ); + const toggleDiffLabel = + nextMode === "split" + ? t(($) => $.transcript.switch_to_diff_split) + : t(($) => $.transcript.switch_to_diff_unified); + const isLong = lines.length > 100; + const displayLines = expanded || !isLong ? lines : lines.slice(0, 100); + const splitRows = useMemo(() => buildSplitRows(displayLines), [displayLines]); + const truncated = !expanded && isLong; + let copyLabel = t(($) => $.transcript.copy_diff); + if (copyFailed) { + copyLabel = t(($) => $.transcript.copy_failed); + } else if (copied) { + copyLabel = t(($) => $.transcript.copied); + } + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(diffText); + setCopyFailed(false); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + setCopyFailed(true); + setTimeout(() => setCopyFailed(false), 2000); + } + }; + + return ( +
+
+ + {hasDiffStructure ? t(($) => $.transcript.file_changes) : t(($) => $.transcript.file_content)} + +
+ + } + aria-label={toggleDiffLabel} + className="flex size-6 items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + onClick={() => setMode(nextMode)} + > + {nextMode === "split" ? ( + + ) : ( + + )} + + {toggleDiffLabel} + + + } + aria-label={copyLabel} + className={ + copyFailed + ? "text-destructive transition-colors hover:text-destructive" + : "text-muted-foreground transition-colors hover:text-foreground" + } + onClick={handleCopy} + > + {copied ? : } + + {copyLabel} + +
+
+ + {!hasDiffStructure ? ( +
+ {t(($) => $.transcript.no_visual_diff)} +
+ ) : ( +
+ {mode === "unified" ? ( +
+ {displayLines.map((line, i) => ( +
+ {line.text} +
+ ))} +
+ ) : ( + + + {splitRows.map((row, i) => { + if (row.type === "file" || row.type === "hunk") { + return ( + + + + ); + } + + return ( + + + + + ); + })} + +
+ {row.left} +
+ {row.left} + + {row.right} +
+ )} + + {truncated && ( + + )} + {expanded && isLong && ( + + )} +
+ )} +
+ ); +} diff --git a/packages/views/locales/en/agents.json b/packages/views/locales/en/agents.json index 327755cad5..1523068f4a 100644 --- a/packages/views/locales/en/agents.json +++ b/packages/views/locales/en/agents.json @@ -345,7 +345,18 @@ "events_filtered": "{{shown}} of {{total}} events", "copy_all": "Copy all", "copy_filtered": "Copy filtered", + "copy_diff": "Copy diff", + "copy_failed": "Copy failed", "copied": "Copied", + "file_changes": "File changes", + "file_content": "File content", + "diff_unified": "Unified", + "diff_split": "Split", + "switch_to_diff_unified": "Switch to unified diff view", + "switch_to_diff_split": "Switch to split diff view", + "no_visual_diff": "No visual diff available for this file change.", + "show_all_lines": "Show all {{count}} lines", + "collapse": "Collapse", "waiting_events": "Waiting for events...", "no_data": "No execution data recorded." }, diff --git a/packages/views/locales/zh-Hans/agents.json b/packages/views/locales/zh-Hans/agents.json index 516b96450e..e3f1889ba8 100644 --- a/packages/views/locales/zh-Hans/agents.json +++ b/packages/views/locales/zh-Hans/agents.json @@ -337,7 +337,18 @@ "events_filtered": "{{shown}} / {{total}} 个事件", "copy_all": "全部复制", "copy_filtered": "复制筛选结果", + "copy_diff": "复制差异", + "copy_failed": "复制失败", "copied": "已复制", + "file_changes": "文件变更", + "file_content": "文件内容", + "diff_unified": "统一差异", + "diff_split": "拆分差异", + "switch_to_diff_unified": "切换到统一差异视图", + "switch_to_diff_split": "切换到拆分差异视图", + "no_visual_diff": "该次文件修改未提供可视化差异。", + "show_all_lines": "显示全部 {{count}} 行", + "collapse": "收起", "waiting_events": "等待事件中...", "no_data": "未记录执行数据。" }, diff --git a/server/pkg/agent/codex.go b/server/pkg/agent/codex.go index 72d7a5691a..8c313f154a 100644 --- a/server/pkg/agent/codex.go +++ b/server/pkg/agent/codex.go @@ -288,6 +288,14 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti // Wait for the reader goroutine to finish so all output is accumulated. <-readerDone + // Flush any buffered turn-level diff so abnormal exit paths (timeout, + // cancellation, abort) still record the latest snapshot. Run AFTER + // readerDone so any turn/diff/updated still in the stdout pipe at the + // moment the wait loop exited is buffered first. Normal turn endings + // already flushed during turn/completed, thread/status idle, or + // final_answer; a second call here is a safe no-op for them. + c.flushTurnDiff(c.turnID) + outputMu.Lock() finalOutput := output.String() outputMu.Unlock() @@ -455,6 +463,10 @@ type codexClient struct { turnErrorMu sync.Mutex turnError string // captured from turn/completed status=failed or terminal error notifications + + fileChangeDeltaMu sync.Mutex + fileChangeDeltas map[string]string + lastTurnDiffs map[string]string } func (c *codexClient) setTurnError(msg string) { @@ -681,8 +693,10 @@ func (c *codexClient) handleNotification(raw map[string]json.RawMessage) { // Raw v2 notifications if c.notificationProtocol != "legacy" { if c.notificationProtocol == "unknown" && - (method == "turn/started" || method == "turn/completed" || - method == "thread/started" || strings.HasPrefix(method, "item/")) { + (strings.HasPrefix(method, "turn/") || + strings.HasPrefix(method, "thread/") || + strings.HasPrefix(method, "item/") || + method == "error") { c.notificationProtocol = "raw" } @@ -739,11 +753,13 @@ func (c *codexClient) handleEvent(msg map[string]any) { } case "patch_apply_end": callID, _ := msg["call_id"].(string) + output, _ := msg["output"].(string) if c.onMessage != nil { c.onMessage(Message{ Type: MessageToolResult, Tool: "patch_apply", CallID: callID, + Output: output, }) } case "task_complete": @@ -783,6 +799,17 @@ func (c *codexClient) handleRawNotification(method string, params map[string]any c.onMessage(Message{Type: MessageStatus, Status: "running", SessionID: c.threadID}) } + case "turn/diff/updated": + if c.onSemanticActivity != nil { + c.onSemanticActivity("turn/diff/updated") + } + turnID, _ := params["turnId"].(string) + if turnID == "" { + turnID = c.turnID + } + diff, _ := params["diff"].(string) + c.emitTurnDiffUpdated(turnID, diff) + case "turn/completed": turnID := extractNestedString(params, "turn", "id") status := extractNestedString(params, "turn", "status") @@ -816,6 +843,10 @@ func (c *codexClient) handleRawNotification(method string, params map[string]any c.extractUsageFromMap(turn) } + // Flush any buffered turn-level diff before signaling done so the + // final aggregate diff lands on the timeline as a single entry. + c.flushTurnDiff(turnID) + if c.onTurnDone != nil { c.onTurnDone(aborted) } @@ -839,6 +870,7 @@ func (c *codexClient) handleRawNotification(method string, params map[string]any case "thread/status/changed": statusType := extractNestedString(params, "status", "type") if statusType == "idle" && c.turnStarted { + c.flushTurnDiff(c.turnID) if c.onTurnDone != nil { c.onTurnDone(false) } @@ -886,6 +918,7 @@ func (c *codexClient) handleItemNotification(method string, params map[string]an } case method == "item/started" && itemType == "fileChange": + c.clearFileChangeDelta(itemID) if c.onMessage != nil { c.onMessage(Message{ Type: MessageToolUse, @@ -894,25 +927,43 @@ func (c *codexClient) handleItemNotification(method string, params map[string]an }) } + case method == "item/fileChange/outputDelta" && itemType == "fileChange": + delta, _ := params["delta"].(string) + c.appendFileChangeDelta(itemID, delta) + case method == "item/completed" && itemType == "fileChange": + output := c.popFileChangeDelta(itemID) + if output == "" { + if aggregatedOutput, ok := item["aggregatedOutput"].(string); ok { + output = aggregatedOutput + } else if inlineOutput, ok := item["output"].(string); ok { + output = inlineOutput + } + } if c.onMessage != nil { c.onMessage(Message{ Type: MessageToolResult, Tool: "patch_apply", CallID: itemID, + Output: output, }) } case method == "item/completed" && itemType == "agentMessage": text, _ := item["text"].(string) + phase, _ := item["phase"].(string) + isFinalAnswer := phase == "final_answer" && c.turnStarted + if isFinalAnswer { + // Flush the buffered diff before emitting the final-answer text: + // turn/diff/updated arrived earlier, so the transcript must show + // the patch_apply row above the final-answer message. + c.flushTurnDiff(c.turnID) + } if text != "" && c.onMessage != nil { c.onMessage(Message{Type: MessageText, Content: text}) } - phase, _ := item["phase"].(string) - if phase == "final_answer" && c.turnStarted { - if c.onTurnDone != nil { - c.onTurnDone(false) - } + if isFinalAnswer && c.onTurnDone != nil { + c.onTurnDone(false) } } } @@ -939,6 +990,103 @@ func describeCodexItemProgressActivity(method, itemType, itemID string) string { return fmt.Sprintf("%s:%s:%s", method, itemType, itemID) } +func (c *codexClient) clearFileChangeDelta(itemID string) { + if itemID == "" { + return + } + c.fileChangeDeltaMu.Lock() + defer c.fileChangeDeltaMu.Unlock() + if c.fileChangeDeltas == nil { + c.fileChangeDeltas = make(map[string]string) + return + } + delete(c.fileChangeDeltas, itemID) +} + +func (c *codexClient) appendFileChangeDelta(itemID, delta string) { + if itemID == "" || delta == "" { + return + } + c.fileChangeDeltaMu.Lock() + defer c.fileChangeDeltaMu.Unlock() + if c.fileChangeDeltas == nil { + c.fileChangeDeltas = make(map[string]string) + } + c.fileChangeDeltas[itemID] += delta +} + +func (c *codexClient) popFileChangeDelta(itemID string) string { + if itemID == "" { + return "" + } + c.fileChangeDeltaMu.Lock() + defer c.fileChangeDeltaMu.Unlock() + if c.fileChangeDeltas == nil { + return "" + } + output := c.fileChangeDeltas[itemID] + delete(c.fileChangeDeltas, itemID) + return output +} + +// emitTurnDiffUpdated buffers the latest aggregate diff for a turn. The diff +// is held until the turn finishes (via turn/completed, thread/status idle, +// final_answer agentMessage, or an abnormal exit such as timeout/cancel) and +// is emitted by flushTurnDiff. Codex can send several turn/diff/updated +// notifications per turn as the agent edits and revises files; appending +// each snapshot to the append-only task timeline would leave stale rows for +// edit-then-revert sequences. Buffering emits only the final state. +func (c *codexClient) emitTurnDiffUpdated(turnID, diff string) { + key := turnID + if key == "" { + key = "_unknown" + } + + c.fileChangeDeltaMu.Lock() + if c.lastTurnDiffs == nil { + c.lastTurnDiffs = make(map[string]string) + } + c.lastTurnDiffs[key] = diff + c.fileChangeDeltaMu.Unlock() +} + +// flushTurnDiff emits the buffered diff for a completed turn, if any. Empty +// diffs (turns that end with no net file changes) are dropped so no stale +// patch_apply row appears on the timeline. +func (c *codexClient) flushTurnDiff(turnID string) { + key := turnID + if key == "" { + key = "_unknown" + } + + c.fileChangeDeltaMu.Lock() + diff, ok := c.lastTurnDiffs[key] + if ok { + delete(c.lastTurnDiffs, key) + } + c.fileChangeDeltaMu.Unlock() + + if !ok || diff == "" { + return + } + + if c.onMessage != nil { + c.onMessage(Message{ + Type: MessageToolResult, + Tool: "patch_apply", + CallID: codexTurnDiffCallID(turnID), + Output: diff, + }) + } +} + +func codexTurnDiffCallID(turnID string) string { + if turnID == "" { + return "turn-diff" + } + return turnID + ":diff" +} + // extractUsageFromMap extracts token usage from a map that may contain // "usage", "token_usage", or "tokens" fields. Handles various Codex formats. func (c *codexClient) extractUsageFromMap(data map[string]any) { diff --git a/server/pkg/agent/codex_test.go b/server/pkg/agent/codex_test.go index 7e6d88a63e..fe189ba486 100644 --- a/server/pkg/agent/codex_test.go +++ b/server/pkg/agent/codex_test.go @@ -515,6 +515,217 @@ func TestCodexRawItemCommandExecution(t *testing.T) { } } +func TestCodexRawItemFileChangeAggregatesOutputDelta(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"item/started","params":{"item":{"type":"fileChange","id":"patch-1"}}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"item/fileChange/outputDelta","params":{"item":{"type":"fileChange","id":"patch-1"},"delta":"--- a/a.txt\n+++ b/a.txt\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"item/fileChange/outputDelta","params":{"item":{"type":"fileChange","id":"patch-1"},"delta":"@@ -1 +1 @@\n-old\n+new\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"fileChange","id":"patch-1"}}}`) + + if len(messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(messages)) + } + if messages[0].Type != MessageToolUse || messages[0].Tool != "patch_apply" || messages[0].CallID != "patch-1" { + t.Fatalf("unexpected start message: %+v", messages[0]) + } + if messages[1].Type != MessageToolResult || messages[1].Tool != "patch_apply" || messages[1].CallID != "patch-1" { + t.Fatalf("unexpected complete message: %+v", messages[1]) + } + if messages[1].Output != "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n" { + t.Fatalf("unexpected aggregated diff output: %q", messages[1].Output) + } +} + +func TestCodexRawTurnDiffEmitsFinalDiffOnTurnCompleted(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + c.threadID = "thr-main" + c.turnID = "turn-1" + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-other","turnId":"turn-2","diff":"--- a/b.txt\n+++ b/b.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) + + if len(messages) != 0 { + t.Fatalf("expected no diff messages before turn/completed, got %d: %+v", len(messages), messages) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thr-main","turn":{"id":"turn-1","status":"completed"}}}`) + + if len(messages) != 1 { + t.Fatalf("expected one buffered patch diff message, got %d: %+v", len(messages), messages) + } + if messages[0].Type != MessageToolResult || messages[0].Tool != "patch_apply" || messages[0].CallID != "turn-1:diff" { + t.Fatalf("unexpected diff message: %+v", messages[0]) + } + if messages[0].Output != "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n" { + t.Fatalf("unexpected diff output: %q", messages[0].Output) + } +} + +func TestCodexRawTurnDiffSuppressesTransientSnapshots(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + c.threadID = "thr-main" + c.turnID = "turn-1" + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + // Edit, then revise to a different diff. Only the latest should survive. + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+intermediate\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+final\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thr-main","turn":{"id":"turn-1","status":"completed"}}}`) + + if len(messages) != 1 { + t.Fatalf("expected only the final diff to be emitted, got %d: %+v", len(messages), messages) + } + if messages[0].Output != "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+final\n" { + t.Fatalf("expected final diff, got %q", messages[0].Output) + } +} + +func TestCodexRawTurnDiffFlushesOnFinalAnswer(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + c.threadID = "thr-main" + c.turnID = "turn-1" + c.turnStarted = true + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) + // Turn finishes via final_answer agentMessage instead of turn/completed. + c.handleLine(`{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-main","item":{"type":"agentMessage","id":"msg-1","text":"Done","phase":"final_answer"}}}`) + + var diffIdx, textIdx int = -1, -1 + for i, m := range messages { + if m.Tool == "patch_apply" && m.CallID == "turn-1:diff" { + diffIdx = i + } + if m.Type == MessageText && m.Content == "Done" { + textIdx = i + } + } + if diffIdx < 0 { + t.Fatalf("expected buffered diff to flush on final_answer, got messages: %+v", messages) + } + if textIdx < 0 { + t.Fatalf("expected final-answer text to be emitted, got messages: %+v", messages) + } + // turn/diff/updated arrived before the final-answer text, so the + // transcript ordering must preserve that: diff first, text second. + if diffIdx > textIdx { + t.Fatalf("expected diff (idx=%d) to be emitted before final-answer text (idx=%d): %+v", diffIdx, textIdx, messages) + } + if messages[diffIdx].Output != "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n" { + t.Fatalf("unexpected diff output: %q", messages[diffIdx].Output) + } +} + +func TestCodexRawTurnDiffFlushesOnThreadIdle(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + c.threadID = "thr-main" + c.turnID = "turn-1" + c.turnStarted = true + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) + // Turn finishes via thread/status idle instead of turn/completed. + c.handleLine(`{"jsonrpc":"2.0","method":"thread/status/changed","params":{"threadId":"thr-main","status":{"type":"idle"}}}`) + + var diffMessages []Message + for _, m := range messages { + if m.Tool == "patch_apply" && m.CallID == "turn-1:diff" { + diffMessages = append(diffMessages, m) + } + } + if len(diffMessages) != 1 { + t.Fatalf("expected buffered diff to flush on thread/status idle, got %d: %+v", len(diffMessages), diffMessages) + } + if diffMessages[0].Output != "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n" { + t.Fatalf("unexpected diff output: %q", diffMessages[0].Output) + } +} + +func TestCodexRawTurnDiffSkipsEmptyFinalSnapshot(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + c.threadID = "thr-main" + c.turnID = "turn-1" + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + // Edit, then revert: turn ends with no net change. No patch_apply entry + // should be appended to the append-only timeline. + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":""}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thr-main","turn":{"id":"turn-1","status":"completed"}}}`) + + if len(messages) != 0 { + t.Fatalf("expected no diff messages when final snapshot is empty, got %d: %+v", len(messages), messages) + } +} + +func TestCodexRawItemFileChangeUsesAggregatedOutputFallback(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"item/started","params":{"item":{"type":"fileChange","id":"patch-1"}}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"fileChange","id":"patch-1","aggregatedOutput":"patched: a.txt"}}}`) + + if len(messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(messages)) + } + if messages[1].Type != MessageToolResult || messages[1].Output != "patched: a.txt" { + t.Fatalf("unexpected complete message: %+v", messages[1]) + } +} + func TestCodexRawItemAgentMessageFinalAnswer(t *testing.T) { t.Parallel() @@ -1161,7 +1372,7 @@ func TestCodexExecuteSemanticInactivityAllowsContinuousDeltaProgress(t *testing. `sleep 0.05`+"\n"+ `echo '{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"threadId":"thr-delta","item":{"type":"agentMessage","id":"msg-1"},"delta":"thinking"}}'`+"\n"+ `sleep 0.05`+"\n"+ - `echo '{"jsonrpc":"2.0","method":"item/fileChange/outputDelta","params":{"threadId":"thr-delta","item":{"type":"fileChange","id":"patch-1"},"delta":"patched"}}'`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-delta","turnId":"turn-delta","diff":"--- a/a.txt\n+++ b/a.txt\n"}}'`+"\n"+ `sleep 0.05`+"\n"+ `echo '{"jsonrpc":"2.0","method":"item/mcpToolCall/progress","params":{"threadId":"thr-delta","item":{"type":"mcpToolCall","id":"mcp-1"},"progress":{"message":"still running"}}}'`+"\n"+ `sleep 0.05`+"\n"+ @@ -1206,6 +1417,81 @@ func TestCodexExecuteSemanticInactivityDoesNotAffectNormalTurnCompletion(t *test } } +func TestCodexExecuteFlushesBufferedDiffOnSemanticInactivityTimeout(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is POSIX-only") + } + + // Fake codex emits turn/started + turn/diff/updated and then hangs. + // Semantic inactivity timeout will fire before turn/completed arrives. + // Use printf instead of echo so the JSON \n escapes are emitted as + // literal "\n" bytes (not interpreted as newlines by the shell). + fakePath := writeFakeCodexAppServer(t, ""+ + `read line`+"\n"+ + `printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{}}'`+"\n"+ + `read line`+"\n"+ + `read line`+"\n"+ + `printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thr-hang"}}}'`+"\n"+ + `read line`+"\n"+ + `printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{}}'`+"\n"+ + `printf '%s\n' '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-hang","turn":{"id":"turn-hang"}}}'`+"\n"+ + `sleep 0.1`+"\n"+ + `printf '%s\n' '{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-hang","turnId":"turn-hang","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}'`+"\n"+ + // Stay alive long enough for semantic inactivity timeout to fire after + // the diff is processed. + `sleep 5`+"\n") + + backend, err := New("codex", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + if err != nil { + t.Fatalf("new codex backend: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + session, err := backend.Execute(ctx, "prompt", ExecOptions{ + Timeout: 5 * time.Second, + SemanticInactivityTimeout: 1500 * time.Millisecond, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + + var diffSeen bool + var diffOutput string + var allMessages []Message + done := make(chan struct{}) + go func() { + defer close(done) + for msg := range session.Messages { + allMessages = append(allMessages, msg) + if msg.Tool == "patch_apply" && msg.CallID == "turn-hang:diff" { + diffSeen = true + diffOutput = msg.Output + } + } + }() + + select { + case result, ok := <-session.Result: + if !ok { + t.Fatal("result channel closed without a value") + } + if result.Status != "timeout" { + t.Fatalf("expected status=timeout, got %q (error=%q)", result.Status, result.Error) + } + case <-time.After(8 * time.Second): + t.Fatal("timeout waiting for result") + } + <-done + + if !diffSeen { + t.Fatalf("expected buffered diff to flush on semantic inactivity timeout, got messages: %+v", allMessages) + } + if diffOutput != "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n" { + t.Fatalf("unexpected flushed diff: %q", diffOutput) + } +} + func writeFakeCodexAppServer(t *testing.T, body string) string { t.Helper() fakePath := filepath.Join(t.TempDir(), "codex")