mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
2858 lines
85 KiB
TypeScript
2858 lines
85 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import type { TranscriptEntry } from "@/adapters";
|
||
import type { HeartbeatRunEvent } from "@paperclipai/shared";
|
||
import {
|
||
assembleThreadItems,
|
||
attachSettledTurns,
|
||
buildActivityPhases,
|
||
buildTurnTimelineRows,
|
||
buildMergedTurnSummary,
|
||
buildTurnSummary,
|
||
coalesceSettledTurns,
|
||
deriveRunStatusLabel,
|
||
embedPlanDocumentAtWriteBoundary,
|
||
flattenSelfTalk,
|
||
isNestableLiveChild,
|
||
ISSUE_BRIEF_ITEM_ID,
|
||
omitProgressRepeatedByResponseAcrossSegments,
|
||
paperclipRunnerActivityItems,
|
||
paperclipRunnerFinalResponse,
|
||
paperclipRunnerHistoryItems,
|
||
paperclipRunnerTimelineItems,
|
||
prependIssueBrief,
|
||
settledRunChildren,
|
||
splitTranscriptAtAnchors,
|
||
toolDisplayName,
|
||
transcriptToTaskChatItems,
|
||
runtimeRequestSegmentContext,
|
||
reconcileSegmentRuntimeRequests,
|
||
type SettledTurnMergeMeta,
|
||
type ThreadBackboneEntry,
|
||
} from "./transcript-adapter";
|
||
import type {
|
||
TaskChatItem,
|
||
TaskChatPlanDocumentItem,
|
||
TaskChatTurnItem,
|
||
} from "./task-chat-model";
|
||
import { latestPendingRuntimeRequest } from "./task-chat-model";
|
||
import { providerActivityPresentation } from "./task-chat-activity-presentation";
|
||
import { nativeRunEventsToTranscript } from "../transcript/native-run-events";
|
||
|
||
const TS = "2026-07-31T12:00:00.000Z";
|
||
|
||
describe("runtime requests across steering sections", () => {
|
||
it("selects the newer request when an older pending permission crosses steering", () => {
|
||
const older: TranscriptEntry = { kind: "runtime_request", ts: TS, requestId: "older-permission",
|
||
requestKind: "permission_approval", turnId: "provider-turn", requestType: "permission", status: "pending",
|
||
prompt: "Earlier write", choices: [{ key: "decline", label: "Deny" }], fields: [] };
|
||
const newer: TranscriptEntry = { ...older, ts: "2026-07-31T12:00:02.000Z", requestId: "newer-permission", prompt: "Later write" };
|
||
const options = { runId: "run-steering", running: true };
|
||
const context = runtimeRequestSegmentContext([older, newer], options);
|
||
const tail = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems([newer], options), [newer], context, true);
|
||
expect(tail.map(item => item.id)).toEqual(["run-steering:runtime-request:older-permission", "run-steering:runtime-request:newer-permission"]);
|
||
expect(latestPendingRuntimeRequest(tail)?.requestId).toBe("newer-permission");
|
||
});
|
||
|
||
it.each(["pending", "resolved", "cancelled"] as const)("keeps one %s permission under whole-run authority", status => {
|
||
const request: TranscriptEntry = { kind: "runtime_request", ts: TS, requestId: "permission-1",
|
||
requestKind: "permission_approval", turnId: "provider-turn", requestType: "permission", status: "pending",
|
||
prompt: "Pi write", choices: [{ key: "decline", label: "Deny" }], fields: [] };
|
||
const after: TranscriptEntry[] = [{ kind: "assistant", ts: "2026-07-31T12:00:01.000Z", text: "Continued.", channel: "progress" }];
|
||
if (status === "resolved") after.push({ ...request, ts: "2026-07-31T12:00:02.000Z", status, resolvedAction: "decline" });
|
||
const entries = [request, ...after];
|
||
const options = { runId: "run-steering", running: status !== "cancelled" };
|
||
const context = runtimeRequestSegmentContext(entries, options);
|
||
const history = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems([request], { ...options, running: false }), [request], context);
|
||
const tail = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(after, options), after, context, options.running);
|
||
const cards = [...history, ...tail].filter(item => item.kind === "protocol" && item.surface === "runtime_request");
|
||
expect(cards).toEqual([expect.objectContaining({ requestId: "permission-1", turnId: "provider-turn", status })]);
|
||
expect(history.filter(item => item.kind === "protocol")).toHaveLength(status === "pending" ? 0 : 1);
|
||
expect(tail.filter(item => item.kind === "protocol")).toHaveLength(status === "pending" ? 1 : 0);
|
||
});
|
||
});
|
||
|
||
describe("accepted native response-wake answers", () => {
|
||
const runId = "native-response-wake";
|
||
const summary =
|
||
"**Before release**\n- Test and rehearse rollback.\n\n**During release**\n- Deploy incrementally and watch errors.\n\n**After release**\n- Verify workflows and record follow-ups.";
|
||
function fixture(mode = "accepted") {
|
||
const result = {
|
||
schema: "paperclip.run_result.v1",
|
||
reportedWorkDisposition: "yielded",
|
||
summary,
|
||
completionClaim: { objectiveSatisfied: true, remainingWork: [] },
|
||
attentionRequests:
|
||
mode === "attention" ? [{ kind: "ask_user_questions" }] : [],
|
||
continuation: {
|
||
kind: mode === "question" ? "interaction" : "response_wake",
|
||
idempotencyKey: mode === "blank_key" ? " " : "next-exact-input",
|
||
},
|
||
evidence: [],
|
||
verification: [],
|
||
artifacts: [],
|
||
};
|
||
const event = (
|
||
seq: number,
|
||
eventType: string,
|
||
payload: Record<string, unknown>,
|
||
) =>
|
||
({
|
||
id: seq,
|
||
companyId: "company-1",
|
||
agentId: "agent-1",
|
||
stream: "system",
|
||
level: "info",
|
||
color: null,
|
||
message: null,
|
||
runId,
|
||
seq,
|
||
eventType,
|
||
createdAt: new Date(TS),
|
||
payload: {
|
||
prpEvent: {
|
||
schema: "paperclip.prp.event.v1",
|
||
schemaVersion: 1,
|
||
runId,
|
||
eventType,
|
||
sourceEventId: `response-${seq}`,
|
||
sourceKind:
|
||
eventType === "run.result.accepted" ||
|
||
eventType === "run.terminal"
|
||
? "control_plane"
|
||
: "runner",
|
||
sourceInstanceId: "runner-1",
|
||
sourceSeq: seq,
|
||
normalizedSessionId: "session-1",
|
||
emittedAt: TS,
|
||
payload,
|
||
},
|
||
},
|
||
}) satisfies HeartbeatRunEvent;
|
||
const accepted = event(3, "run.result.accepted", { result });
|
||
if (mode === "mismatched_run")
|
||
accepted.payload.prpEvent.runId = "other-run";
|
||
if (mode === "forged_kind")
|
||
accepted.payload.prpEvent.eventType = "item.completed";
|
||
if (mode === "missing_source") accepted.payload.prpEvent.sourceEventId = "";
|
||
const events = [
|
||
event(1, "item.completed", {
|
||
kind: "agentMessage",
|
||
channel: "progress",
|
||
item: {
|
||
id: "preamble",
|
||
type: "agentMessage",
|
||
channel: "progress",
|
||
text: "I’m providing the requested checklist and leaving the task open.",
|
||
},
|
||
}),
|
||
event(2, "run.result.proposed", result),
|
||
...(mode === "proposed" ? [] : [accepted]),
|
||
...(mode === "nonterminal"
|
||
? []
|
||
: [
|
||
event(4, "run.terminal", {
|
||
schema: "paperclip.prp.terminal.v1",
|
||
turnTerminalState: "completed",
|
||
runTerminalState:
|
||
mode === "failed_terminal" ? "failed" : "succeeded",
|
||
reportedWorkDisposition: "yielded",
|
||
}),
|
||
]),
|
||
...(mode === "provider_final"
|
||
? [
|
||
event(5, "item.completed", {
|
||
kind: "agentMessage",
|
||
channel: "final",
|
||
item: {
|
||
id: "other-final",
|
||
type: "agentMessage",
|
||
channel: "final",
|
||
text: "Provider commentary is not the accepted checklist.",
|
||
},
|
||
}),
|
||
]
|
||
: []),
|
||
];
|
||
const entries = nativeRunEventsToTranscript(events);
|
||
return transcriptToTaskChatItems(entries, { runId, running: false });
|
||
}
|
||
|
||
it.each(["accepted", "provider_final"])(
|
||
"renders the exact accepted response-wake summary instead of commentary: %s",
|
||
(mode) => {
|
||
expect(
|
||
paperclipRunnerFinalResponse(fixture(mode), { runId }),
|
||
).toMatchObject({
|
||
text: summary,
|
||
channel: "final",
|
||
});
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
"proposed",
|
||
"mismatched_run",
|
||
"forged_kind",
|
||
"missing_source",
|
||
"question",
|
||
"attention",
|
||
"blank_key",
|
||
"nonterminal",
|
||
"failed_terminal",
|
||
])("keeps %s evidence out of the response-wake final exception", (mode) => {
|
||
expect(
|
||
paperclipRunnerFinalResponse(fixture(mode), { runId }),
|
||
).toBeUndefined();
|
||
});
|
||
|
||
it("does not grant the exception to another run or a live fallback", () => {
|
||
expect(
|
||
paperclipRunnerFinalResponse(fixture(), { runId: "other-run" }),
|
||
).toBeUndefined();
|
||
expect(
|
||
paperclipRunnerFinalResponse(fixture(), { runId, allowFallback: false }),
|
||
).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe("completion tool feed visibility", () => {
|
||
it("keeps the full bounded notice text available when expanded", () => {
|
||
const summary = `${"Configuration context. ".repeat(30)}Use the project settings to fix this.`;
|
||
const [notice] = transcriptToTaskChatItems([{
|
||
kind: "provider_activity", ts: TS, family: "provider_notice", eventType: "provider.notice.recorded",
|
||
status: "informational", title: "Provider notice", summary,
|
||
payload: { noticeId: "long-notice", summary },
|
||
}], { runId: "notice", running: false });
|
||
expect(notice.kind === "protocol" && notice.surface === "provider_activity" &&
|
||
notice.details.find((detail) => detail.label === "Summary")?.value).toBe(summary);
|
||
});
|
||
|
||
it("keeps completion events inspectable but hides both tool representations from live and settled feed activity", () => {
|
||
for (const running of [true, false]) {
|
||
const entries: TranscriptEntry[] = [
|
||
{ kind: "tool_call", ts: TS, toolUseId: "legacy-finish", name: "paperclip_finish", input: {} },
|
||
...["paperclip_finish", "search_tasks"].map((name) => ({
|
||
kind: "provider_activity" as const, ts: TS, family: "tool_execution" as const,
|
||
eventType: running ? "tool.execution.started" : "tool.execution.completed",
|
||
status: running ? "running" as const : "completed" as const,
|
||
title: "Tool execution", summary: name,
|
||
payload: { executionId: name, name, transport: "dynamic" },
|
||
})),
|
||
{ kind: "provider_activity", ts: TS, family: "provider_notice", eventType: "provider.notice.recorded",
|
||
status: "informational", title: "Provider notice", summary: "Repository is not trusted",
|
||
payload: { noticeId: "notice", summary: "Repository is not trusted" } },
|
||
];
|
||
const parsed = transcriptToTaskChatItems(entries, { runId: "finish-visibility", running });
|
||
expect(parsed).toHaveLength(4);
|
||
const activity = paperclipRunnerActivityItems(parsed);
|
||
expect(activity).toHaveLength(2);
|
||
expect(JSON.stringify(activity)).not.toContain("paperclip_finish");
|
||
expect(JSON.stringify(activity)).toContain("search_tasks");
|
||
expect(JSON.stringify(activity)).toContain("Repository is not trusted");
|
||
expect(paperclipRunnerTimelineItems(parsed)).toEqual(activity);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("omitProgressRepeatedByResponseAcrossSegments", () => {
|
||
const progress = (id: string, text: string): TaskChatItem => ({
|
||
id,
|
||
kind: "message",
|
||
author: "agent",
|
||
text,
|
||
channel: "progress",
|
||
interstitial: true,
|
||
});
|
||
|
||
it("removes only the final matching progress item across a steered run", () => {
|
||
const first = progress("first", "Repeated answer");
|
||
const second = progress("second", "Repeated answer");
|
||
const segments = [[first], [second]];
|
||
|
||
expect(
|
||
omitProgressRepeatedByResponseAcrossSegments(segments, "Repeated answer"),
|
||
).toEqual([[first], []]);
|
||
});
|
||
|
||
it("preserves segment identity when there is no durable-response match", () => {
|
||
const segments = [[progress("first", "Progress only")]];
|
||
expect(
|
||
omitProgressRepeatedByResponseAcrossSegments(segments, "Final answer"),
|
||
).toBe(segments);
|
||
});
|
||
});
|
||
|
||
function toolCall(name: string, input?: unknown): TranscriptEntry {
|
||
return {
|
||
kind: "tool_call",
|
||
ts: TS,
|
||
name,
|
||
toolUseId: `tool-${name}`,
|
||
input,
|
||
} as TranscriptEntry;
|
||
}
|
||
|
||
describe("splitTranscriptAtAnchors", () => {
|
||
const assistant = (ts: string, text: string): TranscriptEntry =>
|
||
({ kind: "assistant", ts, channel: "progress", text }) as TranscriptEntry;
|
||
|
||
it("assigns each boundary and its following entries to the next segment", () => {
|
||
const entries = [
|
||
assistant("2026-08-25T17:20:50.000Z", "before"),
|
||
assistant("2026-08-25T17:21:44.541Z", "first boundary"),
|
||
assistant("2026-08-25T17:22:10.000Z", "between"),
|
||
assistant("2026-08-25T17:23:00.000Z", "second boundary"),
|
||
];
|
||
const segments = splitTranscriptAtAnchors(
|
||
entries,
|
||
Date.parse("2026-08-25T17:20:45.369Z"),
|
||
[
|
||
Date.parse("2026-08-25T17:23:00.000Z"),
|
||
Date.parse("2026-08-25T17:21:44.541Z"),
|
||
Date.parse("2026-08-25T17:21:44.541Z"),
|
||
],
|
||
);
|
||
|
||
expect(segments.map((segment) => segment.entries.length)).toEqual([
|
||
1, 2, 1,
|
||
]);
|
||
expect(
|
||
segments.map((segment) =>
|
||
segment.entries.map((entry) =>
|
||
entry.kind === "assistant" ? entry.text : entry.kind,
|
||
),
|
||
),
|
||
).toEqual([["before"], ["first boundary", "between"], ["second boundary"]]);
|
||
});
|
||
});
|
||
|
||
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 file contents");
|
||
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 a file");
|
||
expect(
|
||
deriveRunStatusLabel([toolCall("mcp__linear__search_issues")]).label,
|
||
).toBe("Searching 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("Unnamed tool");
|
||
expect(toolDisplayName("tool")).toBe("Unnamed tool");
|
||
});
|
||
|
||
it("maps acpx placeholder names to the explicit unnamed-tool label", () => {
|
||
expect(toolDisplayName("tool call")).toBe("Unnamed tool");
|
||
expect(toolDisplayName("tool call (completed)")).toBe("Unnamed tool");
|
||
expect(toolDisplayName("acp_tool")).toBe("Unnamed 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");
|
||
});
|
||
|
||
it("marks an unfinished tool interrupted once the transcript is settled", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[toolCall("Terminal", { command: "sleep 120" })],
|
||
opts,
|
||
);
|
||
expect(items).toHaveLength(1);
|
||
expect(items[0]).toMatchObject({
|
||
kind: "tool",
|
||
name: "Terminal",
|
||
status: "interrupted",
|
||
detail: "Interrupted before the provider reported completion.",
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("transcriptToTaskChatItems protocol surfaces", () => {
|
||
const opts = { runId: "run-protocol", running: false };
|
||
|
||
it("retains and updates provider activity instead of dropping it", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "provider_activity",
|
||
ts: TS,
|
||
family: "plan",
|
||
eventType: "plan.updated",
|
||
status: "running",
|
||
title: "Plan",
|
||
summary: "Drafting",
|
||
payload: {
|
||
planId: "plan-1",
|
||
revision: 1,
|
||
steps: [
|
||
{ stepId: "one", body: "Inventory", status: "in_progress" },
|
||
],
|
||
},
|
||
},
|
||
{
|
||
kind: "provider_activity",
|
||
ts: TS,
|
||
family: "plan",
|
||
eventType: "plan.updated",
|
||
status: "completed",
|
||
title: "Plan",
|
||
summary: "Ready",
|
||
payload: {
|
||
planId: "plan-1",
|
||
revision: 2,
|
||
steps: [{ stepId: "one", body: "Inventory", status: "completed" }],
|
||
},
|
||
},
|
||
],
|
||
opts,
|
||
);
|
||
expect(items).toHaveLength(1);
|
||
expect(items[0]).toMatchObject({
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family: "plan",
|
||
status: "completed",
|
||
summary: "Ready",
|
||
});
|
||
});
|
||
|
||
it("preserves named ACPX tool identity and progress when terminal updates omit them", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "provider_activity",
|
||
ts: TS,
|
||
family: "tool_execution",
|
||
eventType: "tool.execution.started",
|
||
status: "running",
|
||
title: "Tool execution",
|
||
summary: "Searching the task index",
|
||
payload: {
|
||
executionId: "exec-lifecycle",
|
||
transport: "mcp",
|
||
namespace: "paperclip",
|
||
name: "search_tasks",
|
||
operation: "search",
|
||
progress: "Searching the task index",
|
||
target: "doc/plan.md",
|
||
},
|
||
},
|
||
{
|
||
kind: "provider_activity",
|
||
ts: TS,
|
||
family: "tool_execution",
|
||
eventType: "tool.execution.completed",
|
||
status: "completed",
|
||
title: "Tool execution",
|
||
summary: "tool call",
|
||
payload: {
|
||
executionId: "exec-lifecycle",
|
||
transport: "builtin",
|
||
name: "tool call",
|
||
status: "completed",
|
||
output: "done",
|
||
},
|
||
},
|
||
] as TranscriptEntry[],
|
||
opts,
|
||
);
|
||
expect(items).toHaveLength(1);
|
||
const item = items[0];
|
||
expect(item).toMatchObject({
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family: "tool_execution",
|
||
status: "completed",
|
||
summary: "Searching the task index",
|
||
output: "done",
|
||
});
|
||
if (item.kind !== "protocol" || item.surface !== "provider_activity")
|
||
return;
|
||
expect(item.details).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({ label: "Name", value: "search_tasks" }),
|
||
expect.objectContaining({ label: "Namespace", value: "paperclip" }),
|
||
expect.objectContaining({ label: "Operation", value: "search" }),
|
||
expect.objectContaining({ label: "Target", value: "doc/plan.md" }),
|
||
expect.objectContaining({
|
||
label: "Progress",
|
||
value: "Searching the task index",
|
||
}),
|
||
]),
|
||
);
|
||
expect(providerActivityPresentation(item)).toMatchObject({
|
||
runningLabel: "Searching tasks",
|
||
completedLabel: "Searched tasks",
|
||
detail: "Searching the task index · Paperclip · search_tasks",
|
||
});
|
||
});
|
||
|
||
it("marks a running provider activity interrupted in a settled transcript", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "provider_activity",
|
||
ts: TS,
|
||
family: "tool_execution",
|
||
eventType: "tool.execution.started",
|
||
status: "running",
|
||
title: "Tool execution",
|
||
summary: "sleep 120",
|
||
payload: {},
|
||
},
|
||
],
|
||
opts,
|
||
);
|
||
expect(items[0]).toMatchObject({
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
status: "interrupted",
|
||
summary: "sleep 120 · Interrupted before completion.",
|
||
});
|
||
});
|
||
|
||
it("coalesces workspace revisions and runtime request lifecycle states", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "workspace_change",
|
||
ts: TS,
|
||
changeSetId: "change-1",
|
||
revision: 1,
|
||
source: "harness_reported",
|
||
complete: false,
|
||
files: [],
|
||
totals: { files: 0, additions: null, deletions: null },
|
||
patchArtifactRef: null,
|
||
},
|
||
{
|
||
kind: "workspace_change",
|
||
ts: TS,
|
||
changeSetId: "change-1",
|
||
revision: 2,
|
||
source: "runner_verified",
|
||
complete: true,
|
||
files: [
|
||
{
|
||
path: "ui/src/App.tsx",
|
||
operation: "modify",
|
||
previousPath: null,
|
||
additions: 1,
|
||
deletions: 0,
|
||
binary: false,
|
||
diff: "+ok",
|
||
},
|
||
],
|
||
totals: { files: 1, additions: 1, deletions: 0 },
|
||
patchArtifactRef: null,
|
||
},
|
||
{
|
||
kind: "runtime_request",
|
||
ts: TS,
|
||
requestId: "request-1",
|
||
requestKind: "command_approval",
|
||
turnId: "turn-1",
|
||
requestType: "permission",
|
||
status: "pending",
|
||
prompt: "Allow?",
|
||
choices: [],
|
||
fields: [],
|
||
},
|
||
{
|
||
kind: "runtime_request",
|
||
ts: TS,
|
||
requestId: "request-1",
|
||
requestKind: "command_approval",
|
||
turnId: "turn-1",
|
||
requestType: "permission",
|
||
status: "resolved",
|
||
prompt: "Allow?",
|
||
choices: [],
|
||
fields: [],
|
||
},
|
||
],
|
||
opts,
|
||
);
|
||
expect(items).toHaveLength(2);
|
||
expect(items[0]).toMatchObject({
|
||
kind: "protocol",
|
||
surface: "workspace_change",
|
||
revision: 2,
|
||
complete: true,
|
||
});
|
||
expect(items[1]).toMatchObject({
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-protocol",
|
||
turnId: "turn-1",
|
||
requestKind: "command_approval",
|
||
status: "resolved",
|
||
});
|
||
});
|
||
|
||
it("bounds provider output and workspace previews to 8 KiB", () => {
|
||
const oversized = "x".repeat(9 * 1024);
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "provider_activity",
|
||
ts: TS,
|
||
family: "tool_execution",
|
||
eventType: "tool.execution.completed",
|
||
status: "completed",
|
||
title: "Tool execution",
|
||
summary: "Done",
|
||
payload: { executionId: "exec-1", output: oversized },
|
||
},
|
||
{
|
||
kind: "workspace_file_reference",
|
||
ts: TS,
|
||
referenceId: "file-1",
|
||
source: "runner_verified",
|
||
path: "ui/src/App.tsx",
|
||
displayName: "App.tsx",
|
||
mediaType: "text/typescript",
|
||
presentation: "code",
|
||
line: null,
|
||
preview: oversized,
|
||
previewTruncated: false,
|
||
contentDigest: null,
|
||
},
|
||
],
|
||
opts,
|
||
);
|
||
const provider = items.find(
|
||
(item) =>
|
||
item.kind === "protocol" && item.surface === "provider_activity",
|
||
);
|
||
const file = items.find(
|
||
(item) => item.kind === "protocol" && item.surface === "workspace_file",
|
||
);
|
||
expect(
|
||
provider && provider.surface === "provider_activity"
|
||
? provider.output?.length
|
||
: 0,
|
||
).toBe(8 * 1024);
|
||
expect(
|
||
provider && provider.surface === "provider_activity"
|
||
? provider.outputTruncated
|
||
: false,
|
||
).toBe(true);
|
||
expect(
|
||
file && file.surface === "workspace_file" ? file.preview?.length : 0,
|
||
).toBe(8 * 1024);
|
||
expect(
|
||
file && file.surface === "workspace_file" ? file.previewTruncated : false,
|
||
).toBe(true);
|
||
});
|
||
|
||
it("turns canonical research sources into safe links and exposes notice metadata", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "provider_activity",
|
||
ts: TS,
|
||
family: "research",
|
||
eventType: "research.completed",
|
||
status: "completed",
|
||
title: "Research",
|
||
summary: "PRP",
|
||
payload: {
|
||
researchId: "research-1",
|
||
sources: [
|
||
{
|
||
title: "Protocol notes",
|
||
url: "https://example.com/prp",
|
||
snippet: "Canonical event details",
|
||
},
|
||
{ title: "Unsafe", url: "file:///etc/passwd" },
|
||
],
|
||
},
|
||
},
|
||
{
|
||
kind: "provider_activity",
|
||
ts: TS,
|
||
family: "provider_notice",
|
||
eventType: "provider.notice.recorded",
|
||
status: "informational",
|
||
title: "Provider notice",
|
||
summary: "Update configuration",
|
||
payload: {
|
||
noticeId: "notice-1",
|
||
severity: "warning",
|
||
category: "config",
|
||
scope: "environment",
|
||
recoverable: true,
|
||
userActionable: false,
|
||
},
|
||
},
|
||
],
|
||
opts,
|
||
);
|
||
const research = items.find(
|
||
(item) =>
|
||
item.kind === "protocol" &&
|
||
item.surface === "provider_activity" &&
|
||
item.family === "research",
|
||
);
|
||
const notice = items.find(
|
||
(item) =>
|
||
item.kind === "protocol" &&
|
||
item.surface === "provider_activity" &&
|
||
item.family === "provider_notice",
|
||
);
|
||
expect(
|
||
research?.kind === "protocol" && research.surface === "provider_activity"
|
||
? research.links
|
||
: [],
|
||
).toEqual([
|
||
{
|
||
label: "Protocol notes",
|
||
href: "https://example.com/prp",
|
||
description: "Canonical event details",
|
||
},
|
||
]);
|
||
expect(
|
||
notice?.kind === "protocol" && notice.surface === "provider_activity"
|
||
? notice.details
|
||
: [],
|
||
).toEqual(
|
||
expect.arrayContaining([
|
||
{ label: "Severity", value: "warning", mono: false },
|
||
{ label: "Category", value: "config", mono: false },
|
||
{ label: "Scope", value: "environment", mono: false },
|
||
{ label: "Recoverable", value: "Yes", mono: false },
|
||
{ label: "User Actionable", value: "No", mono: false },
|
||
]),
|
||
);
|
||
});
|
||
|
||
it("retains every canonical provider family for live and settled rendering", () => {
|
||
const families = [
|
||
"plan",
|
||
"tool_execution",
|
||
"research",
|
||
"delegation",
|
||
"model_identity",
|
||
"context",
|
||
"artifact",
|
||
"review",
|
||
"hook",
|
||
"memory",
|
||
"safety",
|
||
"terminal",
|
||
"wait",
|
||
"provider_notice",
|
||
] as const;
|
||
const entries = families.map((family, index): TranscriptEntry => ({
|
||
kind: "provider_activity",
|
||
ts: new Date(Date.parse(TS) + index).toISOString(),
|
||
family,
|
||
eventType: `${family}.fixture`,
|
||
status: index % 2 === 0 ? "running" : "completed",
|
||
title: family,
|
||
summary: `Visible ${family}`,
|
||
payload: { [`${family}Id`]: `${family}-1` },
|
||
}));
|
||
const items = transcriptToTaskChatItems(entries, {
|
||
...opts,
|
||
running: true,
|
||
});
|
||
expect(
|
||
items.map((item) =>
|
||
item.kind === "protocol" && item.surface === "provider_activity"
|
||
? item.family
|
||
: null,
|
||
),
|
||
).toEqual(families);
|
||
});
|
||
});
|
||
|
||
describe("buildActivityPhases provider summaries", () => {
|
||
it("includes canonical provider work in the folded activity summary", () => {
|
||
const provider = (
|
||
id: string,
|
||
family: "research" | "hook",
|
||
): TaskChatItem => ({
|
||
id,
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family,
|
||
eventType: `${family}.completed`,
|
||
status: "completed",
|
||
title: family,
|
||
details: [],
|
||
steps: [],
|
||
links: [],
|
||
children: [],
|
||
});
|
||
const phases = buildActivityPhases(
|
||
[
|
||
{
|
||
id: "finish",
|
||
kind: "tool",
|
||
name: "Paperclip_finish",
|
||
status: "completed",
|
||
},
|
||
provider("research-1", "research"),
|
||
provider("research-2", "research"),
|
||
provider("research-3", "research"),
|
||
provider("hook-1", "hook"),
|
||
{
|
||
id: "workspace",
|
||
kind: "protocol",
|
||
surface: "workspace_change",
|
||
changeSetId: "change-1",
|
||
revision: 1,
|
||
source: "runner_verified",
|
||
complete: true,
|
||
files: [],
|
||
totals: { files: 2, additions: 1, deletions: 1 },
|
||
patchArtifactRef: null,
|
||
},
|
||
],
|
||
false,
|
||
);
|
||
expect(phases[0]?.summary).toBe(
|
||
"Used a tool, searched 3 times, ran a hook",
|
||
);
|
||
});
|
||
|
||
it("counts canonical tool execution when no native tool row exists", () => {
|
||
const phases = buildActivityPhases(
|
||
[
|
||
{
|
||
id: "provider-tool",
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family: "tool_execution",
|
||
eventType: "tool.execution.completed",
|
||
status: "completed",
|
||
title: "Tool execution",
|
||
details: [],
|
||
steps: [],
|
||
links: [],
|
||
children: [],
|
||
},
|
||
],
|
||
false,
|
||
);
|
||
expect(phases[0]?.summary).toBe("Used a tool");
|
||
});
|
||
|
||
it("summarizes the sanitized DOT-212 mix with at most three semantic groups", () => {
|
||
const providerTool = (
|
||
id: string,
|
||
name: string,
|
||
operation: string,
|
||
namespace?: string,
|
||
): TaskChatItem => ({
|
||
id,
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family: "tool_execution",
|
||
eventType: "tool.execution.completed",
|
||
status: "completed",
|
||
title: "Tool execution",
|
||
details: [
|
||
{ label: "Transport", value: namespace ? "mcp" : "builtin" },
|
||
{ label: "Operation", value: operation },
|
||
{ label: "Name", value: name, mono: true },
|
||
...(namespace
|
||
? [{ label: "Namespace", value: namespace, mono: true }]
|
||
: []),
|
||
],
|
||
steps: [],
|
||
links: [],
|
||
children: [],
|
||
});
|
||
const items: TaskChatItem[] = [
|
||
...Array.from({ length: 6 }, (_, index) =>
|
||
providerTool(`search-${index}`, "ToolSearch", "search"),
|
||
),
|
||
providerTool("context", "get_task_context", "read", "paperclip"),
|
||
providerTool("documents", "list_documents", "list", "paperclip"),
|
||
providerTool("history", "get_task_history", "read", "paperclip"),
|
||
providerTool("tasks", "search_tasks", "search", "paperclip"),
|
||
providerTool("progress", "report_progress", "edit", "paperclip"),
|
||
providerTool("block", "paperclip_block", "edit", "paperclip"),
|
||
];
|
||
expect(buildActivityPhases(items, false)[0]?.summary).toBe(
|
||
"Searched available tools 6 times, read from Paperclip 3 times, used Paperclip 3 times",
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("buildTurnTimelineRows (DOT-217)", () => {
|
||
const commentary = (id: string, text: string): TaskChatItem => ({
|
||
id,
|
||
kind: "message",
|
||
author: "agent",
|
||
text,
|
||
interstitial: true,
|
||
channel: "progress",
|
||
});
|
||
const tool = (id: string): TaskChatItem => ({
|
||
id,
|
||
kind: "tool",
|
||
name: "Read",
|
||
rawName: "read_file",
|
||
target: `${id}.ts`,
|
||
status: "completed",
|
||
});
|
||
const request = (
|
||
id: string,
|
||
status: "pending" | "resolved" | "cancelled" | "expired",
|
||
): TaskChatItem => ({
|
||
id: `${id}:${status}`,
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-dot-217",
|
||
requestId: id,
|
||
requestKind: "user_input",
|
||
turnId: "turn-dot-217",
|
||
requestType: "input",
|
||
status,
|
||
prompt: `Question ${id}`,
|
||
choices: [],
|
||
fields: [],
|
||
});
|
||
|
||
const workspace: TaskChatItem = {
|
||
id: "workspace-change",
|
||
kind: "protocol",
|
||
surface: "workspace_change",
|
||
changeSetId: "turn-dot-217:workspace",
|
||
revision: 2,
|
||
source: "runner_verified",
|
||
complete: true,
|
||
files: [
|
||
{
|
||
path: "src/App.tsx",
|
||
operation: "modify",
|
||
previousPath: null,
|
||
additions: 2,
|
||
deletions: 1,
|
||
binary: false,
|
||
diff: "+new\n-old\n",
|
||
},
|
||
],
|
||
totals: { files: 1, additions: 2, deletions: 1 },
|
||
patchArtifactRef: null,
|
||
};
|
||
|
||
it("keeps commentary, grouped tools, and latest question receipts in first-seen order", () => {
|
||
const input = [
|
||
commentary("commentary-1", "Inspecting the workspace."),
|
||
tool("tool-1"),
|
||
tool("tool-2"),
|
||
commentary("commentary-2", "I need two product choices."),
|
||
request("questions-1", "pending"),
|
||
commentary("commentary-3", "I’ll continue with the selected scope."),
|
||
tool("tool-3"),
|
||
request("questions-2", "pending"),
|
||
commentary("commentary-4", "The final constraint is clear."),
|
||
tool("tool-4"),
|
||
request("questions-1", "resolved"),
|
||
request("questions-2", "cancelled"),
|
||
];
|
||
|
||
const rows = buildTurnTimelineRows(input, false);
|
||
expect(
|
||
rows.map((row) => {
|
||
if (row.kind === "activity_phase") {
|
||
return `${row.interstitial?.id ?? "opening"}:${row.items.map((item) => item.id).join(",")}`;
|
||
}
|
||
if (row.kind === "protocol" && row.surface === "runtime_request") {
|
||
return `${row.requestId}:${row.status}`;
|
||
}
|
||
return row.kind;
|
||
}),
|
||
).toEqual([
|
||
"commentary-1:tool-1,tool-2",
|
||
"commentary-2:",
|
||
"questions-1:resolved",
|
||
"commentary-3:tool-3",
|
||
"questions-2:cancelled",
|
||
"commentary-4:tool-4",
|
||
]);
|
||
});
|
||
|
||
it("keeps a pending request composer-only while preserving its group boundary", () => {
|
||
const rows = buildTurnTimelineRows(
|
||
[tool("before"), request("pending", "pending"), tool("after")],
|
||
true,
|
||
);
|
||
|
||
expect(rows).toHaveLength(2);
|
||
expect(rows.every((row) => row.kind === "activity_phase")).toBe(true);
|
||
expect(
|
||
rows.map((row) =>
|
||
row.kind === "activity_phase" ? row.items[0]?.id : null,
|
||
),
|
||
).toEqual(["before", "after"]);
|
||
expect(rows[0]).toMatchObject({ kind: "activity_phase", active: false });
|
||
expect(rows[1]).toMatchObject({ kind: "activity_phase", active: true });
|
||
});
|
||
|
||
it("retains progress-only phases and uses the same row order for settled assembly", () => {
|
||
const input = [
|
||
tool("opening"),
|
||
commentary("commentary-only", "Still checking the edge case."),
|
||
request("expired", "expired"),
|
||
];
|
||
expect(settledRunChildren(input)).toEqual(
|
||
buildTurnTimelineRows(input, false),
|
||
);
|
||
expect(buildTurnTimelineRows(input, false).map((row) => row.kind)).toEqual([
|
||
"activity_phase",
|
||
"activity_phase",
|
||
"protocol",
|
||
]);
|
||
});
|
||
|
||
it("keeps runtime requests in the Paperclip Runner timeline input", () => {
|
||
const pending = request("pending", "pending");
|
||
expect(
|
||
paperclipRunnerTimelineItems([
|
||
commentary("commentary", "Checking."),
|
||
pending,
|
||
{ id: "usage", kind: "usage", usage: { used: 1, size: 10 } },
|
||
]).map((item) => item.id),
|
||
).toEqual(["commentary", pending.id, "usage"]);
|
||
});
|
||
|
||
it("keeps aggregate workspace changes at the turn boundary outside activity", () => {
|
||
const rows = buildTurnTimelineRows(
|
||
paperclipRunnerTimelineItems([tool("before"), workspace, tool("after")]),
|
||
false,
|
||
);
|
||
expect(rows.map((row) => row.kind)).toEqual([
|
||
"activity_phase",
|
||
"protocol",
|
||
"activity_phase",
|
||
]);
|
||
expect(rows[1]).toMatchObject({
|
||
surface: "workspace_change",
|
||
changeSetId: "turn-dot-217:workspace",
|
||
});
|
||
});
|
||
|
||
it("suppresses file-change fragments only when an aggregate event exists", () => {
|
||
const fileChange: TaskChatItem = {
|
||
id: "provider-file-change",
|
||
kind: "tool",
|
||
name: "File change",
|
||
rawName: "fileChange",
|
||
status: "completed",
|
||
diff: { path: "src/App.tsx", added: 2, removed: 1 },
|
||
};
|
||
expect(paperclipRunnerActivityItems([fileChange])).toEqual([fileChange]);
|
||
expect(paperclipRunnerActivityItems([fileChange, workspace])).toEqual([
|
||
workspace,
|
||
]);
|
||
});
|
||
|
||
it("keeps a yielded run-result and provider wait prose out of the durable final slot", () => {
|
||
expect(
|
||
paperclipRunnerFinalResponse([
|
||
{
|
||
id: "provider-wait",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "Waiting for Review browser RTS plan.",
|
||
channel: "final",
|
||
streaming: false,
|
||
},
|
||
{
|
||
id: "result",
|
||
kind: "protocol",
|
||
surface: "run_result",
|
||
disposition: "yielded",
|
||
summary: "Waiting for Review browser RTS plan.",
|
||
objectiveSatisfied: false,
|
||
verification: [],
|
||
remainingWork: [],
|
||
blocker: null,
|
||
artifacts: [],
|
||
},
|
||
]),
|
||
).toBeUndefined();
|
||
});
|
||
|
||
it("keeps a saved plan at its write_document boundary", () => {
|
||
const plan: TaskChatPlanDocumentItem = {
|
||
id: "plan-doc:revision-1",
|
||
kind: "plan_document",
|
||
document: {} as TaskChatPlanDocumentItem["document"],
|
||
};
|
||
const embedded = embedPlanDocumentAtWriteBoundary(
|
||
[
|
||
commentary("commentary-1", "Inspecting."),
|
||
tool("read-before-plan"),
|
||
commentary("commentary-2", "Publishing the plan."),
|
||
{
|
||
id: "write-plan",
|
||
kind: "tool",
|
||
name: "Write document",
|
||
rawName: "write_document",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "write-plan-provider",
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family: "tool_execution",
|
||
eventType: "tool.execution.completed",
|
||
status: "completed",
|
||
title: "write_document",
|
||
details: [{ label: "Name", value: "write_document" }],
|
||
steps: [],
|
||
links: [],
|
||
children: [],
|
||
},
|
||
commentary("commentary-3", "The plan is published."),
|
||
],
|
||
plan,
|
||
);
|
||
|
||
const rows = buildTurnTimelineRows(
|
||
paperclipRunnerTimelineItems(embedded),
|
||
false,
|
||
);
|
||
expect(
|
||
rows.map((row) =>
|
||
row.kind === "activity_phase"
|
||
? `${row.interstitial?.id}:${row.items.map((item) => item.id).join(",")}`
|
||
: row.id,
|
||
),
|
||
).toEqual([
|
||
"commentary-1:read-before-plan",
|
||
"commentary-2:write-plan,write-plan-provider",
|
||
plan.id,
|
||
"commentary-3:",
|
||
]);
|
||
expect(
|
||
embedded.find((item) => item.kind === "plan_document"),
|
||
).toMatchObject({ placement: "write_boundary" });
|
||
});
|
||
|
||
it("retains a saved plan as an explicit fallback when its write boundary is unavailable", () => {
|
||
const plan: TaskChatPlanDocumentItem = {
|
||
id: "plan-doc:revision-fallback",
|
||
kind: "plan_document",
|
||
document: {} as TaskChatPlanDocumentItem["document"],
|
||
};
|
||
|
||
expect(
|
||
embedPlanDocumentAtWriteBoundary(
|
||
[commentary("commentary", "Transcript is still hydrating.")],
|
||
plan,
|
||
),
|
||
).toEqual([
|
||
commentary("commentary", "Transcript is still hydrating."),
|
||
{ ...plan, placement: "fallback" },
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe("transcriptToTaskChatItems native usage", () => {
|
||
it("does not merge progress and final runner messages", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "assistant",
|
||
ts: TS,
|
||
text: "Checking files.",
|
||
channel: "progress",
|
||
},
|
||
{
|
||
kind: "assistant",
|
||
ts: TS,
|
||
text: "The change is ready.",
|
||
channel: "final",
|
||
},
|
||
],
|
||
{ runId: "native-run", running: true },
|
||
);
|
||
|
||
expect(items).toEqual([
|
||
expect.objectContaining({
|
||
kind: "message",
|
||
text: "Checking files.",
|
||
channel: "progress",
|
||
interstitial: true,
|
||
}),
|
||
expect.objectContaining({
|
||
kind: "message",
|
||
text: "The change is ready.",
|
||
channel: "final",
|
||
interstitial: false,
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it("keeps separate completed commentary items as separate chronological paragraphs", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "assistant",
|
||
ts: TS,
|
||
text: "I am checking the existing behavior.",
|
||
channel: "progress",
|
||
itemId: "message-1",
|
||
},
|
||
{
|
||
kind: "assistant",
|
||
ts: "2026-07-31T12:00:01.000Z",
|
||
text: "The implementation is ready.",
|
||
channel: "progress",
|
||
itemId: "message-2",
|
||
},
|
||
],
|
||
{ runId: "native-run", running: false },
|
||
);
|
||
|
||
expect(items).toEqual([
|
||
expect.objectContaining({
|
||
kind: "message",
|
||
text: "I am checking the existing behavior.",
|
||
}),
|
||
expect.objectContaining({
|
||
kind: "message",
|
||
text: "The implementation is ready.",
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it("coalesces only streaming chunks that share an item identity", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "assistant",
|
||
ts: TS,
|
||
text: "Still ",
|
||
channel: "progress",
|
||
delta: true,
|
||
itemId: "message-1",
|
||
},
|
||
{
|
||
kind: "assistant",
|
||
ts: TS,
|
||
text: "working",
|
||
channel: "progress",
|
||
delta: true,
|
||
itemId: "message-1",
|
||
},
|
||
{
|
||
kind: "assistant",
|
||
ts: TS,
|
||
text: "Next message",
|
||
channel: "progress",
|
||
delta: true,
|
||
itemId: "message-2",
|
||
},
|
||
],
|
||
{ runId: "native-run", running: true },
|
||
);
|
||
|
||
expect(items).toEqual([
|
||
expect.objectContaining({ kind: "message", text: "Still working" }),
|
||
expect.objectContaining({ kind: "message", text: "Next message" }),
|
||
]);
|
||
});
|
||
|
||
it("keeps distinct completed reasoning items separate even on the same channel", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "thinking",
|
||
ts: TS,
|
||
text: "Inspecting transport.",
|
||
lifecycle: "completed",
|
||
channel: "summary",
|
||
itemId: "reason-1",
|
||
},
|
||
{
|
||
kind: "thinking",
|
||
ts: "2026-07-31T12:00:01.000Z",
|
||
text: "Checking persistence.",
|
||
lifecycle: "completed",
|
||
channel: "summary",
|
||
itemId: "reason-2",
|
||
},
|
||
],
|
||
{ runId: "native-run", running: false },
|
||
);
|
||
|
||
expect(items).toEqual([
|
||
expect.objectContaining({
|
||
kind: "thinking",
|
||
lines: ["Inspecting transport."],
|
||
}),
|
||
expect.objectContaining({
|
||
kind: "thinking",
|
||
lines: ["Checking persistence."],
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it("renders runner usage without inventing a context-window size", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "result",
|
||
ts: TS,
|
||
text: "",
|
||
inputTokens: 40,
|
||
outputTokens: 10,
|
||
cachedTokens: 5,
|
||
costUsd: 0.02,
|
||
subtype: "paperclip_runner_usage",
|
||
isError: false,
|
||
errors: [],
|
||
},
|
||
],
|
||
{ runId: "native-run", running: true },
|
||
);
|
||
|
||
expect(items).toEqual([
|
||
{
|
||
id: "native-run:usage:0",
|
||
kind: "usage",
|
||
usage: {
|
||
used: 55,
|
||
size: 0,
|
||
inputTokens: 40,
|
||
outputTokens: 10,
|
||
costUsd: 0.02,
|
||
},
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("renders cumulative-only runner session usage", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "result",
|
||
ts: TS,
|
||
text: "",
|
||
inputTokens: 80,
|
||
outputTokens: 20,
|
||
cachedTokens: 0,
|
||
costUsd: 0,
|
||
subtype: "paperclip_runner_session_usage",
|
||
isError: false,
|
||
errors: [],
|
||
},
|
||
],
|
||
{ runId: "native-run", running: true },
|
||
);
|
||
|
||
expect(items).toEqual([
|
||
expect.objectContaining({
|
||
kind: "usage",
|
||
label: "Provider session total",
|
||
detail: expect.stringContaining("cumulative usage"),
|
||
usage: expect.objectContaining({
|
||
used: 100,
|
||
inputTokens: 80,
|
||
outputTokens: 20,
|
||
}),
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it("does not change direct-adapter result presentation", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "result",
|
||
ts: TS,
|
||
text: "done",
|
||
inputTokens: 40,
|
||
outputTokens: 10,
|
||
cachedTokens: 0,
|
||
costUsd: 0,
|
||
subtype: "success",
|
||
isError: false,
|
||
errors: [],
|
||
},
|
||
],
|
||
{ runId: "legacy-run", running: true },
|
||
);
|
||
expect(items).toEqual([]);
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
it("keeps session-cumulative runner usage out of run summaries", () => {
|
||
const runUsage = {
|
||
kind: "result",
|
||
ts: TS,
|
||
subtype: "paperclip_runner_usage",
|
||
inputTokens: 40,
|
||
outputTokens: 10,
|
||
} as TranscriptEntry;
|
||
const sessionUsage = {
|
||
kind: "result",
|
||
ts: TS,
|
||
subtype: "paperclip_runner_session_usage",
|
||
inputTokens: 800,
|
||
outputTokens: 200,
|
||
} as TranscriptEntry;
|
||
|
||
expect(buildTurnSummary([runUsage, sessionUsage]).tokensLabel).toBe(
|
||
"50 tokens",
|
||
);
|
||
expect(
|
||
buildMergedTurnSummary([
|
||
{ entries: [runUsage] },
|
||
{ entries: [sessionUsage] },
|
||
]).tokensLabel,
|
||
).toBe("50 tokens");
|
||
});
|
||
});
|
||
|
||
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);
|
||
}
|
||
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);
|
||
}
|
||
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("excludes an explicit final reply when runner usage follows it", () => {
|
||
const finalThenUsage = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "assistant",
|
||
ts: TS,
|
||
text: "Done — the limiter is wired in.",
|
||
channel: "final",
|
||
} as TranscriptEntry,
|
||
{
|
||
kind: "result",
|
||
ts: TS,
|
||
text: "",
|
||
inputTokens: 40,
|
||
outputTokens: 10,
|
||
cachedTokens: 0,
|
||
costUsd: 0,
|
||
subtype: "paperclip_runner_usage",
|
||
isError: false,
|
||
errors: [],
|
||
} as TranscriptEntry,
|
||
],
|
||
{ runId: "native-run", running: false },
|
||
);
|
||
|
||
const children = settledRunChildren(finalThenUsage);
|
||
expect(children).toHaveLength(1);
|
||
const phase = children[0];
|
||
expect(phase.kind).toBe("activity_phase");
|
||
if (phase.kind !== "activity_phase") return;
|
||
expect(phase.interstitial).toBeUndefined();
|
||
expect(phase.items.map((item) => item.kind)).toEqual(["usage"]);
|
||
});
|
||
|
||
it("keeps a channel-less final reply out of activity when bookkeeping follows it", () => {
|
||
const finalThenBookkeeping = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "assistant",
|
||
ts: TS,
|
||
text: "Done — the direct adapter finished.",
|
||
} as TranscriptEntry,
|
||
{
|
||
kind: "result",
|
||
ts: TS,
|
||
text: "",
|
||
inputTokens: 40,
|
||
outputTokens: 10,
|
||
cachedTokens: 0,
|
||
costUsd: 0,
|
||
subtype: "paperclip_runner_usage",
|
||
isError: false,
|
||
errors: [],
|
||
} as TranscriptEntry,
|
||
],
|
||
{ runId: "legacy-run", running: false },
|
||
);
|
||
|
||
const children = settledRunChildren(finalThenBookkeeping);
|
||
expect(children).toHaveLength(1);
|
||
const phase = children[0];
|
||
expect(phase.kind).toBe("activity_phase");
|
||
if (phase.kind !== "activity_phase") return;
|
||
expect(phase.interstitial).toBeUndefined();
|
||
expect(phase.items.map((item) => item.kind)).toEqual(["usage"]);
|
||
});
|
||
|
||
it("keeps a channel-less final reply out of activity when a workspace summary follows it", () => {
|
||
const finalReply = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "assistant",
|
||
ts: TS,
|
||
text: "Done — the workspace is updated.",
|
||
} as TranscriptEntry,
|
||
],
|
||
{ runId: "legacy-run", running: false },
|
||
);
|
||
const children = settledRunChildren([
|
||
...finalReply,
|
||
{
|
||
id: "workspace-summary",
|
||
kind: "protocol",
|
||
surface: "workspace_change",
|
||
changeSetId: "changes-1",
|
||
revision: 1,
|
||
source: "harness_reported",
|
||
complete: true,
|
||
files: [],
|
||
totals: { files: 0, additions: 0, deletions: 0 },
|
||
patchArtifactRef: null,
|
||
},
|
||
]);
|
||
|
||
expect(children).toHaveLength(1);
|
||
expect(children[0]?.kind).toBe("protocol");
|
||
expect(
|
||
children.some(
|
||
(child) =>
|
||
child.kind === "activity_phase" &&
|
||
child.interstitial?.text === "Done — the workspace is updated.",
|
||
),
|
||
).toBe(false);
|
||
});
|
||
|
||
it("does not hide channel-less commentary when real activity follows it", () => {
|
||
const commentaryThenToolAndUsage = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "assistant",
|
||
ts: TS,
|
||
text: "Checking the direct adapter first.",
|
||
} as TranscriptEntry,
|
||
toolCall("Read", { file_path: "a.ts" }),
|
||
{
|
||
kind: "result",
|
||
ts: TS,
|
||
text: "",
|
||
inputTokens: 40,
|
||
outputTokens: 10,
|
||
cachedTokens: 0,
|
||
costUsd: 0,
|
||
subtype: "paperclip_runner_usage",
|
||
isError: false,
|
||
errors: [],
|
||
} as TranscriptEntry,
|
||
],
|
||
{ runId: "legacy-run", running: false },
|
||
);
|
||
|
||
const children = settledRunChildren(commentaryThenToolAndUsage);
|
||
expect(children).toHaveLength(1);
|
||
const phase = children[0];
|
||
expect(phase.kind).toBe("activity_phase");
|
||
if (phase.kind !== "activity_phase") return;
|
||
expect(phase.interstitial?.text).toBe("Checking the direct adapter first.");
|
||
expect(phase.items.map((item) => item.kind)).toEqual(["tool", "usage"]);
|
||
});
|
||
|
||
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 a file",
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("paperclip runner semantic channels", () => {
|
||
it("concatenates fragmented reasoning deltas into logical lines", () => {
|
||
const parsed = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "thinking",
|
||
ts: TS,
|
||
text: "Inspect",
|
||
delta: true,
|
||
channel: "summary",
|
||
},
|
||
{
|
||
kind: "thinking",
|
||
ts: TS,
|
||
text: "ing the ",
|
||
delta: true,
|
||
channel: "summary",
|
||
},
|
||
{
|
||
kind: "thinking",
|
||
ts: TS,
|
||
text: "runner",
|
||
delta: true,
|
||
channel: "summary",
|
||
},
|
||
{
|
||
kind: "thinking",
|
||
ts: TS,
|
||
text: "\nThen ",
|
||
delta: true,
|
||
channel: "summary",
|
||
},
|
||
{
|
||
kind: "thinking",
|
||
ts: TS,
|
||
text: "verify streaming",
|
||
delta: true,
|
||
channel: "summary",
|
||
},
|
||
],
|
||
{ runId: "run-fragmented-reasoning", running: true },
|
||
);
|
||
|
||
expect(parsed).toHaveLength(1);
|
||
expect(parsed[0]).toMatchObject({
|
||
kind: "thinking",
|
||
lines: ["Inspecting the runner", "Then verify streaming"],
|
||
transcriptIndex: 4,
|
||
});
|
||
});
|
||
|
||
it("ends reasoning when a newer transcript surface becomes current", () => {
|
||
const parsed = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "thinking",
|
||
ts: TS,
|
||
text: "Inspecting the runner",
|
||
channel: "summary",
|
||
delta: true,
|
||
},
|
||
{
|
||
kind: "assistant",
|
||
ts: TS,
|
||
text: "I found the relevant component.",
|
||
channel: "progress",
|
||
},
|
||
{
|
||
kind: "thinking",
|
||
ts: TS,
|
||
text: "Checking the render state",
|
||
channel: "summary",
|
||
delta: true,
|
||
},
|
||
toolCall("Read", { file_path: "TaskChatThinking.tsx" }),
|
||
{
|
||
kind: "thinking",
|
||
ts: TS,
|
||
text: "Verifying the fix",
|
||
channel: "summary",
|
||
delta: true,
|
||
},
|
||
],
|
||
{ runId: "run-reasoning-activity", running: true },
|
||
);
|
||
const thinking = parsed.filter(
|
||
(item): item is Extract<TaskChatItem, { kind: "thinking" }> =>
|
||
item.kind === "thinking",
|
||
);
|
||
expect(thinking.map((item) => item.streaming)).toEqual([
|
||
false,
|
||
false,
|
||
true,
|
||
]);
|
||
});
|
||
|
||
it("turns empty reasoning lifecycle frames into a visible thinking item", () => {
|
||
const parsed = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "thinking",
|
||
ts: "2026-08-21T12:00:00.000Z",
|
||
text: "",
|
||
lifecycle: "started",
|
||
channel: "summary",
|
||
},
|
||
{
|
||
kind: "thinking",
|
||
ts: "2026-08-21T12:00:02.000Z",
|
||
text: "",
|
||
lifecycle: "completed",
|
||
channel: "summary",
|
||
},
|
||
],
|
||
{ runId: "run-empty-reasoning", running: true },
|
||
);
|
||
|
||
expect(parsed).toEqual([
|
||
expect.objectContaining({
|
||
kind: "thinking",
|
||
lines: [],
|
||
streaming: false,
|
||
lifecycleOnly: true,
|
||
summaryLabel: "Thought for 2s",
|
||
}),
|
||
]);
|
||
});
|
||
|
||
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", streaming: false });
|
||
expect(parsed[2]).toMatchObject({ channel: "detail", streaming: false });
|
||
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);
|
||
});
|
||
|
||
it("projects one semantic activity row per useful logical item", () => {
|
||
const items: TaskChatItem[] = [
|
||
{
|
||
id: "progress",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "Looking this up.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
},
|
||
{
|
||
id: "final",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "Finished.",
|
||
channel: "final",
|
||
},
|
||
{
|
||
id: "empty-thinking",
|
||
kind: "thinking",
|
||
lines: [],
|
||
lifecycleOnly: true,
|
||
},
|
||
{
|
||
id: "thinking",
|
||
kind: "thinking",
|
||
lines: ["Provider-authored summary"],
|
||
},
|
||
{
|
||
id: "tool",
|
||
kind: "tool",
|
||
name: "Search",
|
||
rawName: "web_search",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "finish",
|
||
kind: "tool",
|
||
name: "Paperclip_finish",
|
||
rawName: "paperclip_finish",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "session",
|
||
kind: "marker",
|
||
variant: "session_start",
|
||
label: "Session started",
|
||
},
|
||
{
|
||
id: "interrupt",
|
||
kind: "marker",
|
||
variant: "interrupted",
|
||
label: "Interrupted",
|
||
},
|
||
{ id: "usage", kind: "usage", usage: { used: 20, size: 100 } },
|
||
{
|
||
id: "research",
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family: "research",
|
||
eventType: "research.completed",
|
||
status: "completed",
|
||
title: "Research",
|
||
details: [],
|
||
steps: [],
|
||
links: [],
|
||
children: [],
|
||
},
|
||
{
|
||
id: "result",
|
||
kind: "protocol",
|
||
surface: "run_result",
|
||
disposition: "done",
|
||
summary: "Finished.",
|
||
objectiveSatisfied: true,
|
||
verification: [],
|
||
remainingWork: [],
|
||
blocker: null,
|
||
artifacts: [],
|
||
},
|
||
{
|
||
id: "terminal",
|
||
kind: "protocol",
|
||
surface: "run_terminal",
|
||
turnState: "completed",
|
||
runState: "succeeded",
|
||
disposition: "done",
|
||
},
|
||
];
|
||
|
||
expect(paperclipRunnerActivityItems(items).map((item) => item.id)).toEqual([
|
||
"progress",
|
||
"thinking",
|
||
"tool",
|
||
"interrupt",
|
||
"usage",
|
||
"research",
|
||
]);
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|