mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
753 lines
32 KiB
TypeScript
753 lines
32 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import type { TranscriptEntry } from "@/adapters";
|
||
import {
|
||
assembleThreadItems,
|
||
attachSettledTurns,
|
||
buildMergedTurnSummary,
|
||
buildTurnSummary,
|
||
coalesceSettledTurns,
|
||
deriveRunStatusLabel,
|
||
flattenSelfTalk,
|
||
isNestableLiveChild,
|
||
ISSUE_BRIEF_ITEM_ID,
|
||
paperclipRunnerHistoryItems,
|
||
prependIssueBrief,
|
||
settledRunChildren,
|
||
toolDisplayName,
|
||
transcriptToTaskChatItems,
|
||
type SettledTurnMergeMeta,
|
||
type ThreadBackboneEntry,
|
||
} from "./transcript-adapter";
|
||
import type { TaskChatItem, TaskChatTurnItem } from "./task-chat-model";
|
||
|
||
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("Grepping");
|
||
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");
|
||
});
|
||
});
|
||
|
||
describe("isNestableLiveChild", () => {
|
||
const items: TaskChatItem[] = [
|
||
{ id: "t", kind: "tool", name: "Read", status: "completed" },
|
||
{ id: "th", kind: "thinking", lines: ["hm"] },
|
||
{ id: "u", kind: "usage", usage: { used: 1, size: 2 } },
|
||
{ id: "m", kind: "message", author: "agent", text: "finished self-talk", interstitial: true },
|
||
{ id: "mk", kind: "marker", variant: "session_start", label: "Session started" },
|
||
{ id: "s", kind: "status", status: "running", label: "Running" },
|
||
{ id: "i", kind: "interaction", interaction: {} as never },
|
||
];
|
||
|
||
it("nests tools, provider reasoning, and usage inside the live parent row", () => {
|
||
expect(items.filter(isNestableLiveChild).map((it) => it.kind)).toEqual([
|
||
"tool",
|
||
"thinking",
|
||
"usage",
|
||
]);
|
||
});
|
||
|
||
it("keeps messages, markers, statuses and interactions out", () => {
|
||
// Interstitial updates — finished or streaming — never nest: they are
|
||
// ephemeral, living on the live line only while streaming, and the final
|
||
// reply keeps its bubble.
|
||
for (const kind of ["message", "marker", "status", "interaction"]) {
|
||
const item = items.find((it) => it.kind === kind)!;
|
||
expect(isNestableLiveChild(item)).toBe(false);
|
||
}
|
||
expect(
|
||
isNestableLiveChild({
|
||
id: "m2",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "typing…",
|
||
interstitial: true,
|
||
streaming: true,
|
||
}),
|
||
).toBe(false);
|
||
expect(
|
||
isNestableLiveChild({ id: "m3", kind: "message", author: "agent", text: "Final reply." }),
|
||
).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("interstitial self-talk classification (PAP-355)", () => {
|
||
const stream: TranscriptEntry[] = [
|
||
{ kind: "assistant", ts: TS, text: "Let me check the adapter." } as TranscriptEntry,
|
||
toolCall("Read", { file_path: "a.ts" }),
|
||
{ kind: "assistant", ts: "2026-07-31T12:00:05.000Z", text: "Found it — fixing now." } as TranscriptEntry,
|
||
];
|
||
|
||
it("tags agent messages streamed inside a live run turn as interstitial", () => {
|
||
const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: true }).filter(
|
||
(it) => it.kind === "message",
|
||
);
|
||
expect(messages).toHaveLength(2);
|
||
for (const m of messages) {
|
||
if (m.kind !== "message") continue;
|
||
expect(m.interstitial).toBe(true);
|
||
}
|
||
});
|
||
|
||
it("marks only the message still open at the tail as streaming", () => {
|
||
const items = transcriptToTaskChatItems(stream, { runId: "run-1", running: true });
|
||
const messages = items.filter((it) => it.kind === "message");
|
||
if (messages[0].kind !== "message" || messages[1].kind !== "message") return;
|
||
// The first message was closed by the tool call — finished self-talk.
|
||
expect(messages[0].streaming).toBe(false);
|
||
expect(messages[1].streaming).toBe(true);
|
||
});
|
||
|
||
it("marks no message streaming when the tail is a tool call", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[...stream, toolCall("Bash", { command: "pnpm test" })],
|
||
{ runId: "run-1", running: true },
|
||
);
|
||
for (const m of items) {
|
||
if (m.kind === "message") expect(m.streaming).toBe(false);
|
||
}
|
||
});
|
||
|
||
it("tags settled-history messages interstitial too, so live and history agree", () => {
|
||
const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: false }).filter(
|
||
(it) => it.kind === "message",
|
||
);
|
||
expect(messages).toHaveLength(2);
|
||
for (const m of messages) {
|
||
if (m.kind !== "message") continue;
|
||
expect(m.interstitial).toBe(true);
|
||
expect(m.streaming).toBe(false);
|
||
}
|
||
});
|
||
|
||
it("stamps atMs from the first streamed chunk", () => {
|
||
const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: false }).filter(
|
||
(it) => it.kind === "message",
|
||
);
|
||
if (messages[0].kind !== "message" || messages[1].kind !== "message") return;
|
||
expect(messages[0].atMs).toBe(Date.parse(TS));
|
||
expect(messages[1].atMs).toBe(Date.parse("2026-07-31T12:00:05.000Z"));
|
||
});
|
||
});
|
||
|
||
describe("flattenSelfTalk", () => {
|
||
it("strips markdown markers to one plain line", () => {
|
||
expect(
|
||
flattenSelfTalk("## Plan\n\nI'll **extend** the `ipRateLimit` helper:\n- read it\n- patch it"),
|
||
).toBe("Plan I'll extend the ipRateLimit helper: read it patch it");
|
||
});
|
||
|
||
it("keeps link text, drops the url, and is stream-safe on unclosed markers", () => {
|
||
expect(flattenSelfTalk("see [the docs](https://x) for **bol")).toBe("see the docs for bol");
|
||
expect(flattenSelfTalk("```ts\nconst a = 1;")).toBe("const a = 1;");
|
||
});
|
||
});
|
||
|
||
describe("deriveRunStatusLabel streaming interstitial text (PAP-361)", () => {
|
||
it("returns the trailing message's flattened text with the Responding label", () => {
|
||
const status = deriveRunStatusLabel([
|
||
{ kind: "assistant", ts: TS, text: "I'll **extend** " } as TranscriptEntry,
|
||
{ kind: "assistant", ts: TS, text: "the `ipRateLimit` helper." } as TranscriptEntry,
|
||
]);
|
||
expect(status.label).toBe("Responding");
|
||
expect(status.selfTalk).toBe("I'll extend the ipRateLimit helper.");
|
||
expect(status.detail).toBeUndefined();
|
||
});
|
||
|
||
it("only accumulates the trailing message, not one closed by a tool call", () => {
|
||
const status = deriveRunStatusLabel([
|
||
{ kind: "assistant", ts: TS, text: "Earlier note." } as TranscriptEntry,
|
||
toolCall("Read", { file_path: "a.ts" }),
|
||
{ kind: "assistant", ts: TS, text: "Fresh line." } as TranscriptEntry,
|
||
]);
|
||
expect(status.selfTalk).toBe("Fresh line.");
|
||
});
|
||
|
||
it("carries no selfTalk for tool or thinking tails", () => {
|
||
expect(deriveRunStatusLabel([toolCall("Read")]).selfTalk).toBeUndefined();
|
||
expect(
|
||
deriveRunStatusLabel([{ kind: "thinking", ts: TS, text: "hm" } as TranscriptEntry]).selfTalk,
|
||
).toBeUndefined();
|
||
expect(deriveRunStatusLabel([]).selfTalk).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe("settledRunChildren (PAP-361)", () => {
|
||
const transcript: TranscriptEntry[] = [
|
||
{ kind: "assistant", ts: TS, text: "Checking the adapter first." } as TranscriptEntry,
|
||
toolCall("Read", { file_path: "a.ts" }),
|
||
{ kind: "thinking", ts: TS, text: "wire it in" } as TranscriptEntry,
|
||
toolCall("Edit", { file_path: "a.ts" }),
|
||
{ kind: "assistant", ts: TS, text: "Done — the limiter is wired in." } as TranscriptEntry,
|
||
];
|
||
const parsed = transcriptToTaskChatItems(transcript, { runId: "run-1", running: false });
|
||
|
||
it("groups tools under the historical assistant boundary and excludes the final reply", () => {
|
||
const children = settledRunChildren(parsed);
|
||
expect(children.map((c) => c.kind)).toEqual(["activity_phase"]);
|
||
const phase = children[0];
|
||
expect(phase.kind === "activity_phase" && phase.interstitial?.text).toBe("Checking the adapter first.");
|
||
expect(phase.kind === "activity_phase" && phase.items.map((item) => item.kind)).toEqual(["tool", "thinking", "tool"]);
|
||
});
|
||
|
||
it("matches the folded summary's tool count exactly (row-count parity)", () => {
|
||
const children = settledRunChildren(parsed);
|
||
const summary = buildTurnSummary(transcript);
|
||
const phaseToolCount = children.reduce(
|
||
(count, child) => count + (child.kind === "activity_phase" ? child.items.filter((item) => item.kind === "tool").length : 0),
|
||
0,
|
||
);
|
||
expect(phaseToolCount).toBe(summary.toolCount);
|
||
});
|
||
|
||
it("creates a stable opening phase for calls before the first interstitial", () => {
|
||
const opening = transcriptToTaskChatItems([
|
||
toolCall("Read", { file_path: "a.ts" }),
|
||
{ kind: "assistant", ts: TS, text: "Now editing." } as TranscriptEntry,
|
||
toolCall("Edit", { file_path: "a.ts" }),
|
||
{ kind: "assistant", ts: TS, text: "Done." } as TranscriptEntry,
|
||
], { runId: "run-opening", running: false });
|
||
const phases = settledRunChildren(opening);
|
||
expect(phases).toHaveLength(2);
|
||
expect(phases[0].id).toContain(":phase:opening");
|
||
expect(phases[1].kind === "activity_phase" && phases[1].summary).toBe("Edited 1 file");
|
||
});
|
||
});
|
||
|
||
describe("paperclip runner semantic channels", () => {
|
||
it("keeps progress/final messages and reasoning summary/detail in distinct items", () => {
|
||
const parsed = transcriptToTaskChatItems([
|
||
{ kind: "assistant", ts: TS, text: "Checking.", channel: "progress", delta: true },
|
||
{ kind: "thinking", ts: TS, text: "Summary", channel: "summary", delta: true },
|
||
{ kind: "thinking", ts: TS, text: "Detail", channel: "detail", delta: true },
|
||
{ kind: "assistant", ts: TS, text: "Done.", channel: "final", delta: true },
|
||
], { runId: "run-channels", running: true });
|
||
expect(parsed.map((item) => item.kind)).toEqual(["message", "thinking", "thinking", "message"]);
|
||
expect(parsed[0]).toMatchObject({ interstitial: true, channel: "progress" });
|
||
expect(parsed[1]).toMatchObject({ channel: "summary" });
|
||
expect(parsed[2]).toMatchObject({ channel: "detail" });
|
||
expect(parsed[3]).toMatchObject({ interstitial: false, channel: "final" });
|
||
const history = settledRunChildren(parsed);
|
||
expect(JSON.stringify(history)).not.toContain("Done.");
|
||
});
|
||
|
||
it("filters lifecycle and usage noise from only the runner history surface", () => {
|
||
const items: TaskChatItem[] = [
|
||
{ id: "session", kind: "marker", variant: "session_start", label: "Session started", detail: "session-id" },
|
||
{ id: "start", kind: "marker", variant: "turn_boundary", label: "Turn started" },
|
||
{ id: "usage", kind: "usage", usage: { used: 1_024, size: 128_000, inputTokens: 1_000, outputTokens: 24 } },
|
||
{ id: "tool", kind: "tool", name: "Paperclip_finish", status: "completed", target: "done" },
|
||
{ id: "interrupt", kind: "marker", variant: "interrupted", label: "Interrupted" },
|
||
{ id: "runner", kind: "marker", variant: "turn_boundary", label: "Runner update" },
|
||
{ id: "complete", kind: "marker", variant: "turn_boundary", label: "Turn completed" },
|
||
];
|
||
|
||
expect(paperclipRunnerHistoryItems(items).map((item) => item.id)).toEqual([
|
||
"tool",
|
||
"interrupt",
|
||
"runner",
|
||
]);
|
||
expect(items).toHaveLength(7);
|
||
});
|
||
});
|
||
|
||
describe("buildMergedTurnSummary (PAP-362)", () => {
|
||
const runA: TranscriptEntry[] = [
|
||
{ kind: "tool_call", ts: "2026-07-31T12:00:00.000Z", name: "Read", toolUseId: "a-1" } as TranscriptEntry,
|
||
{ kind: "tool_call", ts: "2026-07-31T12:00:05.000Z", name: "Edit", toolUseId: "a-2" } as TranscriptEntry,
|
||
{ kind: "diff", ts: "2026-07-31T12:00:06.000Z", changeType: "add", text: "+x" } as TranscriptEntry,
|
||
{ kind: "result", ts: "2026-07-31T12:00:10.000Z", inputTokens: 500, outputTokens: 300 } as TranscriptEntry,
|
||
];
|
||
const runB: TranscriptEntry[] = [
|
||
{ kind: "tool_call", ts: "2026-07-31T12:30:00.000Z", name: "Bash", toolUseId: "b-1" } as TranscriptEntry,
|
||
{ kind: "result", ts: "2026-07-31T12:30:05.000Z", inputTokens: 300, outputTokens: 100 } as TranscriptEntry,
|
||
];
|
||
|
||
it("sums tool count, diffs, tokens, and duration across runs", () => {
|
||
const summary = buildMergedTurnSummary([
|
||
{ entries: runA, durationMs: 30_000 },
|
||
{ entries: runB, durationMs: 15_000 },
|
||
]);
|
||
expect(summary.toolCount).toBe(3);
|
||
expect(summary.added).toBe(1);
|
||
expect(summary.tokensLabel).toBe("1.2k tokens");
|
||
expect(summary.durationLabel).toBe("45s");
|
||
expect(summary.failed).toBeUndefined();
|
||
});
|
||
|
||
it("sums per-run ts spans — never the idle gap between runs", () => {
|
||
// runA spans 10s, runB spans 5s, the gap between them is ~30min.
|
||
const summary = buildMergedTurnSummary([{ entries: runA }, { entries: runB }]);
|
||
expect(summary.durationLabel).toBe("15s");
|
||
});
|
||
|
||
it("marks the merged turn failed when any run failed", () => {
|
||
const summary = buildMergedTurnSummary([
|
||
{ entries: runA, durationMs: 1000 },
|
||
{ entries: runB, durationMs: 1000, failed: true },
|
||
]);
|
||
expect(summary.failed).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("coalesceSettledTurns (PAP-362)", () => {
|
||
const runA: TranscriptEntry[] = [
|
||
{ kind: "tool_call", ts: TS, name: "Read", toolUseId: "a-1" } as TranscriptEntry,
|
||
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "a-2" } as TranscriptEntry,
|
||
];
|
||
const runB: TranscriptEntry[] = [
|
||
{ kind: "tool_call", ts: TS, name: "Bash", toolUseId: "b-1" } as TranscriptEntry,
|
||
];
|
||
|
||
function agentBubble(id: string): TaskChatItem {
|
||
return { id, kind: "message", author: "agent", authorName: "CEO", text: "Done." };
|
||
}
|
||
function turn(id: string, entries: TranscriptEntry[], durationMs: number): TaskChatItem {
|
||
return {
|
||
id,
|
||
kind: "turn",
|
||
settled: true,
|
||
items: entries.map((e, i) => ({
|
||
id: `${id}:tool:${i}`,
|
||
kind: "tool" as const,
|
||
name: (e as { name: string }).name,
|
||
status: "completed" as const,
|
||
})),
|
||
summary: buildTurnSummary(entries, { durationMs }),
|
||
};
|
||
}
|
||
const metaById = new Map<string, SettledTurnMergeMeta>([
|
||
["run-a:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: runA, durationMs: 30_000 }] }],
|
||
["run-b:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: runB, durationMs: 15_000 }] }],
|
||
]);
|
||
|
||
it("merges two back-to-back runs into ONE Worked row summing tools and duration", () => {
|
||
// Two terminal runs, same agent, consecutive comments, no user message
|
||
// between → the merged turn sits below the LAST bubble.
|
||
const items: TaskChatItem[] = [
|
||
agentBubble("msg-a"),
|
||
turn("run-a:turn", runA, 30_000),
|
||
agentBubble("msg-b"),
|
||
turn("run-b:turn", runB, 15_000),
|
||
];
|
||
const out = coalesceSettledTurns(items, metaById);
|
||
expect(out.map((i) => i.kind)).toEqual(["message", "message", "turn"]);
|
||
const merged = out[2] as Extract<TaskChatItem, { kind: "turn" }>;
|
||
expect(merged.id).toBe("run-a:turn"); // stable: keeps the first run's id
|
||
expect(merged.summary.toolCount).toBe(3);
|
||
expect(merged.summary.durationLabel).toBe("45s");
|
||
expect(merged.items.map((c) => c.id)).toEqual([
|
||
"run-a:turn:tool:0",
|
||
"run-a:turn:tool:1",
|
||
"run-b:turn:tool:0",
|
||
]);
|
||
});
|
||
|
||
it("keeps two rows when a user message sits between the runs", () => {
|
||
const items: TaskChatItem[] = [
|
||
agentBubble("msg-a"),
|
||
turn("run-a:turn", runA, 30_000),
|
||
{ id: "msg-u", kind: "message", author: "human", text: "also do X" },
|
||
agentBubble("msg-b"),
|
||
turn("run-b:turn", runB, 15_000),
|
||
];
|
||
const out = coalesceSettledTurns(items, metaById);
|
||
expect(out.filter((i) => i.kind === "turn")).toHaveLength(2);
|
||
expect(out.map((i) => i.id)).toEqual(["msg-a", "run-a:turn", "msg-u", "msg-b", "run-b:turn"]);
|
||
});
|
||
|
||
it("does not merge across an interaction or into another agent's turn", () => {
|
||
const withInteraction: TaskChatItem[] = [
|
||
turn("run-a:turn", runA, 30_000),
|
||
{ id: "int-1", kind: "interaction", interaction: {} } as TaskChatItem,
|
||
turn("run-b:turn", runB, 15_000),
|
||
];
|
||
expect(coalesceSettledTurns(withInteraction, metaById).filter((i) => i.kind === "turn")).toHaveLength(2);
|
||
|
||
const otherAgent = new Map(metaById);
|
||
otherAgent.set("run-b:turn", { agentKey: "agent-2", agentName: "QA", parts: [{ entries: runB }] });
|
||
const sameShape: TaskChatItem[] = [turn("run-a:turn", runA, 30_000), turn("run-b:turn", runB, 15_000)];
|
||
expect(coalesceSettledTurns(sameShape, otherAgent).filter((i) => i.kind === "turn")).toHaveLength(2);
|
||
});
|
||
|
||
it("never merges the live (unsettled) turn and preserves animateFold", () => {
|
||
const live: TaskChatItem = { id: "run-b:turn", kind: "turn", settled: false, items: [], summary: buildTurnSummary([]) };
|
||
const out = coalesceSettledTurns([turn("run-a:turn", runA, 30_000), live], metaById);
|
||
expect(out).toHaveLength(2);
|
||
|
||
const seenLive = { ...turn("run-b:turn", runB, 15_000), animateFold: true } as TaskChatItem;
|
||
const merged = coalesceSettledTurns([turn("run-a:turn", runA, 30_000), seenLive], metaById);
|
||
expect(merged).toHaveLength(1);
|
||
expect((merged[0] as Extract<TaskChatItem, { kind: "turn" }>).animateFold).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("attachSettledTurns (round 9)", () => {
|
||
const runA: TranscriptEntry[] = [
|
||
{ kind: "tool_call", ts: TS, name: "Read", toolUseId: "a-1" } as TranscriptEntry,
|
||
];
|
||
|
||
function agentBubble(id: string, authorName = "CEO"): TaskChatItem {
|
||
return { id, kind: "message", author: "agent", authorName, text: "Done." };
|
||
}
|
||
function settledTurn(id: string): TaskChatItem {
|
||
return {
|
||
id,
|
||
kind: "turn",
|
||
settled: true,
|
||
items: [{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" }],
|
||
summary: buildTurnSummary(runA, { durationMs: 30_000 }),
|
||
};
|
||
}
|
||
const metaById = new Map<string, SettledTurnMergeMeta>([
|
||
["run-a:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: runA, durationMs: 30_000 }] }],
|
||
]);
|
||
|
||
it("folds a settled turn into the agent reply bubble directly above it", () => {
|
||
const out = attachSettledTurns([agentBubble("msg-a"), settledTurn("run-a:turn")], metaById);
|
||
expect(out).toHaveLength(1);
|
||
const bubble = out[0] as Extract<TaskChatItem, { kind: "message" }>;
|
||
expect(bubble.id).toBe("msg-a");
|
||
expect(bubble.attachedTurn?.id).toBe("run-a:turn");
|
||
expect(bubble.attachedTurn?.summary.durationLabel).toBe("30s");
|
||
});
|
||
|
||
it("keeps the standalone row when the preceding item is not that agent's bubble", () => {
|
||
// Human message directly above.
|
||
const afterHuman = attachSettledTurns(
|
||
[{ id: "msg-u", kind: "message", author: "human", text: "hi" }, settledTurn("run-a:turn")],
|
||
metaById,
|
||
);
|
||
expect(afterHuman.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||
// Another agent's bubble above.
|
||
const otherAgent = attachSettledTurns([agentBubble("msg-q", "QA"), settledTurn("run-a:turn")], metaById);
|
||
expect(otherAgent.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||
// No meta (unknown identity) → conservative standalone fallback.
|
||
const noMeta = attachSettledTurns([agentBubble("msg-a"), settledTurn("run-x:turn")], metaById);
|
||
expect(noMeta.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||
// Nothing above at all.
|
||
const first = attachSettledTurns([settledTurn("run-a:turn")], metaById);
|
||
expect(first.map((i) => i.kind)).toEqual(["turn"]);
|
||
});
|
||
|
||
it("never attaches the live (unsettled) turn and attaches at most one turn per bubble", () => {
|
||
const live: TaskChatItem = {
|
||
id: "run-live:turn",
|
||
kind: "turn",
|
||
settled: false,
|
||
items: [],
|
||
summary: buildTurnSummary([]),
|
||
};
|
||
const out = attachSettledTurns([agentBubble("msg-a"), live], metaById);
|
||
expect(out.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||
|
||
// A second settled turn behind an already-attached bubble stays standalone
|
||
// (coalesceSettledTurns runs first, so same-agent turns arrive pre-merged).
|
||
const twice = attachSettledTurns(
|
||
[agentBubble("msg-a"), settledTurn("run-a:turn"), settledTurn("run-a:turn")],
|
||
metaById,
|
||
);
|
||
expect(twice.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||
expect((twice[0] as Extract<TaskChatItem, { kind: "message" }>).attachedTurn?.id).toBe("run-a:turn");
|
||
});
|
||
});
|
||
|
||
describe("assembleThreadItems (PAP-367)", () => {
|
||
function entry(id: string, ms: number, item?: Partial<TaskChatItem>): ThreadBackboneEntry {
|
||
return {
|
||
ms,
|
||
id,
|
||
item: { id, kind: "message", author: "human", text: `msg ${id}`, ...item } as TaskChatItem,
|
||
};
|
||
}
|
||
function settledTurn(id: string): TaskChatTurnItem {
|
||
return {
|
||
id,
|
||
kind: "turn",
|
||
settled: true,
|
||
items: [{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" }],
|
||
summary: buildTurnSummary([toolCall("Read")], { durationMs: 34_000 }),
|
||
};
|
||
}
|
||
const noAnchors = new Map<string, TaskChatTurnItem[]>();
|
||
|
||
it("inserts a comment-less settled turn chronologically at its run's start time", () => {
|
||
const backbone = [entry("u1", 1_000), entry("a1", 2_000, { author: "agent", authorName: "CEO" }), entry("u2", 3_000)];
|
||
const out = assembleThreadItems(backbone, noAnchors, [{ turn: settledTurn("run-x:turn"), startMs: 2_500 }]);
|
||
expect(out.map((i) => i.id)).toEqual(["u1", "a1", "run-x:turn", "u2"]);
|
||
});
|
||
|
||
it("does not append a comment-less turn after the newest user message (the P11 bug)", () => {
|
||
// The screenshot bug: an OLD stopped run (start 1_500) re-pinning its
|
||
// "Worked · 34s" row under every NEW user bubble (3_000).
|
||
const backbone = [entry("u1", 1_000), entry("u2", 3_000)];
|
||
const out = assembleThreadItems(backbone, noAnchors, [{ turn: settledTurn("run-x:turn"), startMs: 1_500 }]);
|
||
expect(out.map((i) => i.id)).toEqual(["u1", "run-x:turn", "u2"]);
|
||
expect(out[out.length - 1].id).toBe("u2");
|
||
});
|
||
|
||
it("keeps anchored turns directly after their run's reply comment", () => {
|
||
const backbone = [entry("u1", 1_000), entry("a1", 2_000, { author: "agent", authorName: "CEO" }), entry("u2", 3_000)];
|
||
const anchors = new Map([["a1", [settledTurn("run-a:turn")]]]);
|
||
const out = assembleThreadItems(backbone, anchors, []);
|
||
expect(out.map((i) => i.id)).toEqual(["u1", "a1", "run-a:turn", "u2"]);
|
||
});
|
||
|
||
it("keeps the tail slot for a turn with unknown start, and puts ties after the entry", () => {
|
||
const backbone = [entry("u1", 1_000), entry("u2", 3_000)];
|
||
// Unknown start (no linked meta yet — just-settled run): Infinity → tail.
|
||
const tail = assembleThreadItems(backbone, noAnchors, [
|
||
{ turn: settledTurn("run-x:turn"), startMs: Number.POSITIVE_INFINITY },
|
||
]);
|
||
expect(tail.map((i) => i.id)).toEqual(["u1", "u2", "run-x:turn"]);
|
||
// Tie with the trigger comment: comment renders first.
|
||
const tie = assembleThreadItems(backbone, noAnchors, [{ turn: settledTurn("run-y:turn"), startMs: 1_000 }]);
|
||
expect(tie.map((i) => i.id)).toEqual(["u1", "run-y:turn", "u2"]);
|
||
});
|
||
|
||
it("chronological insertion after a user message stays a standalone row through coalesce+attach", () => {
|
||
// Interplay guard: the inserted turn sits below a HUMAN bubble, so the
|
||
// later passes must neither merge it into anything nor attach it.
|
||
const backbone = [entry("u1", 1_000), entry("u2", 3_000)];
|
||
const metaById = new Map<string, SettledTurnMergeMeta>([
|
||
["run-x:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: [toolCall("Read")], durationMs: 34_000 }] }],
|
||
]);
|
||
const assembled = assembleThreadItems(backbone, noAnchors, [{ turn: settledTurn("run-x:turn"), startMs: 1_500 }]);
|
||
const out = attachSettledTurns(coalesceSettledTurns(assembled, metaById), metaById);
|
||
expect(out.map((i) => i.id)).toEqual(["u1", "run-x:turn", "u2"]);
|
||
expect(out.every((i) => i.kind !== "message" || (i as Extract<TaskChatItem, { kind: "message" }>).attachedTurn == null)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("prependIssueBrief (PAP-375)", () => {
|
||
function entry(id: string, ms: number): ThreadBackboneEntry {
|
||
return {
|
||
ms,
|
||
id,
|
||
item: { id, kind: "message", author: "human", text: `msg ${id}` } as TaskChatItem,
|
||
};
|
||
}
|
||
function settledTurn(id: string): TaskChatTurnItem {
|
||
return {
|
||
id,
|
||
kind: "turn",
|
||
settled: true,
|
||
items: [{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" }],
|
||
summary: buildTurnSummary([toolCall("Read")], { durationMs: 34_000 }),
|
||
};
|
||
}
|
||
|
||
it("keeps the description bubble above an unanchored turn that predates every backbone entry (F15)", () => {
|
||
// A stopped run whose startMs (0) is EARLIER than the oldest comment: it
|
||
// sorts to the very top of the assembled thread — the brief must still
|
||
// render above it, because the prepend runs after assembly.
|
||
const backbone = [entry("u1", 1_000), entry("u2", 3_000)];
|
||
const assembled = assembleThreadItems(backbone, new Map(), [
|
||
{ turn: settledTurn("run-x:turn"), startMs: 0 },
|
||
]);
|
||
const out = prependIssueBrief(assembled, true);
|
||
expect(out.map((i) => i.id)).toEqual([ISSUE_BRIEF_ITEM_ID, "run-x:turn", "u1", "u2"]);
|
||
expect(out[0].kind).toBe("brief");
|
||
});
|
||
|
||
it("is the first item of an otherwise empty thread, and a no-op without a brief", () => {
|
||
expect(prependIssueBrief([], true).map((i) => i.kind)).toEqual(["brief"]);
|
||
const items = [entry("u1", 1_000).item];
|
||
expect(prependIssueBrief(items, false)).toBe(items);
|
||
});
|
||
});
|