Files
PaperClipAI/ui/src/components/task-chat/transcript-adapter.test.ts
T
scotttongandClaude Fable 5 a14d8992fa fix(ui): task-chat summary counts unique tool calls; composer shows image paste thumbnails
buildTurnSummary now dedupes tool_call entries by toolUseId (with the
parser's tool-${i} fallback) so the folded 'Worked · N tools' line matches
the expanded row count. Composer attachments carry an object-URL preview
created at attach time, rendered as a size-10 thumbnail in the chip and
revoked on clear/unmount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:56:10 -07:00

211 lines
7.4 KiB
TypeScript
Raw 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 type { TranscriptEntry } from "@/adapters";
import {
buildTurnSummary,
deriveRunStatusLabel,
toolDisplayName,
transcriptToTaskChatItems,
} from "./transcript-adapter";
const TS = "2026-07-31T12:00:00.000Z";
function toolCall(name: string, input?: unknown): TranscriptEntry {
return { kind: "tool_call", ts: TS, name, toolUseId: `tool-${name}`, input } as TranscriptEntry;
}
describe("deriveRunStatusLabel", () => {
it("labels a tail tool_call with the taxonomy verb and tool · target detail", () => {
const status = deriveRunStatusLabel([toolCall("Grep", { pattern: "ui/src/components" })]);
expect(status.label).toBe("Searching");
expect(status.detail).toBe("Grep · ui/src/components");
expect(status.toolName).toBe("Grep");
});
it("uses family verbs per tool", () => {
expect(deriveRunStatusLabel([toolCall("Bash", { command: "pnpm test" })]).label).toBe(
"Running a command",
);
expect(deriveRunStatusLabel([toolCall("Edit", { file_path: "a.ts" })]).label).toBe(
"Editing files",
);
expect(deriveRunStatusLabel([toolCall("mcp__linear__search_issues")]).label).toBe(
"Using Search_issues",
);
});
it("omits the target from the detail when the input has none", () => {
const status = deriveRunStatusLabel([toolCall("Read")]);
expect(status.detail).toBe("Read");
});
it("keeps Thinking / Responding / Running fallbacks", () => {
expect(
deriveRunStatusLabel([{ kind: "thinking", ts: TS, text: "hmm" } as TranscriptEntry]).label,
).toBe("Thinking");
expect(
deriveRunStatusLabel([{ kind: "assistant", ts: TS, text: "done" } as TranscriptEntry]).label,
).toBe("Responding");
expect(deriveRunStatusLabel([]).label).toBe("Running");
// A settled tool (tool_result at the tail) means "between tools" → Running.
expect(
deriveRunStatusLabel([
toolCall("Bash", { command: "ls" }),
{ kind: "tool_result", ts: TS, toolUseId: "tool-Bash", content: "ok" } as TranscriptEntry,
]).label,
).toBe("Running");
});
});
describe("toolDisplayName", () => {
it("collapses mcp names the same way the taxonomy does", () => {
expect(toolDisplayName("mcp__linear-server__search_issues")).toBe("Search_issues");
expect(toolDisplayName("bash")).toBe("Bash");
expect(toolDisplayName("")).toBe("Tool");
expect(toolDisplayName("tool")).toBe("Tool");
});
it("maps acpx placeholder names to the generic Tool label", () => {
expect(toolDisplayName("tool call")).toBe("Tool");
expect(toolDisplayName("tool call (completed)")).toBe("Tool");
expect(toolDisplayName("acp_tool")).toBe("Tool");
});
});
describe("transcriptToTaskChatItems tool_call updates", () => {
const opts = { runId: "run-1", running: false };
function update(toolUseId: string, status: string): TranscriptEntry {
// Mirrors what a persisted acpx tool_call_update line parses to: the
// literal placeholder name plus a synthesized { text, status } input.
return {
kind: "tool_call",
ts: TS,
name: "tool call",
toolUseId,
input: { text: `tool call (${status})`, status },
} as TranscriptEntry;
}
it("keeps the initial real name and target when a generic update arrives", () => {
const items = transcriptToTaskChatItems(
[
toolCall("Terminal", { command: "pnpm test" }),
update("tool-Terminal", "completed"),
{
kind: "tool_result",
ts: TS,
toolUseId: "tool-Terminal",
toolName: "tool call",
content: "ok",
} as TranscriptEntry,
],
opts,
);
expect(items).toHaveLength(1);
const tool = items[0];
expect(tool.kind).toBe("tool");
if (tool.kind !== "tool") return;
expect(tool.name).toBe("Terminal");
expect(tool.rawName).toBe("Terminal");
expect(tool.target).toBe("pnpm test");
expect(tool.status).toBe("completed");
});
it("keeps identity on retitle and uses the invocation as the target", () => {
// Real stored sequence: "Terminal" (pending) → retitle to the command →
// generic "tool call" completion updates.
const items = transcriptToTaskChatItems(
[
{ kind: "tool_call", ts: TS, name: "Terminal", toolUseId: "tc-2" } as TranscriptEntry,
{ kind: "tool_call", ts: TS, name: "ls -la", toolUseId: "tc-2" } as TranscriptEntry,
update("tc-2", "completed"),
],
opts,
);
expect(items).toHaveLength(1);
if (items[0].kind !== "tool") return;
expect(items[0].name).toBe("Terminal");
expect(items[0].rawName).toBe("Terminal");
expect(items[0].target).toBe("ls -la");
});
it("never renders the synthesized { text, status } input as a target", () => {
const items = transcriptToTaskChatItems([update("tc-orphan", "pending")], opts);
expect(items).toHaveLength(1);
if (items[0].kind !== "tool") return;
expect(items[0].target).toBeUndefined();
});
it("upgrades a generic-named call when a later entry carries the real name", () => {
const items = transcriptToTaskChatItems(
[
{ kind: "tool_call", ts: TS, name: "tool call", toolUseId: "tc-1" } as TranscriptEntry,
{ kind: "tool_call", ts: TS, name: "Read", toolUseId: "tc-1" } as TranscriptEntry,
],
opts,
);
expect(items).toHaveLength(1);
if (items[0].kind !== "tool") return;
expect(items[0].name).toBe("Read");
});
});
describe("buildTurnSummary tool counting", () => {
function statusEntry(toolUseId: string | undefined, status: string): TranscriptEntry {
return {
kind: "tool_call",
ts: TS,
name: "Bash",
toolUseId,
input: { text: `tool call (${status})`, status },
} as TranscriptEntry;
}
it("counts unique tool calls, not per-status transcript entries", () => {
// 4 real calls × 4 status changes each = 16 entries; the summary must
// match the 4 rows the expanded list renders.
const entries = ["tc-1", "tc-2", "tc-3", "tc-4"].flatMap((id) =>
["pending", "in_progress", "in_progress", "completed"].map((status) =>
statusEntry(id, status),
),
);
expect(entries).toHaveLength(16);
expect(buildTurnSummary(entries).toolCount).toBe(4);
});
it("counts id-less legacy entries once each", () => {
const entries = [
statusEntry(undefined, "completed"),
statusEntry(undefined, "completed"),
toolCall("Read"),
];
expect(buildTurnSummary(entries).toolCount).toBe(3);
});
});
describe("deriveRunStatusLabel with generic tail updates", () => {
it("recovers the real tool name from the call's initial entry", () => {
const status = deriveRunStatusLabel([
toolCall("Terminal", { command: "ls -la" }),
{
kind: "tool_call",
ts: TS,
name: "tool call",
toolUseId: "tool-Terminal",
} as TranscriptEntry,
]);
expect(status.label).toBe("Running a command");
expect(status.toolName).toBe("Terminal");
});
it("treats a tail retitle as the invocation, not the identity", () => {
const status = deriveRunStatusLabel([
{ kind: "tool_call", ts: TS, name: "Terminal", toolUseId: "tc-3" } as TranscriptEntry,
{ kind: "tool_call", ts: TS, name: "pnpm test", toolUseId: "tc-3" } as TranscriptEntry,
]);
expect(status.label).toBe("Running a command");
expect(status.toolName).toBe("Terminal");
expect(status.detail).toBe("Terminal · pnpm test");
});
});