Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

1380 lines
41 KiB
TypeScript

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<string, unknown>) =>
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) => (
<div role="tooltip">{children}</div>
),
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<AgentHostApplyWorkspaceGitPatchResult> => {
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:patch-batches",
turnId: "turn-1",
fileCount: 2,
modifiedCount: 2,
createdCount: 0,
occurredAtUnixMs: 10,
files: [
{
label: "src/app.ts",
path: "src/app.ts",
fileName: "app.ts",
directory: "src",
changeType: "modified",
toolName: null,
messageId: "call:1",
unifiedDiff:
"diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-old\n+middle\n",
occurredAtUnixMs: 10
},
{
label: "src/app.ts",
path: "src/app.ts",
fileName: "app.ts",
directory: "src",
changeType: "modified",
toolName: null,
messageId: "call:2",
unifiedDiff:
"diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-middle\n+new\n",
occurredAtUnixMs: 11
}
],
patchBatches: [
{
cwd: "/workspace/demo",
toolCallId: "call:1",
changes: [
{
path: "src/app.ts",
changeType: "modified",
unifiedDiff: "@@ -1 +1 @@\n-old\n+middle"
}
]
},
{
cwd: "/workspace/demo",
toolCallId: "call:2",
changes: [
{
path: "src/app.ts",
changeType: "modified",
unifiedDiff: "@@ -1 +1 @@\n-middle\n+new"
}
]
}
]
}}
workspaceRoot="/workspace/demo"
label="Changed files"
/>
);
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<AgentHostApplyWorkspaceGitPatchResult> => {
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:unified-fallback",
turnId: "turn-1",
fileCount: 1,
modifiedCount: 1,
createdCount: 0,
occurredAtUnixMs: 10,
files: [
{
label: "src/app.ts",
path: "src/app.ts",
fileName: "app.ts",
directory: "src",
changeType: "modified",
toolName: null,
messageId: "call:1",
unifiedDiff:
"diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n",
occurredAtUnixMs: 10
}
]
}}
workspaceRoot="/workspace/demo"
label="Changed files"
/>
);
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<AgentHostApplyWorkspaceGitPatchResult> => {
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:created-content-fallback",
turnId: "turn-1",
fileCount: 1,
modifiedCount: 0,
createdCount: 1,
occurredAtUnixMs: 10,
files: [
{
label: "hello_world.md",
path: "/Users/demo/02-git-clean/hello_world.md",
fileName: "hello_world.md",
directory: "/Users/demo/02-git-clean",
changeType: "created",
toolName: null,
messageId: "call:1",
content: "# Hello, world!\n",
occurredAtUnixMs: 10
}
]
}}
workspaceRoot="/Users/demo/02-git-clean"
label="Changed files"
/>
);
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:bad-patch-batch-file-fallback",
turnId: "turn-1",
fileCount: 1,
modifiedCount: 0,
createdCount: 1,
occurredAtUnixMs: 10,
files: [
{
label: "hello_world.md",
path: "/Users/demo/02-git-clean/hello_world.md",
fileName: "hello_world.md",
directory: "/Users/demo/02-git-clean",
changeType: "created",
toolName: null,
messageId: "call:1",
unifiedDiff:
"diff --git a/hello_world.md b/hello_world.md\nnew file mode 100644\n--- /dev/null\n+++ b/hello_world.md\n@@ -0,0 +1,1 @@\n+# Hello, world!\n",
occurredAtUnixMs: 10
}
],
patchBatches: [
{
cwd: "/",
toolCallId: "call:1",
changes: [
{
path: "/Users/demo/02-git-clean/hello_world.md",
changeType: "created"
}
]
}
]
}}
workspaceRoot="/"
label="Changed files"
/>
);
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<AgentHostApplyWorkspaceGitPatchResult> => {
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:mapped-workspace",
turnId: "turn-1",
fileCount: 1,
modifiedCount: 0,
createdCount: 1,
occurredAtUnixMs: 10,
files: [
{
label: "/workspace/hello_world.md",
path: "/workspace/hello_world.md",
fileName: "hello_world.md",
directory: "/workspace",
changeType: "created",
toolName: null,
messageId: "call:1",
content: "# Hello, world!\n",
occurredAtUnixMs: 10
}
],
patchBatches: [
{
cwd: "/workspace",
toolCallId: "call:1",
changes: [
{
path: "/workspace/hello_world.md",
changeType: "created",
content: "# Hello, world!\n"
}
]
}
]
}}
workspaceRoot="/Users/demo/02-git-clean"
label="Changed files"
/>
);
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<AgentHostApplyWorkspaceGitPatchResult> => ({
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:failed-undo",
turnId: "turn-1",
fileCount: 1,
modifiedCount: 1,
createdCount: 0,
occurredAtUnixMs: 10,
files: [
{
label: "src/app.ts",
path: "src/app.ts",
fileName: "app.ts",
directory: "src",
changeType: "modified",
toolName: null,
messageId: "call:1",
unifiedDiff:
"diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n",
occurredAtUnixMs: 10
}
]
}}
workspaceRoot="/workspace/demo"
label="Changed files"
/>
);
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<AgentHostApplyWorkspaceGitPatchResult> => ({
appliedPaths: [],
conflictedPaths: [],
skippedPaths: [],
status: "error"
})
);
window.agentHostApi = {
...(window.agentHostApi ?? {}),
workspace: {
...(window.agentHostApi?.workspace ?? {}),
applyGitPatch
}
} as unknown as typeof window.agentHostApi;
render(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:fallback-toast",
turnId: "turn-1",
fileCount: 1,
modifiedCount: 1,
createdCount: 0,
occurredAtUnixMs: 10,
files: [
{
label: "src/app.ts",
path: "src/app.ts",
fileName: "app.ts",
directory: "src",
changeType: "modified",
toolName: null,
messageId: "call:1",
unifiedDiff:
"diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n",
occurredAtUnixMs: 10
}
]
}}
workspaceRoot="/workspace/demo"
label="Changed files"
/>
);
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:no-git",
turnId: "turn-1",
fileCount: 1,
modifiedCount: 1,
createdCount: 0,
occurredAtUnixMs: 10,
files: [
{
label: "src/app.ts",
path: "src/app.ts",
fileName: "app.ts",
directory: "src",
changeType: "modified",
toolName: null,
messageId: "call:1",
unifiedDiff:
"diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n",
occurredAtUnixMs: 10
}
]
}}
workspaceRoot="/workspace/no-git"
label="Changed files"
/>
);
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<AgentHostApplyWorkspaceGitPatchResult> => ({
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:probe-failure",
turnId: "turn-1",
fileCount: 1,
modifiedCount: 1,
createdCount: 0,
occurredAtUnixMs: 10,
files: [
{
label: "src/app.ts",
path: "src/app.ts",
fileName: "app.ts",
directory: "src",
changeType: "modified",
toolName: null,
messageId: "call:1",
unifiedDiff:
"diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n",
occurredAtUnixMs: 10
}
]
}}
workspaceRoot="/workspace/demo"
label="Changed files"
/>
);
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:missing-patch-data",
turnId: "turn-1",
fileCount: 1,
modifiedCount: 1,
createdCount: 0,
occurredAtUnixMs: 10,
files: [
{
label: "hello_world.md",
path: "/workspace/hello_world.md",
fileName: "hello_world.md",
directory: "/workspace",
changeType: "modified",
toolName: null,
messageId: "call:1",
occurredAtUnixMs: 10
}
]
}}
workspaceRoot="/Users/demo/01-no-git"
label="Changed files"
/>
);
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:one-sided-modified",
turnId: "turn-1",
fileCount: 1,
modifiedCount: 1,
createdCount: 0,
occurredAtUnixMs: 10,
files: [
{
label: "note.txt",
path: "/workspace/note.txt",
fileName: "note.txt",
directory: "/workspace",
changeType: "modified",
toolName: null,
messageId: "call:one-sided-modified",
newString: "after",
occurredAtUnixMs: 10
}
]
}}
workspaceRoot="/workspace"
label="Changed files"
/>
);
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:mixed-coverage",
turnId: "turn-1",
fileCount: 2,
modifiedCount: 1,
createdCount: 1,
occurredAtUnixMs: 10,
files: [
{
label: "created.txt",
path: "/workspace/created.txt",
fileName: "created.txt",
directory: "/workspace",
changeType: "created",
toolName: null,
messageId: "call:mixed-created",
newString: "created",
occurredAtUnixMs: 10
},
{
label: "incomplete.txt",
path: "/workspace/incomplete.txt",
fileName: "incomplete.txt",
directory: "/workspace",
changeType: "modified",
toolName: null,
messageId: "call:mixed-incomplete",
newString: "after",
occurredAtUnixMs: 10
}
],
patchBatches: [
{
cwd: "/workspace",
toolCallId: "call:mixed-created",
changes: [
{
path: "/workspace/created.txt",
changeType: "created",
oldString: "",
newString: "created"
}
]
}
]
}}
workspaceRoot="/workspace"
label="Changed files"
/>
);
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:1",
turnId: "turn-1",
fileCount: 2,
modifiedCount: 2,
createdCount: 0,
occurredAtUnixMs: 10,
files: [
{
label: "src/app.ts",
path: "src/app.ts",
fileName: "app.ts",
directory: "src",
changeType: "modified",
toolName: "Edit",
messageId: "call:1",
unifiedDiff:
"diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-const ready = false\n+const ready = true\n",
occurredAtUnixMs: 10
},
{
label: "src/feature.ts",
path: "src/feature.ts",
fileName: "feature.ts",
directory: "src",
changeType: "modified",
toolName: "Edit",
messageId: "call:2",
unifiedDiff:
"diff --git a/src/feature.ts b/src/feature.ts\n--- a/src/feature.ts\n+++ b/src/feature.ts\n@@ -1 +1 @@\n-export const enabled = false\n+export const enabled = true\n",
occurredAtUnixMs: 10
}
]
}}
workspaceRoot="/workspace/demo"
label="Changed files"
onLinkAction={onLinkAction}
/>
);
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:2",
turnId: "turn-2",
fileCount: 4,
modifiedCount: 4,
createdCount: 0,
occurredAtUnixMs: 20,
files: Array.from({ length: 4 }, (_, index) => ({
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:external",
turnId: "turn-external",
fileCount: 1,
modifiedCount: 0,
createdCount: 1,
occurredAtUnixMs: 25,
files: [
{
label: externalPath,
path: externalPath,
fileName: "test-note.md",
directory: "/var/folders/17/demo/T/codex-presentations",
changeType: "created",
toolName: "write_file",
messageId: "call:external",
content: "# Test note",
newString: "# Test note",
occurredAtUnixMs: 25
}
]
}}
workspaceRoot="/Users/demo/project"
label="Changed files"
onLinkAction={onLinkAction}
/>
);
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:no-project",
turnId: "turn-no-project",
fileCount: 1,
modifiedCount: 0,
createdCount: 1,
occurredAtUnixMs: 26,
files: [
{
label: "readme.md",
path: "readme.md",
fileName: "readme.md",
directory: null,
changeType: "created",
toolName: "Write",
messageId: "call:no-project",
content: "# LiYing",
occurredAtUnixMs: 26
}
]
}}
workspaceRoot={null}
basePath="/Users/demo/Documents/tutti/session-1"
label="Changed files"
onLinkAction={onLinkAction}
/>
);
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:3",
turnId: "turn-3",
fileCount: 1,
modifiedCount: 0,
createdCount: 1,
occurredAtUnixMs: 30,
files: [
{
label: "/workspace/today.txt",
path: "/workspace/today.txt",
fileName: "today.txt",
directory: "/workspace",
changeType: "created",
toolName: "Edit",
messageId: "call:3",
content: "2026-05-19",
newString: "2026-05-19",
occurredAtUnixMs: 30
}
]
}}
workspaceRoot="/workspace"
label="Changed files"
/>
);
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(
<AgentTurnSummaryRow
row={{
kind: "turn-summary",
id: "turn-summary:raw-created-content",
turnId: "turn-raw-created-content",
fileCount: 1,
modifiedCount: 0,
createdCount: 1,
occurredAtUnixMs: 40,
files: [
{
label: "README.md",
path: "/workspace/README.md",
fileName: "README.md",
directory: "/workspace",
changeType: "created",
toolName: "Edit",
messageId: "call:raw-created-content",
unifiedDiff: content,
occurredAtUnixMs: 40
}
]
}}
workspaceRoot="/workspace"
label="Changed files"
/>
);
expect(screen.getAllByText("+13")).toHaveLength(2);
expect(screen.queryByText("-3")).toBeNull();
});
});