import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { toast } from "@tutti-os/ui-system"; import type { AgentHostApplyWorkspaceGitPatchInput, AgentHostApplyWorkspaceGitPatchResult } from "../../../host/agentHostApi"; import { AgentTurnSummaryRow } from "./AgentTurnSummaryRow"; vi.mock("../../../i18n/index", () => ({ translate: (key: string, values?: Record) => values ? `${key}:${Object.values(values).join(",")}` : key })); type MockTooltipProps = { children?: ReactNode }; vi.mock("@tutti-os/ui-system", () => { const Passthrough = ({ children }: MockTooltipProps) => <>{children}; return { Tooltip: Passthrough, TooltipProvider: Passthrough, TooltipTrigger: Passthrough, TooltipContent: ({ children }: MockTooltipProps) => (
{children}
), toast: { error: vi.fn() } }; }); describe("AgentTurnSummaryRow", () => { beforeEach(() => { Reflect.deleteProperty(window, "agentHostApi"); vi.mocked(toast.error).mockClear(); }); it("applies patch batches in Codex-compatible undo and reapply order", async () => { const patchCalls: AgentHostApplyWorkspaceGitPatchInput[] = []; const applyGitPatch = vi.fn( async ( input: AgentHostApplyWorkspaceGitPatchInput ): Promise => { patchCalls.push(input); return { appliedPaths: ["src/app.ts"], conflictedPaths: [], skippedPaths: [], status: "success" as const }; } ); window.agentHostApi = { ...(window.agentHostApi ?? {}), workspace: { ...(window.agentHostApi?.workspace ?? {}), applyGitPatch } } as unknown as typeof window.agentHostApi; render( ); fireEvent.click( screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryUndo" }) ); expect(screen.getByText("agentHost.agentGui.turnSummaryUndo")).toBeTruthy(); await waitFor(() => { expect(applyGitPatch).toHaveBeenCalledTimes(2); }); expect(patchCalls[0]).toMatchObject({ cwd: "/workspace/demo", revert: true }); expect(patchCalls[0]?.diff).toContain("-middle\n+new"); expect(patchCalls[1]?.diff).toContain("-old\n+middle"); expect( await screen.findByRole("button", { name: "agentHost.agentGui.turnSummaryReapply" }) ).toBeTruthy(); expect( screen.getByText("agentHost.agentGui.turnSummaryReapply") ).toBeTruthy(); fireEvent.click( screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryReapply" }) ); await waitFor(() => { expect(applyGitPatch).toHaveBeenCalledTimes(4); }); expect(patchCalls[2]).toMatchObject({ cwd: "/workspace/demo", revert: false }); expect(patchCalls[2]?.diff).toContain("-old\n+middle"); expect(patchCalls[3]?.diff).toContain("-middle\n+new"); }, 10_000); it("falls back to a single unified diff batch when patch batches are missing", async () => { const patchCalls: AgentHostApplyWorkspaceGitPatchInput[] = []; const applyGitPatch = vi.fn( async ( input: AgentHostApplyWorkspaceGitPatchInput ): Promise => { patchCalls.push(input); return { appliedPaths: ["src/app.ts"], conflictedPaths: [], skippedPaths: [], status: "success" }; } ); window.agentHostApi = { ...(window.agentHostApi ?? {}), workspace: { ...(window.agentHostApi?.workspace ?? {}), applyGitPatch } } as unknown as typeof window.agentHostApi; render( ); fireEvent.click( screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryUndo" }) ); await waitFor(() => { expect(applyGitPatch).toHaveBeenCalledTimes(1); }); expect(patchCalls[0]).toMatchObject({ cwd: "/workspace/demo", revert: true }); expect(patchCalls[0]?.diff).toContain( "diff --git a/src/app.ts b/src/app.ts" ); expect(patchCalls[0]?.diff).toContain("-old\n+new"); }); it("builds a fallback add-file patch from created content when patch batches are missing", async () => { const patchCalls: AgentHostApplyWorkspaceGitPatchInput[] = []; const applyGitPatch = vi.fn( async ( input: AgentHostApplyWorkspaceGitPatchInput ): Promise => { patchCalls.push(input); return { appliedPaths: ["hello_world.md"], conflictedPaths: [], skippedPaths: [], status: "success" }; } ); window.agentHostApi = { ...(window.agentHostApi ?? {}), workspace: { ...(window.agentHostApi?.workspace ?? {}), applyGitPatch } } as unknown as typeof window.agentHostApi; render( ); fireEvent.click( screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryUndo" }) ); await waitFor(() => { expect(applyGitPatch).toHaveBeenCalledTimes(1); }); expect(patchCalls[0]).toMatchObject({ cwd: "/Users/demo/02-git-clean", revert: true }); expect(patchCalls[0]?.diff).toContain( "diff --git a/hello_world.md b/hello_world.md" ); expect(patchCalls[0]?.diff).toContain("new file mode 100644"); expect(patchCalls[0]?.diff).toContain("+# Hello, world!"); }); it("disables undo when recorded patch batches are not executable", () => { const applyGitPatch = vi.fn(); window.agentHostApi = { ...(window.agentHostApi ?? {}), workspace: { ...(window.agentHostApi?.workspace ?? {}), applyGitPatch } } as unknown as typeof window.agentHostApi; render( ); const button = screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryUndo" }); expect(button.hasAttribute("disabled")).toBe(true); expect(screen.getByRole("tooltip").textContent).toContain( "agentHost.agentGui.turnSummaryPatchUnavailable" ); fireEvent.click(button); expect(applyGitPatch).not.toHaveBeenCalled(); }); it("maps Codex /workspace patch cwd to the host workspace root before git calls", async () => { const patchCalls: AgentHostApplyWorkspaceGitPatchInput[] = []; const supportCalls: string[] = []; const applyGitPatch = vi.fn( async ( input: AgentHostApplyWorkspaceGitPatchInput ): Promise => { patchCalls.push(input); return { appliedPaths: ["hello_world.md"], conflictedPaths: [], skippedPaths: [], status: "success" }; } ); const resolveGitPatchSupport = vi.fn(async ({ cwd }: { cwd: string }) => { supportCalls.push(cwd); return { root: cwd, supported: true }; }); window.agentHostApi = { ...(window.agentHostApi ?? {}), workspace: { ...(window.agentHostApi?.workspace ?? {}), applyGitPatch, resolveGitPatchSupport } } as unknown as typeof window.agentHostApi; render( ); await waitFor(() => { expect(resolveGitPatchSupport).toHaveBeenCalledTimes(1); }); expect(supportCalls).toEqual(["/Users/demo/02-git-clean"]); fireEvent.click( screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryUndo" }) ); await waitFor(() => { expect(applyGitPatch).toHaveBeenCalledTimes(1); }); expect(patchCalls[0]).toMatchObject({ cwd: "/Users/demo/02-git-clean", revert: true }); expect(patchCalls[0]?.diff).toContain( "diff --git a/hello_world.md b/hello_world.md" ); expect(patchCalls[0]?.diff).not.toContain("/workspace"); }); it("shows patch failures through the host toast instead of an inline card message", async () => { vi.mocked(toast.error).mockClear(); const hostToastError = vi.fn(); const applyGitPatch = vi.fn( async (): Promise => ({ appliedPaths: [], conflictedPaths: [], errorCode: "invalid-patch", skippedPaths: [], status: "error" }) ); window.agentHostApi = { ...(window.agentHostApi ?? {}), toast: { error: hostToastError }, workspace: { ...(window.agentHostApi?.workspace ?? {}), applyGitPatch } } as unknown as typeof window.agentHostApi; render( ); fireEvent.click( screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryUndo" }) ); await waitFor(() => { expect(hostToastError).toHaveBeenCalledWith( "agentHost.agentGui.turnSummaryInvalidPatch" ); }); expect(toast.error).not.toHaveBeenCalled(); expect( screen.queryByText("agentHost.agentGui.turnSummaryUndoFailed") ).toBeNull(); expect( screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryUndo" }) ).toBeTruthy(); }); it("falls back to the ui-system toast when the host has no toast capability", async () => { vi.mocked(toast.error).mockClear(); const applyGitPatch = vi.fn( async (): Promise => ({ appliedPaths: [], conflictedPaths: [], skippedPaths: [], status: "error" }) ); window.agentHostApi = { ...(window.agentHostApi ?? {}), workspace: { ...(window.agentHostApi?.workspace ?? {}), applyGitPatch } } as unknown as typeof window.agentHostApi; render( ); fireEvent.click( screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryUndo" }) ); await waitFor(() => { expect(toast.error).toHaveBeenCalledWith( "agentHost.agentGui.turnSummaryUndoFailed" ); }); }); it("disables undo with a git repository hint when the cwd is not a git repo", async () => { const applyGitPatch = vi.fn(); const resolveGitPatchSupport = vi.fn(async () => ({ errorCode: "not-git-repo", supported: false })); window.agentHostApi = { ...(window.agentHostApi ?? {}), workspace: { ...(window.agentHostApi?.workspace ?? {}), applyGitPatch, resolveGitPatchSupport } } as unknown as typeof window.agentHostApi; render( ); await waitFor(() => { expect(resolveGitPatchSupport).toHaveBeenCalledWith({ cwd: "/workspace/no-git" }); }); const button = screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryUndo" }); await waitFor(() => { expect(button).toHaveAttribute("disabled"); }); expect(button.textContent).toContain("agentHost.agentGui.turnSummaryUndo"); expect(screen.getByRole("tooltip")).toHaveTextContent( "agentHost.agentGui.turnSummaryGitRequired" ); fireEvent.click(button); expect(applyGitPatch).not.toHaveBeenCalled(); }); it("keeps undo available when git support probing fails", async () => { const applyGitPatch = vi.fn( async (): Promise => ({ appliedPaths: ["src/app.ts"], conflictedPaths: [], skippedPaths: [], status: "success" }) ); const resolveGitPatchSupport = vi.fn(async () => { throw new Error("probe failed"); }); window.agentHostApi = { ...(window.agentHostApi ?? {}), workspace: { ...(window.agentHostApi?.workspace ?? {}), applyGitPatch, resolveGitPatchSupport } } as unknown as typeof window.agentHostApi; render( ); await waitFor(() => { expect(resolveGitPatchSupport).toHaveBeenCalledWith({ cwd: "/workspace/demo" }); }); const button = screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryUndo" }); await waitFor(() => { expect(button).not.toHaveAttribute("disabled"); }); fireEvent.click(button); await waitFor(() => { expect(applyGitPatch).toHaveBeenCalledTimes(1); }); }); it("keeps undo visible but disabled when no reversible patch data is available", () => { const applyGitPatch = vi.fn(); window.agentHostApi = { ...(window.agentHostApi ?? {}), workspace: { ...(window.agentHostApi?.workspace ?? {}), applyGitPatch } } as unknown as typeof window.agentHostApi; render( ); const button = screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryUndo" }); expect(button).toHaveAttribute("disabled"); expect(screen.getByRole("tooltip")).toHaveTextContent( "agentHost.agentGui.turnSummaryPatchUnavailable" ); fireEvent.click(button); expect(applyGitPatch).not.toHaveBeenCalled(); }); it("does not dispatch undo for a one-sided modified file", () => { const applyGitPatch = vi.fn(); window.agentHostApi = { ...(window.agentHostApi ?? {}), workspace: { ...(window.agentHostApi?.workspace ?? {}), applyGitPatch } } as unknown as typeof window.agentHostApi; render( ); const button = screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryUndo" }); expect(button.hasAttribute("disabled")).toBe(true); expect(screen.getByRole("tooltip").textContent).toContain( "agentHost.agentGui.turnSummaryPatchUnavailable" ); fireEvent.click(button); expect(applyGitPatch).not.toHaveBeenCalled(); }); it("does not partially undo a turn when patch batches miss a visible sibling", () => { const applyGitPatch = vi.fn(); window.agentHostApi = { ...(window.agentHostApi ?? {}), workspace: { ...(window.agentHostApi?.workspace ?? {}), applyGitPatch } } as unknown as typeof window.agentHostApi; render( ); const button = screen.getByRole("button", { name: "agentHost.agentGui.turnSummaryUndo" }); expect(button.hasAttribute("disabled")).toBe(true); fireEvent.click(button); expect(applyGitPatch).not.toHaveBeenCalled(); }); it("renders a change card with expandable file diff rows and dispatches workspace actions", async () => { const onLinkAction = vi.fn(); render( ); expect( screen.getByText("agentHost.agentGui.turnSummaryFilesChanged:2") ).toBeTruthy(); expect(screen.getByText("+2")).toBeTruthy(); expect(screen.getByText("-2")).toBeTruthy(); expect( screen .getByText("agentHost.agentGui.turnSummaryFilesChanged:2") .closest("div")?.className ).toContain("text-[15px]"); expect( screen .getByText("agentHost.agentGui.turnSummaryFilesChanged:2") .closest("div")?.className ).toContain("text-[var(--text-primary)]"); expect(screen.getByText("+2").parentElement?.className).toContain( "inline-flex" ); expect(screen.getByText("+2").className).toContain( "workspace-agents-status-panel__detail-tool-diff-added" ); expect(screen.getByText("+2").className).not.toContain( "tsh-ui-pill-success" ); expect(screen.getByText("+2").className).not.toContain("text-emerald"); expect(screen.getByText("-2").className).toContain( "workspace-agents-status-panel__detail-tool-diff-removed" ); expect(screen.getByText("-2").className).not.toContain( "tsh-ui-pill-danger" ); expect(screen.getByText("-2").className).not.toContain("text-rose"); expect( screen.getByText("+2").parentElement?.parentElement?.className ).toContain("items-center"); expect(screen.getByText("+2").parentElement?.parentElement).toBe( screen.getByText("agentHost.agentGui.turnSummaryFilesChanged:2") .parentElement ); expect( screen .getByText("agentHost.agentGui.turnSummaryFilesChanged:2") .closest("section")?.firstElementChild?.className ).toContain("agent-turn-summary-card"); expect( screen .getByText("agentHost.agentGui.turnSummaryFilesChanged:2") .closest("section")?.firstElementChild?.firstElementChild?.className ).toContain("items-center"); expect( screen .getByText("agentHost.agentGui.turnSummaryFilesChanged:2") .closest("section")?.firstElementChild?.className ).not.toContain("border-[var(--line-1)]"); expect(screen.queryByText("Changed files")).toBeNull(); expect( screen.queryByText("agentHost.agentGui.turnSummaryOpenFile") ).toBeNull(); const firstFileToggle = screen.getAllByRole("button", { name: /src\/app\.ts/i })[0]!; expect(firstFileToggle.className).toContain("items-center"); expect(firstFileToggle.className).toContain("gap-1"); expect( firstFileToggle .querySelector("svg") ?.classList.contains("lucide-chevron-right") ).toBe(true); expect( firstFileToggle.querySelector(".lucide-chevron-right")?.parentElement ?.className ).toContain("text-[var(--text-tertiary)]"); expect( firstFileToggle.querySelector(".lucide-chevron-right")?.parentElement ?.className ).toContain("opacity-0"); expect( firstFileToggle.querySelector(".lucide-chevron-right")?.parentElement ?.className ).toContain("group-hover/file-toggle:opacity-100"); expect( firstFileToggle.querySelector(".lucide-chevron-right")?.className ).toContain("transition-transform"); expect(firstFileToggle.querySelector(".lucide-file-text")).toBeNull(); expect(firstFileToggle.querySelector(".lucide-file-pen-line")).toBeNull(); expect(firstFileToggle.querySelector(".lucide-file-plus-2")).toBeNull(); expect(screen.getByTitle("src/app.ts").className).toContain( "text-[var(--text-secondary)]" ); expect(screen.getByTitle("src/app.ts").className).toContain("text-[13px]"); expect( screen .getByText("agentHost.agentGui.turnSummaryFilesChanged:2") .closest("section")?.className ).toContain("workspace-agents-status-panel__detail-turn-summary"); fireEvent.click(firstFileToggle); expect( firstFileToggle.querySelector(".lucide-chevron-right")?.className ).toContain("rotate-90"); await waitFor(() => { expect(screen.getByText("const ready = false")).toBeTruthy(); expect(screen.getByText("const ready = true")).toBeTruthy(); }); expect( screen .getByText("const ready = false") .closest(".workspace-agents-status-panel__detail-tool-diff")?.className ).toContain("border-[var(--line-2)]"); expect( screen .getByText("const ready = false") .closest(".workspace-agents-status-panel__detail-tool-diff") ?.parentElement?.className ).not.toContain("bg-[color:var(--transparency-block)]"); expect( screen .getByText("const ready = false") .closest(".workspace-agents-status-panel__detail-tool-diff") ?.parentElement?.className ).toContain("rounded-none"); await waitFor(() => { expect( screen .getByText("const ready = false") .closest(".agent-collapsible-reveal") ).toHaveAttribute("data-expanded", "true"); }); const openFileButton = screen.getByRole("button", { name: /agentHost\.workspaceAgentSessionDetailOpenFile:src\/app\.ts/i }); expect(openFileButton.className).toContain("h-7"); expect(openFileButton.className).toContain("w-7"); expect(openFileButton.className).not.toContain("h-8"); expect(openFileButton.className).not.toContain("w-8"); fireEvent.click(openFileButton); expect( openFileButton.querySelector('svg[data-tutti-chrome-glyph="fill"]') ).toBeTruthy(); expect( openFileButton .querySelector('svg[data-tutti-chrome-glyph="fill"]') ?.getAttribute("class") ).toContain("text-[var(--text-secondary)]"); expect(onLinkAction).toHaveBeenCalled(); }); it("animates the overflow file list when there are more than three files", async () => { render( ({ label: `src/file-${index + 1}.ts`, path: `src/file-${index + 1}.ts`, fileName: `file-${index + 1}.ts`, directory: "src", changeType: "modified" as const, toolName: "Edit", messageId: `call:${index + 1}`, unifiedDiff: `diff --git a/src/file-${index + 1}.ts b/src/file-${index + 1}.ts\n--- a/src/file-${index + 1}.ts\n+++ b/src/file-${index + 1}.ts\n@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}\n`, occurredAtUnixMs: 20 })) }} workspaceRoot="/workspace/demo" label="Changed files" /> ); expect(screen.queryByTitle("src/file-4.ts")).toBeNull(); fireEvent.click( screen.getByRole("button", { name: /agentHost\.agentGui\.turnSummaryShowMoreFiles:1/i }) ); await waitFor(() => { expect(screen.getByTitle("src/file-4.ts")).toBeTruthy(); }); expect( screen.getByTitle("src/file-4.ts").closest(".agent-collapsible-reveal") ).toHaveAttribute("data-expanded", "true"); expect( screen.getByRole("button", { name: /agentHost\.agentGui\.turnSummaryShowFewerFiles/i }) ).toBeTruthy(); }); it("dispatches workspace actions for external absolute file changes", async () => { const onLinkAction = vi.fn(); const externalPath = "/var/folders/17/demo/T/codex-presentations/test-note.md"; render( ); fireEvent.click( screen.getAllByRole("button", { name: /test-note\.md/i })[0]! ); const openFileButton = await screen.findByRole("button", { name: /agentHost\.workspaceAgentSessionDetailOpenFile:\/var\/folders\/17\/demo\/T\/codex-presentations\/test-note\.md/i }); fireEvent.click(openFileButton); expect(onLinkAction).toHaveBeenCalledWith( expect.objectContaining({ path: externalPath, type: "open-workspace-file" }) ); }); it("opens relative file changes from the session cwd without a workspace root", () => { const onLinkAction = vi.fn(); render( ); fireEvent.click( screen.getByRole("button", { name: /agentHost\.workspaceAgentSessionDetailOpenFile:readme\.md/i }) ); expect(onLinkAction).toHaveBeenCalledWith({ type: "open-workspace-file", path: "/Users/demo/Documents/tutti/session-1/readme.md", directoryPath: "/Users/demo/Documents/tutti/session-1", workspaceRoot: "/Users/demo/Documents/tutti/session-1", source: "agent-file-change" }); }); it("renders created edit-file content without falling back to monaco loading", async () => { render( ); expect( screen.getByText("agentHost.agentGui.turnSummaryFilesChanged:1") ).toBeTruthy(); expect(screen.getAllByText("+1")).toHaveLength(2); expect(screen.queryByText("-1")).toBeNull(); expect( screen.queryByText("agentHost.agentGui.turnSummaryCreatedTag") ).toBeNull(); fireEvent.click( screen.getAllByRole("button", { name: /today\.txt/i })[0]! ); await waitFor(() => { expect(screen.getByText("2026-05-19")).toBeTruthy(); }); expect( screen.queryByText("agentHost.agentTool.details.loadingDiff") ).toBeNull(); }); it("counts raw created content as additions instead of deletions", () => { const content = [ "# 你好,我是 Liying 👋", "", "欢迎来到我的个人主页!", "", "我是 **Liying**,一名对学习、探索和创造充满热情的人。很高兴认识你!", "", "## 关于我", "", "- 👤 名字:Liying", "- 🌱 正在持续学习与成长", "- ✨ 喜欢尝试新事物、记录想法", "", "感谢你的访问!" ].join("\n"); render( ); expect(screen.getAllByText("+13")).toHaveLength(2); expect(screen.queryByText("-3")).toBeNull(); }); });