Files
multica/packages/views/common/task-transcript/trace-event-presenter.test.ts
Eve a4f79b3f3a fix(transcript): correct the Chinese multi-file patch count semantics
Addresses review on #6158.

The summary is handed the number of files *beyond* the named one, but the
Chinese phrasing stated a total: "a.go 等 2 个文件" reads as two files including
a.go, so a three-file patch under-reported by one. English hides the
distinction ("+2 more"), which is why it survived the first pass.

Rewords zh-Hans to "另有 N 个文件". Japanese (他) and Korean (외) already read
as "besides", so their wording is unchanged.

Also renames the interpolation variable from `count` to `extra`, for two
reasons. i18next treats `count` as the plural selector — this very namespace
relies on that for events_one/events_other — so a plain number had no business
borrowing it. And the name is what a translator reads: `extra` cannot be
mistaken for a total the way `count` was.

Guards the whole bug class rather than just this string: a locale test asserts
every locale interpolates {{path}} and {{extra}} and never the reserved
{{count}}, and a presenter test pins that the injected number is the count of
additional files, not the total.

Verified: both new locale assertions fail against the reverted string and pass
now; rendering the real locale strings for a three-file patch yields "+2 more",
"另有 2 个文件", "他 2 件", "외 2개". 53 target tests pass, repo typecheck clean,
views lint back to its pre-existing 16 warnings and 0 errors.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 14:00:08 +08:00

571 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
collapseDiffContext,
parseUnifiedDiff,
stripShellWrapper,
traceEventCopyText,
traceEventDefaultExpanded,
traceEventDetail,
traceEventHasDetail,
traceEventKind,
traceEventLabel,
traceEventSummary,
traceToolArgSummary,
unwrapToolOutput,
} from "./trace-event-presenter";
describe("traceEventKind / traceEventLabel", () => {
it("maps the five persisted types and keeps unknown types as generic", () => {
expect(traceEventKind({ type: "text" })).toBe("agent");
expect(traceEventKind({ type: "thinking" })).toBe("thinking");
expect(traceEventKind({ type: "tool_use" })).toBe("tool_use");
expect(traceEventKind({ type: "tool_result" })).toBe("tool_result");
expect(traceEventKind({ type: "error" })).toBe("error");
expect(traceEventKind({ type: "provider_custom" })).toBe("generic");
});
it("shows provider-native tool names verbatim and surfaces raw unknown types", () => {
expect(traceEventLabel({ type: "tool_use", tool: "exec_command" })).toBe("exec_command");
expect(traceEventLabel({ type: "tool_result", tool: "patch_apply" })).toBe("patch_apply");
expect(traceEventLabel({ type: "tool_use" })).toBe("Tool");
expect(traceEventLabel({ type: "provider_custom" })).toBe("provider_custom");
});
});
describe("stripShellWrapper", () => {
it("strips login-shell wrappers but keeps bare commands", () => {
expect(stripShellWrapper("/bin/zsh -lc 'rm ./reply.md'")).toBe("rm ./reply.md");
expect(stripShellWrapper('/bin/bash -c "git status"')).toBe("git status");
expect(stripShellWrapper("sh -c 'ls -la'")).toBe("ls -la");
expect(stripShellWrapper("pnpm test")).toBe("pnpm test");
// Mismatched quotes are not a wrapper match.
expect(stripShellWrapper("/bin/zsh -lc 'echo hi\"")).toBe("/bin/zsh -lc 'echo hi\"");
});
});
describe("traceToolArgSummary", () => {
it("prefers query, then paths (shortened), then command with wrapper stripped", () => {
expect(traceToolArgSummary({ query: "flaky tests", command: "x" })).toBe("flaky tests");
expect(traceToolArgSummary({ file_path: "/a/b/c/d/e.ts" })).toBe(".../d/e.ts");
expect(traceToolArgSummary({ command: "/bin/zsh -lc 'kubectl get pods -n prd'" })).toBe(
"kubectl get pods -n prd",
);
});
it("falls back to the first short string value and tolerates empty input", () => {
expect(traceToolArgSummary({ n: 3, note: "short value" })).toBe("short value");
expect(traceToolArgSummary(undefined)).toBe("");
expect(traceToolArgSummary({})).toBe("");
});
});
describe("traceEventSummary", () => {
it("takes the first non-empty line for agent text", () => {
expect(traceEventSummary({ type: "text", content: "\n\nFirst line\nrest" })).toBe(
"First line",
);
});
it("collapses pretty-printed JSON output to a content preview, not a lone bracket", () => {
const output = '[\n {\n "id": "694c",\n "title": "x"\n }\n]';
expect(traceEventSummary({ type: "tool_result", output })).toBe(
'[ { "id": "694c", "title": "x" } ]',
);
});
it("unwraps a JSON-encoded result so the collapsed row shows no transport escaping", () => {
expect(
traceEventSummary({
type: "tool_result",
tool: "Bash",
output: '"target/release/deps/acceptance\\n 0 page faults\\n 0 swaps"',
}),
).toBe("target/release/deps/acceptance 0 page faults 0 swaps");
});
it("retains unknown events instead of dropping them", () => {
expect(traceEventSummary({ type: "custom", content: "payload" })).toBe("payload");
});
});
describe("traceEventCopyText", () => {
it("copies the full untruncated body, not the one-line summary", () => {
const longOutput = "line 1\n".repeat(60);
expect(traceEventCopyText({ type: "tool_result", tool: "Bash", output: longOutput })).toBe(
`[Bash] ${longOutput}`,
);
expect(
traceEventCopyText({ type: "tool_use", tool: "Bash", input: { command: "ls" } }),
).toBe('[Bash] {\n "command": "ls"\n}');
expect(traceEventCopyText({ type: "text", content: "full\nagent\nreply" })).toBe(
"[Agent] full\nagent\nreply",
);
});
it("copies a result as the terminal output it was, not its transport encoding", () => {
expect(
traceEventCopyText({ type: "tool_result", tool: "Bash", output: '"line 1\\nline 2"' }),
).toBe("[Bash] line 1\nline 2");
});
it("emits a bare label when the event has no body", () => {
expect(traceEventCopyText({ type: "tool_use", tool: "Bash" })).toBe("[Bash]");
});
});
describe("traceEventDefaultExpanded", () => {
const agent = { type: "text", content: "hello" };
const error = { type: "error", content: "boom" };
const thinking = { type: "thinking", content: "hmm" };
const tool = { type: "tool_use", tool: "Bash", input: { command: "ls" } };
it("smart: agent and error read without a click, process noise stays folded", () => {
expect(traceEventDefaultExpanded(agent, "smart")).toBe(true);
expect(traceEventDefaultExpanded(error, "smart")).toBe(true);
expect(traceEventDefaultExpanded(thinking, "smart")).toBe(false);
expect(traceEventDefaultExpanded(tool, "smart")).toBe(false);
});
it("expanded/collapsed override the hierarchy wholesale", () => {
expect(traceEventDefaultExpanded(thinking, "expanded")).toBe(true);
expect(traceEventDefaultExpanded(agent, "collapsed")).toBe(false);
});
it("a row without detail never expands", () => {
expect(traceEventDefaultExpanded({ type: "text" }, "expanded")).toBe(false);
expect(traceEventHasDetail({ type: "tool_use", input: {} })).toBe(false);
});
});
describe("unwrapToolOutput", () => {
it("decodes the one JSON string layer a result arrives wrapped in", () => {
expect(unwrapToolOutput('"line one\\nline two"')).toBe("line one\nline two");
expect(unwrapToolOutput('"{\\n \\"id\\": 1\\n}"')).toBe('{\n "id": 1\n}');
});
it("leaves anything that is not a wrapped string untouched", () => {
expect(unwrapToolOutput("plain text")).toBe("plain text");
expect(unwrapToolOutput('{"id": 1}')).toBe('{"id": 1}');
// A quoted-looking body that is not valid JSON must survive verbatim.
expect(unwrapToolOutput('"unterminated')).toBe('"unterminated');
expect(unwrapToolOutput("")).toBe("");
});
it("decodes only one layer, so a JSON document stays a document", () => {
expect(unwrapToolOutput('"[1, 2]"')).toBe("[1, 2]");
});
});
describe("traceEventDetail", () => {
it("renders a replacement edit as a diff, keyed off input shape not tool name", () => {
// Provider-native names differ (Edit, patch_apply, str_replace...), so the
// shape of the input is what identifies an edit.
const detail = traceEventDetail({
type: "tool_use",
tool: "patch_apply",
input: {
file_path: "/repo/src/counter.rs",
old_string: "let a = 1;\nlet b = 2;",
new_string: "let a = 1;\nlet b = 3;\nlet c = 4;",
},
});
expect(detail.kind).toBe("diff");
if (detail.kind !== "diff") return;
expect(detail.path).toBe("/repo/src/counter.rs");
expect(detail.lines).toEqual([
{ kind: "context", text: "let a = 1;" },
{ kind: "remove", text: "let b = 2;" },
{ kind: "add", text: "let b = 3;" },
{ kind: "add", text: "let c = 4;" },
]);
});
it("renders a whole-file write as plain content, not an all-additions diff", () => {
const detail = traceEventDetail({
type: "tool_use",
tool: "Write",
input: { file_path: "/repo/new.txt", content: "alpha\nbeta" },
});
expect(detail).toEqual({
kind: "file",
path: "/repo/new.txt",
text: "alpha\nbeta",
lineCount: 2,
});
});
it("still diffs an insertion whose old side is empty", () => {
// `content` marks a whole-file write; an empty old_string is an insertion
// into a file that already exists, so the diff gutter still carries meaning.
const detail = traceEventDetail({
type: "tool_use",
input: { file_path: "/f", old_string: "", new_string: "added" },
});
expect(detail.kind).toBe("diff");
if (detail.kind !== "diff") return;
expect(detail.lines).toEqual([{ kind: "add", text: "added" }]);
});
it("keeps a deletion visible when new_string is empty", () => {
const detail = traceEventDetail({
type: "tool_use",
input: { file_path: "/f", old_string: "gone", new_string: "" },
});
expect(detail.kind).toBe("diff");
if (detail.kind !== "diff") return;
expect(detail.lines).toEqual([{ kind: "remove", text: "gone" }]);
});
it("falls back to pretty JSON for a tool call that is not an edit", () => {
const detail = traceEventDetail({
type: "tool_use",
tool: "Bash",
input: { command: "ls -la" },
});
expect(detail.kind).toBe("text");
if (detail.kind !== "text") return;
expect(detail.text).toBe('{\n "command": "ls -la"\n}');
});
it("unwraps a tool result so it reads as the terminal output it was", () => {
const detail = traceEventDetail({
type: "tool_result",
tool: "Bash",
output: '"total 0\\ndrwxr-xr-x 2 user staff"',
});
expect(detail).toEqual({ kind: "text", text: "total 0\ndrwxr-xr-x 2 user staff" });
});
it("uses content for prose events and never throws on an empty event", () => {
expect(traceEventDetail({ type: "text", content: "hello" })).toEqual({
kind: "text",
text: "hello",
});
expect(traceEventDetail({ type: "tool_use" })).toEqual({ kind: "text", text: "" });
});
});
describe("collapseDiffContext", () => {
const ctx = (n: number) => Array.from({ length: n }, (_, i) => ctxLine(`c${i}`));
function ctxLine(text: string) {
return { kind: "context" as const, text };
}
it("collapses a long unchanged stretch between two changes", () => {
const lines = [
{ kind: "remove" as const, text: "old" },
...ctx(10),
{ kind: "add" as const, text: "new" },
];
const out = collapseDiffContext(lines, 2);
expect(out.map((l) => l.kind)).toEqual([
"remove",
"context",
"context",
"gap",
"context",
"context",
"add",
]);
expect(out[3]).toEqual({ kind: "gap", text: "", hidden: 6 });
});
it("drops leading and trailing context entirely — nothing faces a change there", () => {
const out = collapseDiffContext([...ctx(8), { kind: "add", text: "x" }, ...ctx(8)], 2);
expect(out.map((l) => l.kind)).toEqual(["gap", "context", "context", "add", "context", "context", "gap"]);
expect(out[0]?.hidden).toBe(6);
});
it("leaves a run alone when collapsing would not save a line", () => {
const lines = [{ kind: "remove" as const, text: "a" }, ...ctx(4), { kind: "add" as const, text: "b" }];
expect(collapseDiffContext(lines, 2)).toEqual(lines);
});
it("is a no-op for a diff with no context at all", () => {
const lines = [
{ kind: "remove" as const, text: "a" },
{ kind: "add" as const, text: "b" },
];
expect(collapseDiffContext(lines)).toEqual(lines);
});
});
// --- Codex multi-file patch payload (GH #6157) -----------------------------
//
// The Codex adapter records a file edit as
// `{ changes: [{ path, kind, diff?, content?, move_path? }], truncated? }`.
// Before this the presenter only understood a top-level file_path plus
// old_string/new_string, so a Codex edit fell through to pretty JSON.
describe("parseUnifiedDiff", () => {
it("maps a ready-made unified diff onto diff rows", () => {
// Codex hands over a finished diff, so it is parsed rather than recomputed.
expect(parseUnifiedDiff("@@ -1,3 +1,3 @@\n ctx\n-old\n+new\n")).toEqual([
{ kind: "gap", text: "@@ -1,3 +1,3 @@" },
{ kind: "context", text: "ctx" },
{ kind: "remove", text: "old" },
{ kind: "add", text: "new" },
]);
});
it("drops file headers ahead of the first hunk", () => {
const lines = parseUnifiedDiff(
"diff --git a/x b/x\nindex 111..222 100644\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n\\ No newline at end of file\n",
);
expect(lines).toEqual([
{ kind: "gap", text: "@@ -1 +1 @@" },
{ kind: "remove", text: "a" },
{ kind: "add", text: "b" },
]);
});
// Inside a hunk, "---"/"+++" are ordinary changed lines whose content starts
// with a dash or plus — a Markdown rule, a nested patch, a comment banner.
// Treating them as file headers anywhere silently deleted real content.
it("keeps header-like content lines once inside a hunk", () => {
expect(parseUnifiedDiff("@@ -1 +1 @@\n--- old markdown\n+++ new markdown\n")).toEqual([
{ kind: "gap", text: "@@ -1 +1 @@" },
{ kind: "remove", text: "-- old markdown" },
{ kind: "add", text: "++ new markdown" },
]);
});
it("keeps a removed line that is exactly a Markdown rule", () => {
expect(parseUnifiedDiff("@@ -1,2 +1,1 @@\n---\n ok\n")).toEqual([
{ kind: "gap", text: "@@ -1,2 +1,1 @@" },
{ kind: "remove", text: "--" },
{ kind: "context", text: "ok" },
]);
});
it("keeps an empty context line and does not invent a trailing one", () => {
// " " is a blank unchanged line; the phantom element left by the trailing
// newline is not.
expect(parseUnifiedDiff("@@ -1,2 +1,2 @@\n \n+x\n")).toEqual([
{ kind: "gap", text: "@@ -1,2 +1,2 @@" },
{ kind: "context", text: "" },
{ kind: "add", text: "x" },
]);
});
});
describe("traceEventDetail — Codex changes[]", () => {
it("renders a multi-file patch with one section per file", () => {
const detail = traceEventDetail({
type: "tool_use",
tool: "patch_apply",
input: {
changes: [
{ path: "src/a.go", kind: "update", diff: "@@ -1 +1 @@\n-a\n+b\n" },
{ path: "src/new.go", kind: "add", content: "package main\n" },
],
},
});
expect(detail.kind).toBe("patch");
if (detail.kind !== "patch") return;
expect(detail.truncated).toBe(false);
expect(detail.files).toHaveLength(2);
const [update, added] = detail.files;
expect(update?.path).toBe("src/a.go");
expect(update?.changeKind).toBe("update");
expect(update?.body).toEqual({
kind: "diff",
lines: [
{ kind: "gap", text: "@@ -1 +1 @@" },
{ kind: "remove", text: "a" },
{ kind: "add", text: "b" },
],
});
// An added file has no before side, matching the whole-file write surface.
expect(added?.path).toBe("src/new.go");
expect(added?.body).toEqual({ kind: "file", text: "package main\n", lineCount: 2 });
});
it("renders a deletion as all-removals rather than a green whole-file write", () => {
// The legacy protocol reports a delete as the outgoing file's content; a
// "+N" gutter would state the opposite of what happened.
const detail = traceEventDetail({
type: "tool_use",
tool: "patch_apply",
input: { changes: [{ path: "gone.txt", kind: "delete", content: "one\ntwo" }] },
});
expect(detail.kind).toBe("patch");
if (detail.kind !== "patch") return;
expect(detail.files[0]?.body).toEqual({
kind: "diff",
lines: [
{ kind: "remove", text: "one" },
{ kind: "remove", text: "two" },
],
});
});
it("surfaces a rename destination", () => {
const detail = traceEventDetail({
type: "tool_use",
tool: "patch_apply",
input: {
changes: [
{ path: "old.go", kind: "update", move_path: "new.go", diff: "@@ -1 +1 @@\n-x\n+y\n" },
],
},
});
if (detail.kind !== "patch") return expect(detail.kind).toBe("patch");
expect(detail.files[0]?.movePath).toBe("new.go");
});
it("keeps the path when the body was dropped by the size budget", () => {
const detail = traceEventDetail({
type: "tool_use",
tool: "patch_apply",
input: {
changes: [{ path: "big.txt", kind: "add", truncated: true }],
truncated: true,
},
});
expect(detail.kind).toBe("patch");
if (detail.kind !== "patch") return;
expect(detail.truncated).toBe(true);
expect(detail.files[0]).toEqual({
path: "big.txt",
changeKind: "add",
truncated: true,
body: { kind: "none" },
});
});
it("renders an empty added file as an empty body, not as a missing one", () => {
const detail = traceEventDetail({
type: "tool_use",
tool: "patch_apply",
input: { changes: [{ path: "empty.txt", kind: "add", content: "" }] },
});
if (detail.kind !== "patch") return expect(detail.kind).toBe("patch");
expect(detail.files[0]?.body).toEqual({ kind: "file", text: "", lineCount: 0 });
});
it("falls back to pretty JSON for an unrecognised shape", () => {
// Forward compatibility: a payload this presenter does not understand must
// still be readable rather than rendering as an empty patch.
const detail = traceEventDetail({
type: "tool_use",
tool: "patch_apply",
input: { changes: "not-an-array" },
});
expect(detail.kind).toBe("text");
if (detail.kind !== "text") return;
expect(detail.text).toContain("not-an-array");
});
it("falls back to pretty JSON when no change carries a path", () => {
const detail = traceEventDetail({
type: "tool_use",
tool: "patch_apply",
input: { changes: [42, null, { kind: "add" }] },
});
expect(detail.kind).toBe("text");
});
});
describe("traceToolArgSummary / traceEventHasDetail — Codex changes[]", () => {
it("summarises a single-file patch as its path", () => {
expect(
traceToolArgSummary({ changes: [{ path: "src/a.go", kind: "update", diff: "@@\n+x" }] }),
).toBe("src/a.go");
});
it("summarises a multi-file patch as the first path plus a count", () => {
expect(
traceToolArgSummary({
changes: [
{ path: "src/a.go", kind: "update", diff: "@@\n+x" },
{ path: "src/b.go", kind: "add", content: "y" },
{ path: "src/c.go", kind: "add", content: "z" },
],
}),
).toBe("src/a.go +2 more");
});
it("shortens a deep path in the summary", () => {
expect(
traceToolArgSummary({ changes: [{ path: "a/b/c/d/e.go", kind: "add", content: "x" }] }),
).toBe(".../d/e.go");
});
// The presenter stays i18n-free, so the caller injects the phrasing; the
// English form is only the fallback.
it("uses injected phrasing for the multi-file count", () => {
expect(
traceToolArgSummary(
{
changes: [
{ path: "src/a.go", kind: "update", diff: "@@\n+x" },
{ path: "src/b.go", kind: "add", content: "y" },
],
},
{ morePaths: (path, extraCount) => `${path},另有 ${extraCount} 个文件` },
),
).toBe("src/a.go另有 1 个文件");
});
// The injected number counts files *beyond* the named one. Phrasing it as a
// total under-reports by one, which is easy to miss in English ("+2 more")
// and obvious in a language that states the total outright.
it("passes the count of additional files, not the total", () => {
const seen: number[] = [];
traceToolArgSummary(
{
changes: [
{ path: "a.go", kind: "add", content: "x" },
{ path: "b.go", kind: "add", content: "y" },
{ path: "c.go", kind: "add", content: "z" },
],
},
{
morePaths: (path, extraCount) => {
seen.push(extraCount);
return path;
},
},
);
expect(seen).toEqual([2]);
});
it("does not consult the injected phrasing for a single file", () => {
expect(
traceToolArgSummary(
{ changes: [{ path: "only.go", kind: "add", content: "x" }] },
{
morePaths: () => {
throw new Error("must not be called for a single-file patch");
},
},
),
).toBe("only.go");
});
it("makes a patch row expandable — the bug was two blank unexpandable rows", () => {
expect(
traceEventHasDetail({
type: "tool_use",
tool: "patch_apply",
input: { changes: [{ path: "a.go", kind: "add", content: "x" }] },
}),
).toBe(true);
expect(
traceEventHasDetail({
type: "tool_result",
tool: "patch_apply",
output: "completed (1 file)",
}),
).toBe(true);
// The regression itself: no payload means no expandable detail.
expect(traceEventHasDetail({ type: "tool_use", tool: "patch_apply", input: {} })).toBe(false);
expect(traceEventHasDetail({ type: "tool_result", tool: "patch_apply", output: "" })).toBe(
false,
);
});
});