mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task chat shows each native runner turn while it streams, while it settles, and after it settles. > - During the onboarding interview, the agent streams a reply, posts a question card, and ends its turn as `yielded`. > - The live tail hides that reply when the card's pending interaction arrives. The settled turn also hides it, because a yielded turn never fills the final-response slot. > - The user sees the reply appear and then disappear. The reply is still in run history, so this is a rendering regression, not data loss. > - This pull request hides only the control-plane wait stub of a yielded turn. It keeps prose that the agent wrote for the user. > - The benefit is a stable conversation while responses stream and finish. ## Linked Issues or Issue Description **Bug description** During a hands-on onboarding test on staging, interview messages in the first-task chat appeared and then disappeared. **Steps to reproduce** 1. Start onboarding and pick "Interview me and propose a plan". 2. Watch the agent's turn. It streams a reply that describes the question card, for example "I've posted a question card on this task that asks four things...". 3. The question card appears. The reply disappears and does not come back after the run settles or after a reload. **Expected behavior** The conversation stays stable while responses stream and finish. **Related** Refs #18977. That PR changes server-side final-response selection for native standing conversations and leaves yielded waits and cards in control. This PR changes only how the task chat renders a yielded turn's authored reply. The two PRs do not overlap. **Evidence and root cause** - Persisted history is complete. The run's events contain the `agentMessage` `item.completed` with `channel: "final"` and the full reply text. The `run.result.accepted` result has `reportedWorkDisposition: "yielded"` with a `response_wake` continuation. Its summary is different from the reply. - The rendered thread does not contain that reply, also after "More messages" expands the history. - Classification: rendering/replacement, not persistence. Three gates drop the reply. `TaskChatThread` sets `suppressFinal` on the live tail when a pending interaction for that run exists. `TaskChatRunnerTurn` then clears the latched final. `paperclipRunnerFinalResponse` and the settled-turn path (`!sourceYielded`) return no final response for yielded turns. - Those gates were added on purpose so the mechanical "Waiting for <card>." prose that some providers echo does not show as an answer. This change keeps that behavior. ## What Changed - `transcript-adapter.ts`: add `isYieldedWaitStub`. A wait stub is text that echoes the yielded result summary, or a single-line "Waiting for ..." text. For a yielded turn, `paperclipRunnerFinalResponse` now returns the last authored final-channel message. It still returns nothing for a wait stub. New `yielded` and `waitSummary` options let callers state the yield from run metadata. - `TaskChatRunnerTurn.tsx`: `suppressFinal` hides only a wait stub. It no longer hides the agent's authored reply. A new `waitSummary` prop carries the accepted summary. - `TaskChatThread.tsx`: a settled yielded turn renders the authored reply once, in the last populated segment. The reply also counts as a native response for the "no user-facing response" marker and for progress de-duplication. The live tail passes the accepted summary to the runner turn. - Review round 1 (commitc134d72620): a run that posted a question card stays out of the accepted response-wake path while the card is pending and after it is answered. Before, an answered card let the wait summary replace the reply. The backward scan now skips wait stubs, so a stub echoed after the reply does not hide it. The accepted response-wake summary still has precedence for runs that posted no card. The existing contract test for that still passes. - Review round 2 (commit4f300fbb3e): when a steering anchor splits a yielded turn, the reply renders in the segment that contains it. The settled final-response choice moves from a nested ternary into a helper. `TaskChatRunnerTurn`'s `suppressFinal` prop is renamed `awaitingContinuation`. - Tests: a `TaskChatThread` red/green test feeds native run events through the three states: live stream, live with the pending card, and settled as yielded. An adapter unit test covers authored prose versus the two wait-stub forms. ## Verification Red (current `master` source with the new test). The reply disappears while the run is still live, at the moment the question card arrives: ``` RUN v5.0.3 /Users/dotta/paperclipai/paperclip/.claude/worktrees/fix-bug02-vanishing-messages-3e49c5/ui ❯ src/components/TaskChatThread.test.tsx (365 tests | 1 failed | 364 skipped) 664ms ❯ TaskChatThread runtime transcript selection (169) × keeps a streamed interview reply visible after its run yields to a question card 662ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL src/components/TaskChatThread.test.tsx > TaskChatThread runtime transcript selection > keeps a streamed interview reply visible after its run yields to a question card AssertionError: expected 'Unanswered questionWhat does your org…' to contain 'I\'ve posted a question card' Expected: "I've posted a question card" Received: "Unanswered questionWhat does your organization do?RunnerWorking for 103767m 18sThinkingWhat does your organization do?What does your organization do?YesNoOther CancelSubmit answers" ❯ src/components/TaskChatThread.test.tsx:3215:35 3213| linkedRuns: [linkedRun("running")], 3214| }); 3215| expect(container.textContent).toContain("I've posted a question ca… | ^ 3216| 3217| // 3. Settled: the run yielded to the question card it posted. ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Test Files 1 failed (1) Tests 1 failed | 364 skipped (365) Start at 13:26:47 Duration 31.59s (transform 71%, import 19%, environment 8%, tests 2%) ``` The first red run failed one step later, at the settled step (before I added the live-with-card step). Both steps fail on `master`: ``` AssertionError: expected 'RunnerWorked for 30sUnanswered questi…' to contain 'I\'ve posted a question card' Received: "RunnerWorked for 30sUnanswered questionWhat does your organization do?...Submit answers" ``` Green (same test, with the fix): ``` Test Files 2 passed (2) Tests 2 passed | 479 skipped (481) ``` Neighbouring suites (`TaskChatThread*`, all of `src/components/task-chat/`, `IssueChatThread`, `issue-chat-messages`): ``` Test Files 59 passed (59) Tests 1411 passed | 1 skipped (1412) ``` This includes the existing tests "keeps a yielded question turn out of the final-response slot", "keeps a yielded run-result and provider wait prose out of the durable final slot", and every "renders only an authorized terminal response-wake answer" mode. They still pass, so wait stubs stay hidden. Review round 1 red/green. I added a fourth test step that answers the card. With the old pending-only check, that step fails: ``` × keeps a streamed interview reply visible after its run yields to a question card Received: "RunnerWorked for 30sWaiting for user answers on the interview question card.Questions answered..." ``` Withc134d72620, the step passes. The neighbouring suites pass again: `Test Files 59 passed (59)`, `Tests 1411 passed | 1 skipped (1412)`. Review round 2 red/green. A new steered-yielded test fails with the old last-segment placement (`expected 163 to be less than 52`: the reply rendered after the steering message). It passes with the fix. Neighbouring suites, now including `design-sandbox`: `Test Files 61 passed (61)`, `Tests 1426 passed | 1 skipped (1427)`. Other checks: - `node scripts/check-token-gates.mjs`: all gates clean. The change has no styling edits. - `ui` `tsc --noEmit`: no errors in the changed files. The remaining errors are already on `master` in this worktree: missing generated storybook catalog JSON, and workspace packages that are not built. - I did not run the full `pnpm -r typecheck`, `pnpm test:run`, or `pnpm build` locally. CI runs them. ## Risks - A yielded turn can now show a final reply above its question card. This is the text that the user already saw while the turn streamed. The text now stays instead of flashing away. - The wait-stub check is heuristic. A one-line authored reply that starts with "Waiting for" stays hidden, as before. A multi-line provider stub that does not match the summary now shows. - No data, API, or schema changes. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with tool use (shell, file edit, browser inspection of the staging run history). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
3112 lines
101 KiB
TypeScript
3112 lines
101 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, userActionable: true },
|
||
}], { 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", userActionable: true } },
|
||
];
|
||
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);
|
||
// The turn's changed files close the turn, after its other activity.
|
||
expect(items[1]).toMatchObject({
|
||
kind: "protocol",
|
||
surface: "workspace_change",
|
||
revision: 2,
|
||
complete: true,
|
||
});
|
||
expect(items[0]).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).toBeUndefined();
|
||
});
|
||
|
||
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`, ...(family === "provider_notice" ? { userActionable: true } : {}) },
|
||
}));
|
||
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 in the feed while preserving its group boundary", () => {
|
||
const rows = buildTurnTimelineRows(
|
||
[tool("before"), request("pending", "pending"), tool("after")],
|
||
true,
|
||
);
|
||
|
||
expect(rows).toHaveLength(3);
|
||
expect(
|
||
rows.map((row) =>
|
||
row.kind === "activity_phase" ? row.items[0]?.id : row.id,
|
||
),
|
||
).toEqual(["before", "pending:pending", "after"]);
|
||
expect(rows[0]).toMatchObject({ kind: "activity_phase", active: false });
|
||
expect(rows[2]).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 authored prose from a yielded turn but not its wait stub", () => {
|
||
const result = {
|
||
id: "result",
|
||
kind: "protocol",
|
||
surface: "run_result",
|
||
disposition: "yielded",
|
||
summary: "Waiting for user answers on the interview card.",
|
||
objectiveSatisfied: false,
|
||
verification: [],
|
||
remainingWork: [],
|
||
blocker: null,
|
||
artifacts: [],
|
||
} satisfies TaskChatItem;
|
||
const reply = {
|
||
id: "reply",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "I've posted a question card that asks four things.\n- what you do\n- what done looks like",
|
||
channel: "final",
|
||
streaming: false,
|
||
} satisfies TaskChatItem;
|
||
expect(paperclipRunnerFinalResponse([reply, result])).toBe(reply);
|
||
expect(paperclipRunnerFinalResponse([reply], { yielded: true })).toBe(reply);
|
||
// A provider may echo the wait stub after its authored reply.
|
||
expect(
|
||
paperclipRunnerFinalResponse([
|
||
reply,
|
||
{ ...reply, id: "stub", text: result.summary },
|
||
result,
|
||
]),
|
||
).toBe(reply);
|
||
expect(
|
||
paperclipRunnerFinalResponse(
|
||
[{ ...reply, text: "Waiting for Which tools should the storefront use?." }],
|
||
{ yielded: true },
|
||
),
|
||
).toBeUndefined();
|
||
expect(
|
||
paperclipRunnerFinalResponse([{ ...reply, text: result.summary }], {
|
||
yielded: true,
|
||
waitSummary: result.summary,
|
||
}),
|
||
).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("embeds a saved Plan from a body-free conversation write receipt", () => {
|
||
const runId = "10000000-0000-4000-8000-000000000001";
|
||
const boundary: HeartbeatRunEvent = {
|
||
id: 1, companyId: "company", runId, agentId: "agent", seq: 1,
|
||
eventType: "item.completed", stream: "system", level: "info", color: null, message: null,
|
||
createdAt: new Date(TS),
|
||
payload: { prpEvent: {
|
||
schema: "paperclip.prp.event.v1", sourceEventId: "write-boundary", sourceInstanceId: "runner-1",
|
||
sourceKind: "runner", sourceSeq: 1, runId, normalizedSessionId: "session-1", eventType: "item.completed",
|
||
schemaVersion: 1, priority: 1, emittedAt: TS,
|
||
payload: { kind: "tool_result", item: { id: "write-plan", tool_use_id: "write-plan", type: "tool_result",
|
||
name: "write_document", input: { key: "plan" }, result: { disposition: "applied", document: { key: "plan" } } } },
|
||
} },
|
||
};
|
||
const parsed = transcriptToTaskChatItems(nativeRunEventsToTranscript([boundary]), { runId, running: false });
|
||
const embedded = embedPlanDocumentAtWriteBoundary(parsed, {
|
||
id: "saved-plan", kind: "plan_document", document: {} as TaskChatPlanDocumentItem["document"],
|
||
});
|
||
expect(embedded.find(item => item.kind === "plan_document")).toMatchObject({ placement: "write_boundary" });
|
||
expect(embedded.at(-1)?.id).toBe("saved-plan");
|
||
expect(parsed.some(item => item.kind === "tool" && item.rawName === "write_document" && item.status === "completed")).toBe(true);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
describe("harness status notices", () => {
|
||
const notice = (noticeId: string, payload: Record<string, unknown>): TranscriptEntry => ({
|
||
kind: "provider_activity", ts: TS, family: "provider_notice", eventType: "provider.notice.recorded",
|
||
status: payload.severity === "error" ? "failed" : "informational", title: "Provider notice",
|
||
summary: String(payload.summary ?? ""),
|
||
payload: { schema: "paperclip.provider.notice.v1", noticeId, scope: "turn", userActionable: false, ...payload },
|
||
});
|
||
const read: TranscriptEntry = { kind: "tool_call", ts: TS, toolUseId: "read-1", name: "read_file", input: { path: "a.ts" } };
|
||
const retry = notice("error:retry:turn-1", {
|
||
severity: "warning", category: "provider_retry", recoverable: true, summary: "Reconnecting... 2/5",
|
||
details: [{ name: "Reason", value: "responseStreamDisconnected" }],
|
||
});
|
||
|
||
it("shows status notices as top-level timeline rows while generic warnings stay hidden", () => {
|
||
const parsed = transcriptToTaskChatItems([
|
||
read,
|
||
notice("compaction-1", { severity: "info", category: "context_compaction", recoverable: true, summary: "Compacting context", details: [{ name: "Phase", value: "running" }] }),
|
||
notice("warning-1", { severity: "warning", category: "warning", recoverable: true, summary: "noise" }),
|
||
], { runId: "status", running: true });
|
||
const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(parsed), true);
|
||
expect(rows.map((row) => row.kind === "protocol" && row.surface === "provider_activity" ? row.id : row.kind)).toHaveLength(2);
|
||
const chip = rows[1];
|
||
expect(chip.kind === "protocol" && chip.surface === "provider_activity" && chip.details.find((d) => d.label === "Phase")?.value).toBe("running");
|
||
expect(JSON.stringify(rows)).not.toContain("noise");
|
||
});
|
||
|
||
it("marks a retry resolved once real work follows, and drops it behind a fatal error", () => {
|
||
const live = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, retry], { runId: "r", running: true })), true);
|
||
expect(live.at(-1)).toMatchObject({ kind: "protocol" });
|
||
expect(live.at(-1)).not.toHaveProperty("resolved");
|
||
|
||
const recovered = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, retry, { ...read, toolUseId: "read-2" }], { runId: "r", running: true })), true);
|
||
expect(recovered.find((row) => row.kind === "protocol")).toMatchObject({ resolved: true });
|
||
|
||
const failed = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([
|
||
read, retry,
|
||
notice("error:final:turn-1", { severity: "error", category: "error", recoverable: false, summary: "You've hit your usage limit." }),
|
||
], { runId: "r", running: false })), false);
|
||
const protocols = failed.filter((row) => row.kind === "protocol");
|
||
expect(protocols).toHaveLength(1);
|
||
expect(protocols[0]).toMatchObject({ summary: "You've hit your usage limit." });
|
||
});
|
||
|
||
it("keeps a later retry active after work between retries, and collapses back-to-back retries", () => {
|
||
const retry2 = notice("error:retry:turn-1", {
|
||
severity: "warning", category: "provider_retry", recoverable: true, summary: "Reconnecting... 1/5",
|
||
});
|
||
const work: TranscriptEntry = { ...read, toolUseId: "read-2" };
|
||
const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems(
|
||
[read, retry, work, retry2], { runId: "r", running: true })), true)
|
||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||
expect(rows).toHaveLength(2);
|
||
expect(rows[0]).toMatchObject({ resolved: true });
|
||
expect(rows[1]).not.toHaveProperty("resolved");
|
||
const backToBack = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems(
|
||
[read, retry, retry2], { runId: "r", running: true })), true)
|
||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||
expect(backToBack).toHaveLength(1);
|
||
expect(backToBack[0]).toMatchObject({ summary: "Reconnecting... 1/5" });
|
||
});
|
||
|
||
it("marks a compaction that never completed as interrupted once the turn ends", () => {
|
||
const running = notice("compaction-1", { severity: "info", category: "context_compaction", recoverable: true, summary: "Compacting context", details: [{ name: "Phase", value: "running" }] });
|
||
const rows = (live: boolean) => buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, running], { runId: "r", running: live })), live)
|
||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||
expect(rows(true)[0]).toMatchObject({ status: "informational" });
|
||
expect(rows(false)[0]).toMatchObject({ status: "interrupted" });
|
||
});
|
||
|
||
it("marks a retry left unresolved by a stopped turn as interrupted", () => {
|
||
const rows = (live: boolean) => buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, retry], { runId: "r", running: live })), live)
|
||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||
expect(rows(true)[0]).toMatchObject({ status: "informational" });
|
||
expect(rows(false)[0]).toMatchObject({ status: "interrupted" });
|
||
expect(rows(false)[0]).not.toHaveProperty("resolved");
|
||
});
|
||
|
||
it("treats a retry followed only by the final answer as recovered", () => {
|
||
const answer: TranscriptEntry = { kind: "assistant", ts: TS, text: "Done: 8 lines.", channel: "final" } as TranscriptEntry;
|
||
const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, retry, answer], { runId: "r", running: false })), false)
|
||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0]).toMatchObject({ resolved: true });
|
||
expect(rows[0]).not.toMatchObject({ status: "interrupted" });
|
||
});
|
||
|
||
it("keeps a retry stopped when only run-end records follow it", () => {
|
||
const parsed = [
|
||
...transcriptToTaskChatItems([read, retry], { runId: "r", running: false }),
|
||
{ id: "terminal", kind: "protocol", surface: "run_terminal", turnState: "cancelled", runState: "cancelled", disposition: "blocked" } as TaskChatItem,
|
||
{ id: "stopped", kind: "marker", variant: "interrupted", label: "Run stopped" } as TaskChatItem,
|
||
];
|
||
const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(parsed), false)
|
||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||
expect(rows[0]).toMatchObject({ status: "interrupted" });
|
||
expect(rows[0]).not.toHaveProperty("resolved");
|
||
});
|
||
|
||
it("does not treat a turn-end changed-files card as recovery from a stopped retry", () => {
|
||
const parsed = [
|
||
...transcriptToTaskChatItems([read, retry], { runId: "r", running: false }),
|
||
{ id: "changes", kind: "protocol", surface: "workspace_change", changeSetId: "c", revision: 1, source: "runner_verified",
|
||
complete: true, files: [], totals: { files: 1, additions: 2, deletions: 0 }, patchArtifactRef: null } as TaskChatItem,
|
||
];
|
||
const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(parsed), false)
|
||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||
expect(rows[0]).toMatchObject({ status: "interrupted" });
|
||
expect(rows[0]).not.toHaveProperty("resolved");
|
||
});
|
||
|
||
it("lets a context.compacted divider replace the completed compaction chip", () => {
|
||
const compacted = notice("compaction-1", { severity: "info", category: "context_compaction", recoverable: true, summary: "Context compacted", details: [{ name: "Phase", value: "completed" }] });
|
||
const divider: TranscriptEntry = { kind: "provider_activity", ts: TS, family: "context", eventType: "context.compacted", status: "completed", title: "Context", summary: "", payload: { compactionId: "c1", reason: "provider" } };
|
||
const rows = (entries: TranscriptEntry[]) => buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems(entries, { runId: "r", running: true })), true)
|
||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity" && row.family === "provider_notice");
|
||
expect(rows([read, compacted])).toHaveLength(1);
|
||
expect(rows([read, compacted, divider])).toHaveLength(0);
|
||
const marker = { id: "m", kind: "marker", variant: "context_compacted", label: "Context compacted" } as unknown as TaskChatItem;
|
||
const parsed = paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, compacted], { runId: "r", running: true }));
|
||
const withMarker = buildTurnTimelineRows([...parsed, marker], true)
|
||
.filter((row) => row.kind === "protocol" && row.surface === "provider_activity");
|
||
expect(withMarker).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe("turn changes assembled from edit rows", () => {
|
||
it("lists each edited file when the harness reports only per-edit patches", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{ kind: "tool_call", ts: TS, name: "Write", toolUseId: "w1", input: { path: "price.js" } },
|
||
{ kind: "diff", ts: TS, changeType: "file_header", text: "price.js", toolUseId: "w1" },
|
||
{ kind: "diff", ts: TS, changeType: "hunk", text: "@@ -0,0 +1,2 @@", toolUseId: "w1" },
|
||
{ kind: "diff", ts: TS, changeType: "add", text: "a", toolUseId: "w1" },
|
||
{ kind: "diff", ts: TS, changeType: "add", text: "b", toolUseId: "w1" },
|
||
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "e1", input: { path: "price.js" } },
|
||
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "e2", input: { path: "other.js" } },
|
||
{ kind: "diff", ts: TS, changeType: "file_header", text: "other.js", toolUseId: "e2" },
|
||
{ kind: "diff", ts: TS, changeType: "hunk", text: "@@ -4,2 +4,2 @@", toolUseId: "e2" },
|
||
{ kind: "diff", ts: TS, changeType: "context", text: "keep", toolUseId: "e2" },
|
||
{ kind: "diff", ts: TS, changeType: "remove", text: "old", toolUseId: "e2" },
|
||
{ kind: "diff", ts: TS, changeType: "add", text: "new", toolUseId: "e2" },
|
||
// An addressed line reaches its own row even after another call started.
|
||
{ kind: "diff", ts: TS, changeType: "file_header", text: "price.js", toolUseId: "e1" },
|
||
{ kind: "diff", ts: TS, changeType: "hunk", text: "@@ -1,2 +1,2 @@", toolUseId: "e1" },
|
||
{ kind: "diff", ts: TS, changeType: "remove", text: "b", toolUseId: "e1" },
|
||
{ kind: "diff", ts: TS, changeType: "add", text: "c", toolUseId: "e1" },
|
||
{ kind: "diff", ts: TS, changeType: "truncation", text: "Diff shortened for display.", toolUseId: "e1" },
|
||
{ kind: "tool_result", ts: TS, toolUseId: "w1", content: "", isError: false },
|
||
{ kind: "tool_result", ts: TS, toolUseId: "e1", content: "", isError: false },
|
||
{ kind: "tool_result", ts: TS, toolUseId: "e2", content: "", isError: false },
|
||
],
|
||
{ runId: "run-edits", running: false },
|
||
);
|
||
const edit = items.find((item) => item.kind === "tool" && item.id.endsWith("e1"));
|
||
expect(edit).toMatchObject({ diff: { path: "price.js", added: 1, removed: 1, truncated: true } });
|
||
const changes = items.at(-1);
|
||
expect(changes).toMatchObject({
|
||
kind: "protocol",
|
||
surface: "workspace_change",
|
||
complete: true,
|
||
totals: { files: 2, additions: 3, deletions: 1 },
|
||
});
|
||
if (changes?.kind !== "protocol" || changes.surface !== "workspace_change") throw new Error("expected changes");
|
||
// A file created this turn reports its net size.
|
||
expect(changes.files[0]).toMatchObject({ path: "price.js", operation: "create", additions: 2, deletions: 0 });
|
||
expect(changes.files[1]).toMatchObject({ path: "other.js", operation: "modify", additions: 1, deletions: 1 });
|
||
expect(changes.files[1].diff).toBe("@@ -4,2 +4,2 @@\n keep\n-old\n+new\n");
|
||
});
|
||
});
|
||
|
||
describe("edit diff review fixes", () => {
|
||
it("leaves failed edits out of the turn card and keeps producer counts for shortened patches", () => {
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "ok", input: { path: "big.js" } },
|
||
{ kind: "diff", ts: TS, changeType: "file_header", text: "big.js", toolUseId: "ok", additions: 900, deletions: 40 },
|
||
{ kind: "diff", ts: TS, changeType: "hunk", text: "@@ -1,1 +1,1 @@", toolUseId: "ok" },
|
||
{ kind: "diff", ts: TS, changeType: "remove", text: "a", toolUseId: "ok" },
|
||
{ kind: "diff", ts: TS, changeType: "add", text: "b", toolUseId: "ok" },
|
||
{ kind: "diff", ts: TS, changeType: "truncation", text: "Diff shortened for display.", toolUseId: "ok" },
|
||
{ kind: "tool_result", ts: TS, toolUseId: "ok", content: "", isError: false },
|
||
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "bad", input: { path: "never.js" } },
|
||
{ kind: "diff", ts: TS, changeType: "file_header", text: "never.js", toolUseId: "bad", additions: 3, deletions: 0 },
|
||
{ kind: "diff", ts: TS, changeType: "add", text: "x", toolUseId: "bad" },
|
||
{ kind: "tool_result", ts: TS, toolUseId: "bad", content: "rejected", isError: true },
|
||
],
|
||
{ runId: "run-review", running: false },
|
||
);
|
||
expect(items.find((item) => item.kind === "tool" && item.id.endsWith(":ok"))).toMatchObject({
|
||
diff: { added: 900, removed: 40, truncated: true },
|
||
});
|
||
const card = items.at(-1);
|
||
if (card?.kind !== "protocol" || card.surface !== "workspace_change") throw new Error("expected changes");
|
||
expect(card.files.map((file) => file.path)).toEqual(["big.js"]);
|
||
expect(card.files[0]).toMatchObject({ additions: 900, deletions: 40, diffTruncated: true });
|
||
});
|
||
});
|