Files
PaperClipAI/ui/src/components/task-chat/transcript-adapter.test.ts
T
DottaandPaperclip 57e977be72 feat: integrate Pi 1.0 into the experimental Runner (#14921)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - The experimental Runner owns provider processes and durable
sessions.
> - Pi needs working task execution and human controls.
> - The five-PR stack must preserve changes already on master.
> - Each layer now carries the complete integrated source for a safe
sequential fallback.
> - This PR belongs to native GitHub stack #15602, ending at #14956.

## Linked Issues or Issue Description

Refs #14436, #14631, #14743 and #14956.

Ship Pi 1.0 through the experimental Paperclip Runner. The five PRs are
#14921, #14922, #14923, #14924 and #14956. The user authorized the
complete merge after checks pass. Existing `pi_local` execution is
unchanged. Accounting and wider provider/platform qualification remain
deferred.

## What Changed

- Recover missing final replies after workspace finalization changes
owners, using accepted-turn evidence without rerunning work or granting
external-chat publication.
- Preserve the admitted Pi instruction root across warm runs, while
retaining changed-root rejection.
- Give Pi a bounded 15-second default shutdown grace so stop, drain
acknowledgement and durable suspension can complete. Explicit deadlines
and other providers retain their existing behavior.
- Integrate the Pi 1.0 runtime and master contracts.
- Use Pi profile 22. Preserve explicit caller-selected models and exact
native thinking levels. Keep Pi's wrapper, helper, extension and
question/control behavior unchanged from the qualified profile-19
runtime.
- Preserve master's Dot lifecycle and consent fields, configured task
environment, status guards and current Codex/Claude dependency versions.
Cursor stays qualified. Copilot stays pending; profile 17 binds the
changed shared protocol validation sources.
- Exclude general AWS IAM credentials from Pi static/custom provider
bindings and selected task projections; preserve the provider-scoped
Bedrock bearer key. Profile 21 is retained as historical provenance.
Rust and cloud install probes use the current declaration.
- Patch bundled brace-expansion 5.0.9 to the exact official 5.0.12
payload. Pin the patch and complete runtime closures. Include the patch
in normal installed setup tooling. Keep the upstream Pi shrinkwrap as
provenance and permit only this exact security correction.
- Include current attestation files in the Docker build context. Keep
the repository lockfile unchanged from master. CI and private image
builds resolve manifest changes before their frozen installation.

## Verification

- Full local `pnpm -r typecheck` passes, including Runner Rust, server
and UI. Focused integration checks pass: 194 Runner
admission/environment tests, 63 profile/credential tests with one
expected skip, 152 Dot/UI configuration tests, and Pi transcript/notice
tests.
- Full local `pnpm build` passes on the final source.
- Fresh final-source checks pass: all 698 Rust workspace tests (32
binaries), 156 credential/profile/controller tests with one expected
skip, Runner TypeScript typecheck, and 20 package/setup/sandbox tests.
- The profile-21 Pi materializer passes on the native host with the
official pinned Node 24.21.0 and its npm. It verifies all 150 locked
packages, the patched dependency and the exact closure. Setup/package
bundle tests and UI token gates pass.
- The old hashes were reproduced for all three supported targets before
calculating the patched graph. New closure hashes are darwin-arm64
`282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf`,
darwin-x64
`64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e`, and
linux-x64
`713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410`.
Independent native platform execution is separate from graph identity
reproduction.
- Historical cloud qualification remains unchanged: all seven core cases
pass on shipping source `10dc43c9ec65d88c2f782d62afb296d09494f215`,
harness `1a4408a48cfb5a1f094a311141c257c92cd7a893`, image
`sha256:5b3a775b383591bda1b0c1889e509acc70ce7f37c53f09733c81d59037f02280`,
and accepted Sonnet 4.6/low fixture. All 215 canonical files and all
seven cleanup checks pass independent verification. These are profile-19
results and are not relabeled as fresh profile-22 runs.
- Current Pi digest:
`sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9`.
[The readiness
plan](https://github.com/paperclipai/paperclip/blob/codex/pi-production-readiness/doc/plans/2026-10-02-pi-production-readiness.md)
preserves campaign and failed-attempt provenance.
- Merge only after every PR's current-head CI and fresh review pass.
Linux CI covers the full suites, build and browser tests. The local
embedded Postgres API-authority suite cannot start on this macOS/Node 26
host, so Linux CI must confirm that suite.

### Fresh profile-22 core qualification — 2026-10-08

All seven accepted core cases pass canonically on Pi profile 22, with
`openrouter/anthropic/claude-sonnet-4.6` and native-confirmed low
thinking. This model is a fixture; production accepts the caller's
explicit Pi provider/model.

Runtime/install source: `3241a992f2a7703e59e97ed0fd3e5d6405de4401`.
Frozen accepted harness: `1a4408a48cfb5a1f094a311141c257c92cd7a893`.
Immutable cloud image:
`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:506f22db7edd78f37c0c40bec1cc084af1850455026dbf467194bfbb8fcef141`.
Pi digest:
`sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9`.

[Hosted Linux image and clean-install
verification](https://github.com/paperclipai/paperclip/actions/runs/37868328023)
passes, including all 20 source-bound archives, normal CLI/Pi setup,
companion import and the production pack reader. This exact installation
source includes the latest master integration and the corrected Pi warm
instruction-root fence. Full local typecheck/build and current-head
hosted CI verify the final stack. All 13 focused real-root regressions
pass. The full local executor suite passed 662 tests; 15 database tests
could not start the Mac embedded PostgreSQL service. Hosted Linux CI
passes the full required verification and E2E checks. These fresh
results keep their own source identity; profile-19 results remain
historical.

| Core path | Canonical campaign | Retained archive SHA-256 |
| --- | --- | --- |
| File edit, validation, download and Done |
`pi-core22-replyfix-0-1791511228` | 23 files;
`a473e8603a3dd4737863291f8d3d1e392391f0b16d433c3e0e0e9d8baf7a97b0` |
| Pending question and controller restart |
`pi-core22-replyfix-1-1791511376` | 33 files;
`6b829c4eb74e1f32a89c692a4ae7130dbfc1c6d3cf13915effe2103d9e242c8e` |
| Three-turn session/process/workspace continuity |
`pi-core22-replyfix-2-1791511587` | 23 files;
`7a87021f8f9a3fdd3c58bb4467f8d82c635e3ea4795d6e75f144d9aa14818df8` |
| Four typed questions and browser reconnects |
`pi-core22-replyfix-3-1791511881` | 42 files;
`9e31755252be1f4f9cb0626c984c142d4d1ae5f5bee3a7af08444db8d12c280a` |
| Plan approval and completion | `pi-core22-replyfix-4-1791512031` | 22
files;
`a0383ce1aab38e7b5a25ce0e9dd3bebea5c037ebd96ae6b29dae19015da2ae2c` |
| Same-turn steering and permission denial |
`pi-core22-replyfix-5-1791512261` | 39 files;
`c929b8c7070f0b66aedc17e65ca46e6beab1e363926ac9f7e2a75fb250f05949` |
| Stop during pending permission | `pi-core22-replyfix-6-1791512390` |
33 files;
`7f0a58ae0f4d5bfc76149435f4e322537089c5bd16e7ffe9b5ad71f10a621a07` |

All 215 canonical files (28714587 bytes) are independently
hash-verified. All seven cleanup grades pass, with no owned runtime
process or temporary root after each case. Automatic retries are zero.
The owned cloud host stopped normally after retention. The prior
profile-22 warm attempt remains failed and separately retained: archive
SHA-256
`1e54eba5ec72b50cee1534b23d1d1d4f21a090006b8a64501ba70db972abfde5`. Its
original canonical classification is preserved. Diagnosis reproduced a
product bug comparing an agent-files root against an unset
checkpoint-only field. The fix stores the admitted physical root
separately from the adopted per-run collection capability. The real-root
regression fails before the fix and passes afterward, including
rejection of a changed physical root. Fixture, grader, model and all
seven accepted case IDs are unchanged; this fresh campaign tests
final-reply publication after file registration first. The intermediate
restart attempt also remains failed and retained: archive SHA-256
`5dcaefdf1d17cf4cd54fd4cf810f45e736667392339b8ce7caf08bb4e225277f`. Its
original canonical classification is preserved. Pi resumed, wrote the
verified answer and completed its task; exact runner suspension was
proven, but idle stop consumed about 5.2s and left under 3s for the
drain acknowledgement. The Pi-only default shutdown grace is now 15s,
preserving a full 5s drain round trip and a finite suspension reserve.
Explicit caller deadlines, other provider defaults, literal drain
receipts and exact suspension identity checks remain unchanged. The
timing regression fails before this correction and passes afterward; all
18 focused settlement tests and Runner typecheck pass. The final-source
file attempt is also preserved as failed (`candidate_failure`), archive
SHA-256
`db6767b6773ea618997927ac77bdb005a5ac81492c7b9c0ffbc900449f829bc9`.
Native edit, validation, exact downloadable artifact and Done/succeeded
all passed, and the exact final reply was durably recorded. A workspace
recovery owner completed before the live heartbeat reached presentation,
leaving that reply absent from task chat. Recovery now materializes only
a completed final reply from the accepted turn of an ordinary internal
Done task, preserving issue/run/contract binding, suppression,
external-chat authorization and same-run deduplication. The database
regression covers the generated file-preparation receipt, suppression,
unapproved external continuation and replay. Server typecheck and all 49
response-selection tests pass; hosted Linux verifies the database
regression because embedded PostgreSQL cannot start on this Mac.

The delayed-final-answer database regression passes on [the final
root-source Linux server
shard](https://github.com/paperclipai/paperclip/actions/runs/37868262553/job/113628594152),
alongside 1,108 passing tests. The first root Runner shard had one
unchanged durable-resume test exceed its 5-second timeout; the identical
top-source shard and the isolated exact test passed. One rerun of that
failed job and its required aggregate passed without source or test
changes. The original failed job log and the single-rerun receipt remain
retained.

### October 9 merge verification

Current merge head: `5a8fe63512a7166aaef5cf50065a25008aa8b44b`. All
current-head checks pass, including `ci / verify` and `ci / e2e`;
exact-head Greptile review is 5/5 with no unresolved threads. Current
master conflicts are resolved. The user authorized the maintainer
override of the code-owner review gate after these checks. The seven
retained live core cases remain bound to source
`3241a992f2a7703e59e97ed0fd3e5d6405de4401` and its recorded cloud image.

## Risks

- The security correction changes the dependency closure and profile
identity. Old sessions must reopen on the new profile. Exact identities
and credential bindings fail closed.
- The runner remains experimental and requires explicit selection.
Legacy Pi Local is unchanged. Caller model IDs pass through; the E2E
model is a fixture.
- Accounting and the broad platform/provider matrix remain deferred.
This merge does not publish a release or deploy a service.

## Model Used

OpenAI GPT-6 through Codex assisted with reasoning, repository
inspection, editing and tool use. The exact serving ID and context
window are not exposed in this session. Final live qualification uses Pi
1.0.0 with `openrouter/anthropic/claude-sonnet-4.6` and native-confirmed
low thinking.

## 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
#` 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-09 08:56:41 -05:00

2873 lines
85 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import type { TranscriptEntry } from "@/adapters";
import type { HeartbeatRunEvent } from "@paperclipai/shared";
import {
assembleThreadItems,
attachSettledTurns,
buildActivityPhases,
buildTurnTimelineRows,
buildMergedTurnSummary,
buildTurnSummary,
coalesceSettledTurns,
deriveRunStatusLabel,
embedPlanDocumentAtWriteBoundary,
flattenSelfTalk,
isNestableLiveChild,
ISSUE_BRIEF_ITEM_ID,
omitProgressRepeatedByResponseAcrossSegments,
paperclipRunnerActivityItems,
paperclipRunnerFinalResponse,
paperclipRunnerHistoryItems,
paperclipRunnerTimelineItems,
prependIssueBrief,
settledRunChildren,
splitTranscriptAtAnchors,
toolDisplayName,
transcriptToTaskChatItems,
runtimeRequestSegmentContext,
reconcileSegmentRuntimeRequests,
type SettledTurnMergeMeta,
type ThreadBackboneEntry,
} from "./transcript-adapter";
import type {
TaskChatItem,
TaskChatPlanDocumentItem,
TaskChatTurnItem,
} from "./task-chat-model";
import { latestPendingRuntimeRequest } from "./task-chat-model";
import { providerActivityPresentation } from "./task-chat-activity-presentation";
import { nativeRunEventsToTranscript } from "../transcript/native-run-events";
const TS = "2026-07-31T12:00:00.000Z";
describe("runtime requests across steering sections", () => {
it("selects the newer request when an older pending permission crosses steering", () => {
const older: TranscriptEntry = { kind: "runtime_request", ts: TS, requestId: "older-permission",
requestKind: "permission_approval", turnId: "provider-turn", requestType: "permission", status: "pending",
prompt: "Earlier write", choices: [{ key: "decline", label: "Deny" }], fields: [] };
const newer: TranscriptEntry = { ...older, ts: "2026-07-31T12:00:02.000Z", requestId: "newer-permission", prompt: "Later write" };
const options = { runId: "run-steering", running: true };
const context = runtimeRequestSegmentContext([older, newer], options);
const tail = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems([newer], options), [newer], context, true);
expect(tail.map(item => item.id)).toEqual(["run-steering:runtime-request:older-permission", "run-steering:runtime-request:newer-permission"]);
expect(latestPendingRuntimeRequest(tail)?.requestId).toBe("newer-permission");
});
it.each(["pending", "resolved", "cancelled"] as const)("keeps one %s permission under whole-run authority", status => {
const request: TranscriptEntry = { kind: "runtime_request", ts: TS, requestId: "permission-1",
requestKind: "permission_approval", turnId: "provider-turn", requestType: "permission", status: "pending",
prompt: "Pi write", choices: [{ key: "decline", label: "Deny" }], fields: [] };
const after: TranscriptEntry[] = [{ kind: "assistant", ts: "2026-07-31T12:00:01.000Z", text: "Continued.", channel: "progress" }];
if (status === "resolved") after.push({ ...request, ts: "2026-07-31T12:00:02.000Z", status, resolvedAction: "decline" });
const entries = [request, ...after];
const options = { runId: "run-steering", running: status !== "cancelled" };
const context = runtimeRequestSegmentContext(entries, options);
const history = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems([request], { ...options, running: false }), [request], context);
const tail = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(after, options), after, context, options.running);
const cards = [...history, ...tail].filter(item => item.kind === "protocol" && item.surface === "runtime_request");
expect(cards).toEqual([expect.objectContaining({ requestId: "permission-1", turnId: "provider-turn", status })]);
expect(history.filter(item => item.kind === "protocol")).toHaveLength(status === "pending" ? 0 : 1);
expect(tail.filter(item => item.kind === "protocol")).toHaveLength(status === "pending" ? 1 : 0);
});
});
describe("accepted native response-wake answers", () => {
const runId = "native-response-wake";
const summary =
"**Before release**\n- Test and rehearse rollback.\n\n**During release**\n- Deploy incrementally and watch errors.\n\n**After release**\n- Verify workflows and record follow-ups.";
function fixture(mode = "accepted") {
const result = {
schema: "paperclip.run_result.v1",
reportedWorkDisposition: "yielded",
summary,
completionClaim: { objectiveSatisfied: true, remainingWork: [] },
attentionRequests:
mode === "attention" ? [{ kind: "ask_user_questions" }] : [],
continuation: {
kind: mode === "question" ? "interaction" : "response_wake",
idempotencyKey: mode === "blank_key" ? " " : "next-exact-input",
},
evidence: [],
verification: [],
artifacts: [],
};
const event = (
seq: number,
eventType: string,
payload: Record<string, unknown>,
) =>
({
id: seq,
companyId: "company-1",
agentId: "agent-1",
stream: "system",
level: "info",
color: null,
message: null,
runId,
seq,
eventType,
createdAt: new Date(TS),
payload: {
prpEvent: {
schema: "paperclip.prp.event.v1",
schemaVersion: 1,
runId,
eventType,
sourceEventId: `response-${seq}`,
sourceKind:
eventType === "run.result.accepted" ||
eventType === "run.terminal"
? "control_plane"
: "runner",
sourceInstanceId: "runner-1",
sourceSeq: seq,
normalizedSessionId: "session-1",
emittedAt: TS,
payload,
},
},
}) satisfies HeartbeatRunEvent;
const accepted = event(3, "run.result.accepted", { result });
if (mode === "mismatched_run")
accepted.payload.prpEvent.runId = "other-run";
if (mode === "forged_kind")
accepted.payload.prpEvent.eventType = "item.completed";
if (mode === "missing_source") accepted.payload.prpEvent.sourceEventId = "";
const events = [
event(1, "item.completed", {
kind: "agentMessage",
channel: "progress",
item: {
id: "preamble",
type: "agentMessage",
channel: "progress",
text: "I’m providing the requested checklist and leaving the task open.",
},
}),
event(2, "run.result.proposed", result),
...(mode === "proposed" ? [] : [accepted]),
...(mode === "nonterminal"
? []
: [
event(4, "run.terminal", {
schema: "paperclip.prp.terminal.v1",
turnTerminalState: "completed",
runTerminalState:
mode === "failed_terminal" ? "failed" : "succeeded",
reportedWorkDisposition: "yielded",
}),
]),
...(mode === "provider_final"
? [
event(5, "item.completed", {
kind: "agentMessage",
channel: "final",
item: {
id: "other-final",
type: "agentMessage",
channel: "final",
text: "Provider commentary is not the accepted checklist.",
},
}),
]
: []),
];
const entries = nativeRunEventsToTranscript(events);
return transcriptToTaskChatItems(entries, { runId, running: false });
}
it.each(["accepted", "provider_final"])(
"renders the exact accepted response-wake summary instead of commentary: %s",
(mode) => {
expect(
paperclipRunnerFinalResponse(fixture(mode), { runId }),
).toMatchObject({
text: summary,
channel: "final",
});
},
);
it.each([
"proposed",
"mismatched_run",
"forged_kind",
"missing_source",
"question",
"attention",
"blank_key",
"nonterminal",
"failed_terminal",
])("keeps %s evidence out of the response-wake final exception", (mode) => {
expect(
paperclipRunnerFinalResponse(fixture(mode), { runId }),
).toBeUndefined();
});
it("does not grant the exception to another run or a live fallback", () => {
expect(
paperclipRunnerFinalResponse(fixture(), { runId: "other-run" }),
).toBeUndefined();
expect(
paperclipRunnerFinalResponse(fixture(), { runId, allowFallback: false }),
).toBeUndefined();
});
});
describe("completion tool feed visibility", () => {
it("keeps the full bounded notice text available when expanded", () => {
const summary = `${"Configuration context. ".repeat(30)}Use the project settings to fix this.`;
const [notice] = transcriptToTaskChatItems([{
kind: "provider_activity", ts: TS, family: "provider_notice", eventType: "provider.notice.recorded",
status: "informational", title: "Provider notice", summary,
payload: { noticeId: "long-notice", summary },
}], { runId: "notice", running: false });
expect(notice.kind === "protocol" && notice.surface === "provider_activity" &&
notice.details.find((detail) => detail.label === "Summary")?.value).toBe(summary);
});
it("keeps completion events inspectable but hides both tool representations from live and settled feed activity", () => {
for (const running of [true, false]) {
const entries: TranscriptEntry[] = [
{ kind: "tool_call", ts: TS, toolUseId: "legacy-finish", name: "paperclip_finish", input: {} },
...["paperclip_finish", "search_tasks"].map((name) => ({
kind: "provider_activity" as const, ts: TS, family: "tool_execution" as const,
eventType: running ? "tool.execution.started" : "tool.execution.completed",
status: running ? "running" as const : "completed" as const,
title: "Tool execution", summary: name,
payload: { executionId: name, name, transport: "dynamic" },
})),
{ kind: "provider_activity", ts: TS, family: "provider_notice", eventType: "provider.notice.recorded",
status: "informational", title: "Provider notice", summary: "Repository is not trusted",
payload: { noticeId: "notice", summary: "Repository is not trusted" } },
];
const parsed = transcriptToTaskChatItems(entries, { runId: "finish-visibility", running });
expect(parsed).toHaveLength(4);
const activity = paperclipRunnerActivityItems(parsed);
expect(activity).toHaveLength(2);
expect(JSON.stringify(activity)).not.toContain("paperclip_finish");
expect(JSON.stringify(activity)).toContain("search_tasks");
expect(JSON.stringify(activity)).toContain("Repository is not trusted");
expect(paperclipRunnerTimelineItems(parsed)).toEqual(activity);
}
});
});
describe("omitProgressRepeatedByResponseAcrossSegments", () => {
const progress = (id: string, text: string): TaskChatItem => ({
id,
kind: "message",
author: "agent",
text,
channel: "progress",
interstitial: true,
});
it("removes only the final matching progress item across a steered run", () => {
const first = progress("first", "Repeated answer");
const second = progress("second", "Repeated answer");
const segments = [[first], [second]];
expect(
omitProgressRepeatedByResponseAcrossSegments(segments, "Repeated answer"),
).toEqual([[first], []]);
});
it("preserves segment identity when there is no durable-response match", () => {
const segments = [[progress("first", "Progress only")]];
expect(
omitProgressRepeatedByResponseAcrossSegments(segments, "Final answer"),
).toBe(segments);
});
});
function toolCall(name: string, input?: unknown): TranscriptEntry {
return {
kind: "tool_call",
ts: TS,
name,
toolUseId: `tool-${name}`,
input,
} as TranscriptEntry;
}
describe("splitTranscriptAtAnchors", () => {
const assistant = (ts: string, text: string): TranscriptEntry =>
({ kind: "assistant", ts, channel: "progress", text }) as TranscriptEntry;
it("assigns each boundary and its following entries to the next segment", () => {
const entries = [
assistant("2026-08-25T17:20:50.000Z", "before"),
assistant("2026-08-25T17:21:44.541Z", "first boundary"),
assistant("2026-08-25T17:22:10.000Z", "between"),
assistant("2026-08-25T17:23:00.000Z", "second boundary"),
];
const segments = splitTranscriptAtAnchors(
entries,
Date.parse("2026-08-25T17:20:45.369Z"),
[
Date.parse("2026-08-25T17:23:00.000Z"),
Date.parse("2026-08-25T17:21:44.541Z"),
Date.parse("2026-08-25T17:21:44.541Z"),
],
);
expect(segments.map((segment) => segment.entries.length)).toEqual([
1, 2, 1,
]);
expect(
segments.map((segment) =>
segment.entries.map((entry) =>
entry.kind === "assistant" ? entry.text : entry.kind,
),
),
).toEqual([["before"], ["first boundary", "between"], ["second boundary"]]);
});
});
describe("deriveRunStatusLabel", () => {
it("labels a tail tool_call with the taxonomy verb and tool · target detail", () => {
const status = deriveRunStatusLabel([
toolCall("Grep", { pattern: "ui/src/components" }),
]);
expect(status.label).toBe("Searching file contents");
expect(status.detail).toBe("Grep · ui/src/components");
expect(status.toolName).toBe("Grep");
});
it("uses family verbs per tool", () => {
expect(
deriveRunStatusLabel([toolCall("Bash", { command: "pnpm test" })]).label,
).toBe("Running a command");
expect(
deriveRunStatusLabel([toolCall("Edit", { file_path: "a.ts" })]).label,
).toBe("Editing a file");
expect(
deriveRunStatusLabel([toolCall("mcp__linear__search_issues")]).label,
).toBe("Searching issues");
});
it("omits the target from the detail when the input has none", () => {
const status = deriveRunStatusLabel([toolCall("Read")]);
expect(status.detail).toBe("Read");
});
it("keeps Thinking / Responding / Running fallbacks", () => {
expect(
deriveRunStatusLabel([
{ kind: "thinking", ts: TS, text: "hmm" } as TranscriptEntry,
]).label,
).toBe("Thinking");
expect(
deriveRunStatusLabel([
{ kind: "assistant", ts: TS, text: "done" } as TranscriptEntry,
]).label,
).toBe("Responding");
expect(deriveRunStatusLabel([]).label).toBe("Running");
// A settled tool (tool_result at the tail) means "between tools" → Running.
expect(
deriveRunStatusLabel([
toolCall("Bash", { command: "ls" }),
{
kind: "tool_result",
ts: TS,
toolUseId: "tool-Bash",
content: "ok",
} as TranscriptEntry,
]).label,
).toBe("Running");
});
});
describe("toolDisplayName", () => {
it("collapses mcp names the same way the taxonomy does", () => {
expect(toolDisplayName("mcp__linear-server__search_issues")).toBe(
"Search issues",
);
expect(toolDisplayName("bash")).toBe("Bash");
expect(toolDisplayName("")).toBe("Unnamed tool");
expect(toolDisplayName("tool")).toBe("Unnamed tool");
});
it("maps acpx placeholder names to the explicit unnamed-tool label", () => {
expect(toolDisplayName("tool call")).toBe("Unnamed tool");
expect(toolDisplayName("tool call (completed)")).toBe("Unnamed tool");
expect(toolDisplayName("acp_tool")).toBe("Unnamed tool");
});
});
describe("transcriptToTaskChatItems tool_call updates", () => {
const opts = { runId: "run-1", running: false };
function update(toolUseId: string, status: string): TranscriptEntry {
// Mirrors what a persisted acpx tool_call_update line parses to: the
// literal placeholder name plus a synthesized { text, status } input.
return {
kind: "tool_call",
ts: TS,
name: "tool call",
toolUseId,
input: { text: `tool call (${status})`, status },
} as TranscriptEntry;
}
it("keeps the initial real name and target when a generic update arrives", () => {
const items = transcriptToTaskChatItems(
[
toolCall("Terminal", { command: "pnpm test" }),
update("tool-Terminal", "completed"),
{
kind: "tool_result",
ts: TS,
toolUseId: "tool-Terminal",
toolName: "tool call",
content: "ok",
} as TranscriptEntry,
],
opts,
);
expect(items).toHaveLength(1);
const tool = items[0];
expect(tool.kind).toBe("tool");
if (tool.kind !== "tool") return;
expect(tool.name).toBe("Terminal");
expect(tool.rawName).toBe("Terminal");
expect(tool.target).toBe("pnpm test");
expect(tool.status).toBe("completed");
});
it("keeps identity on retitle and uses the invocation as the target", () => {
// Real stored sequence: "Terminal" (pending) → retitle to the command →
// generic "tool call" completion updates.
const items = transcriptToTaskChatItems(
[
{
kind: "tool_call",
ts: TS,
name: "Terminal",
toolUseId: "tc-2",
} as TranscriptEntry,
{
kind: "tool_call",
ts: TS,
name: "ls -la",
toolUseId: "tc-2",
} as TranscriptEntry,
update("tc-2", "completed"),
],
opts,
);
expect(items).toHaveLength(1);
if (items[0].kind !== "tool") return;
expect(items[0].name).toBe("Terminal");
expect(items[0].rawName).toBe("Terminal");
expect(items[0].target).toBe("ls -la");
});
it("never renders the synthesized { text, status } input as a target", () => {
const items = transcriptToTaskChatItems(
[update("tc-orphan", "pending")],
opts,
);
expect(items).toHaveLength(1);
if (items[0].kind !== "tool") return;
expect(items[0].target).toBeUndefined();
});
it("upgrades a generic-named call when a later entry carries the real name", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "tool_call",
ts: TS,
name: "tool call",
toolUseId: "tc-1",
} as TranscriptEntry,
{
kind: "tool_call",
ts: TS,
name: "Read",
toolUseId: "tc-1",
} as TranscriptEntry,
],
opts,
);
expect(items).toHaveLength(1);
if (items[0].kind !== "tool") return;
expect(items[0].name).toBe("Read");
});
it("marks an unfinished tool interrupted once the transcript is settled", () => {
const items = transcriptToTaskChatItems(
[toolCall("Terminal", { command: "sleep 120" })],
opts,
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
kind: "tool",
name: "Terminal",
status: "interrupted",
detail: "Interrupted before the provider reported completion.",
});
});
});
describe("transcriptToTaskChatItems protocol surfaces", () => {
const opts = { runId: "run-protocol", running: false };
it("retains and updates provider activity instead of dropping it", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "provider_activity",
ts: TS,
family: "plan",
eventType: "plan.updated",
status: "running",
title: "Plan",
summary: "Drafting",
payload: {
planId: "plan-1",
revision: 1,
steps: [
{ stepId: "one", body: "Inventory", status: "in_progress" },
],
},
},
{
kind: "provider_activity",
ts: TS,
family: "plan",
eventType: "plan.updated",
status: "completed",
title: "Plan",
summary: "Ready",
payload: {
planId: "plan-1",
revision: 2,
steps: [{ stepId: "one", body: "Inventory", status: "completed" }],
},
},
],
opts,
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
kind: "protocol",
surface: "provider_activity",
family: "plan",
status: "completed",
summary: "Ready",
});
});
it("preserves named ACPX tool identity and progress when terminal updates omit them", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "provider_activity",
ts: TS,
family: "tool_execution",
eventType: "tool.execution.started",
status: "running",
title: "Tool execution",
summary: "Searching the task index",
payload: {
executionId: "exec-lifecycle",
transport: "mcp",
namespace: "paperclip",
name: "search_tasks",
operation: "search",
progress: "Searching the task index",
target: "doc/plan.md",
},
},
{
kind: "provider_activity",
ts: TS,
family: "tool_execution",
eventType: "tool.execution.completed",
status: "completed",
title: "Tool execution",
summary: "tool call",
payload: {
executionId: "exec-lifecycle",
transport: "builtin",
name: "tool call",
status: "completed",
output: "done",
},
},
] as TranscriptEntry[],
opts,
);
expect(items).toHaveLength(1);
const item = items[0];
expect(item).toMatchObject({
kind: "protocol",
surface: "provider_activity",
family: "tool_execution",
status: "completed",
summary: "Searching the task index",
output: "done",
});
if (item.kind !== "protocol" || item.surface !== "provider_activity")
return;
expect(item.details).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "Name", value: "search_tasks" }),
expect.objectContaining({ label: "Namespace", value: "paperclip" }),
expect.objectContaining({ label: "Operation", value: "search" }),
expect.objectContaining({ label: "Target", value: "doc/plan.md" }),
expect.objectContaining({
label: "Progress",
value: "Searching the task index",
}),
]),
);
expect(providerActivityPresentation(item)).toMatchObject({
runningLabel: "Searching tasks",
completedLabel: "Searched tasks",
detail: "Searching the task index · Paperclip · search_tasks",
});
});
it("marks a running provider activity interrupted in a settled transcript", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "provider_activity",
ts: TS,
family: "tool_execution",
eventType: "tool.execution.started",
status: "running",
title: "Tool execution",
summary: "sleep 120",
payload: {},
},
],
opts,
);
expect(items[0]).toMatchObject({
kind: "protocol",
surface: "provider_activity",
status: "interrupted",
summary: "sleep 120 · Interrupted before completion.",
});
});
it("coalesces workspace revisions and runtime request lifecycle states", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "workspace_change",
ts: TS,
changeSetId: "change-1",
revision: 1,
source: "harness_reported",
complete: false,
files: [],
totals: { files: 0, additions: null, deletions: null },
patchArtifactRef: null,
},
{
kind: "workspace_change",
ts: TS,
changeSetId: "change-1",
revision: 2,
source: "runner_verified",
complete: true,
files: [
{
path: "ui/src/App.tsx",
operation: "modify",
previousPath: null,
additions: 1,
deletions: 0,
binary: false,
diff: "+ok",
},
],
totals: { files: 1, additions: 1, deletions: 0 },
patchArtifactRef: null,
},
{
kind: "runtime_request",
ts: TS,
requestId: "request-1",
requestKind: "command_approval",
turnId: "turn-1",
requestType: "permission",
status: "pending",
prompt: "Allow?",
choices: [],
fields: [],
},
{
kind: "runtime_request",
ts: TS,
requestId: "request-1",
requestKind: "command_approval",
turnId: "turn-1",
requestType: "permission",
status: "resolved",
prompt: "Allow?",
choices: [],
fields: [],
},
],
opts,
);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
kind: "protocol",
surface: "workspace_change",
revision: 2,
complete: true,
});
expect(items[1]).toMatchObject({
kind: "protocol",
surface: "runtime_request",
runId: "run-protocol",
turnId: "turn-1",
requestKind: "command_approval",
status: "resolved",
});
});
it("bounds provider output and workspace previews to 8 KiB", () => {
const oversized = "x".repeat(9 * 1024);
const items = transcriptToTaskChatItems(
[
{
kind: "provider_activity",
ts: TS,
family: "tool_execution",
eventType: "tool.execution.completed",
status: "completed",
title: "Tool execution",
summary: "Done",
payload: { executionId: "exec-1", output: oversized },
},
{
kind: "workspace_file_reference",
ts: TS,
referenceId: "file-1",
source: "runner_verified",
path: "ui/src/App.tsx",
displayName: "App.tsx",
mediaType: "text/typescript",
presentation: "code",
line: null,
preview: oversized,
previewTruncated: false,
contentDigest: null,
},
],
opts,
);
const provider = items.find(
(item) =>
item.kind === "protocol" && item.surface === "provider_activity",
);
const file = items.find(
(item) => item.kind === "protocol" && item.surface === "workspace_file",
);
expect(
provider && provider.surface === "provider_activity"
? provider.output?.length
: 0,
).toBe(8 * 1024);
expect(
provider && provider.surface === "provider_activity"
? provider.outputTruncated
: false,
).toBe(true);
expect(
file && file.surface === "workspace_file" ? file.preview?.length : 0,
).toBe(8 * 1024);
expect(
file && file.surface === "workspace_file" ? file.previewTruncated : false,
).toBe(true);
});
it("turns canonical research sources into safe links and exposes notice metadata", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "provider_activity",
ts: TS,
family: "research",
eventType: "research.completed",
status: "completed",
title: "Research",
summary: "PRP",
payload: {
researchId: "research-1",
sources: [
{
title: "Protocol notes",
url: "https://example.com/prp",
snippet: "Canonical event details",
},
{ title: "Unsafe", url: "file:///etc/passwd" },
],
},
},
{
kind: "provider_activity",
ts: TS,
family: "provider_notice",
eventType: "provider.notice.recorded",
status: "informational",
title: "Provider notice",
summary: "Update configuration",
payload: {
noticeId: "notice-1",
severity: "warning",
category: "config",
scope: "environment",
recoverable: true,
userActionable: false,
},
},
],
opts,
);
const research = items.find(
(item) =>
item.kind === "protocol" &&
item.surface === "provider_activity" &&
item.family === "research",
);
const notice = items.find(
(item) =>
item.kind === "protocol" &&
item.surface === "provider_activity" &&
item.family === "provider_notice",
);
expect(
research?.kind === "protocol" && research.surface === "provider_activity"
? research.links
: [],
).toEqual([
{
label: "Protocol notes",
href: "https://example.com/prp",
description: "Canonical event details",
},
]);
expect(
notice?.kind === "protocol" && notice.surface === "provider_activity"
? notice.details
: [],
).toEqual(
expect.arrayContaining([
{ label: "Severity", value: "warning", mono: false },
{ label: "Category", value: "config", mono: false },
{ label: "Scope", value: "environment", mono: false },
{ label: "Recoverable", value: "Yes", mono: false },
{ label: "User Actionable", value: "No", mono: false },
]),
);
});
it("retains every canonical provider family for live and settled rendering", () => {
const families = [
"plan",
"tool_execution",
"research",
"delegation",
"model_identity",
"context",
"artifact",
"review",
"hook",
"memory",
"safety",
"terminal",
"wait",
"provider_notice",
] as const;
const entries = families.map((family, index): TranscriptEntry => ({
kind: "provider_activity",
ts: new Date(Date.parse(TS) + index).toISOString(),
family,
eventType: `${family}.fixture`,
status: index % 2 === 0 ? "running" : "completed",
title: family,
summary: `Visible ${family}`,
payload: { [`${family}Id`]: `${family}-1` },
}));
const items = transcriptToTaskChatItems(entries, {
...opts,
running: true,
});
expect(
items.map((item) =>
item.kind === "protocol" && item.surface === "provider_activity"
? item.family
: null,
),
).toEqual(families);
});
});
describe("buildActivityPhases provider summaries", () => {
it("includes canonical provider work in the folded activity summary", () => {
const provider = (
id: string,
family: "research" | "hook",
): TaskChatItem => ({
id,
kind: "protocol",
surface: "provider_activity",
family,
eventType: `${family}.completed`,
status: "completed",
title: family,
details: [],
steps: [],
links: [],
children: [],
});
const phases = buildActivityPhases(
[
{
id: "finish",
kind: "tool",
name: "Paperclip_finish",
status: "completed",
},
provider("research-1", "research"),
provider("research-2", "research"),
provider("research-3", "research"),
provider("hook-1", "hook"),
{
id: "workspace",
kind: "protocol",
surface: "workspace_change",
changeSetId: "change-1",
revision: 1,
source: "runner_verified",
complete: true,
files: [],
totals: { files: 2, additions: 1, deletions: 1 },
patchArtifactRef: null,
},
],
false,
);
expect(phases[0]?.summary).toBe(
"Used a tool, searched 3 times, ran a hook",
);
});
it("counts canonical tool execution when no native tool row exists", () => {
const phases = buildActivityPhases(
[
{
id: "provider-tool",
kind: "protocol",
surface: "provider_activity",
family: "tool_execution",
eventType: "tool.execution.completed",
status: "completed",
title: "Tool execution",
details: [],
steps: [],
links: [],
children: [],
},
],
false,
);
expect(phases[0]?.summary).toBe("Used a tool");
});
it("summarizes the sanitized DOT-212 mix with at most three semantic groups", () => {
const providerTool = (
id: string,
name: string,
operation: string,
namespace?: string,
): TaskChatItem => ({
id,
kind: "protocol",
surface: "provider_activity",
family: "tool_execution",
eventType: "tool.execution.completed",
status: "completed",
title: "Tool execution",
details: [
{ label: "Transport", value: namespace ? "mcp" : "builtin" },
{ label: "Operation", value: operation },
{ label: "Name", value: name, mono: true },
...(namespace
? [{ label: "Namespace", value: namespace, mono: true }]
: []),
],
steps: [],
links: [],
children: [],
});
const items: TaskChatItem[] = [
...Array.from({ length: 6 }, (_, index) =>
providerTool(`search-${index}`, "ToolSearch", "search"),
),
providerTool("context", "get_task_context", "read", "paperclip"),
providerTool("documents", "list_documents", "list", "paperclip"),
providerTool("history", "get_task_history", "read", "paperclip"),
providerTool("tasks", "search_tasks", "search", "paperclip"),
providerTool("progress", "report_progress", "edit", "paperclip"),
providerTool("block", "paperclip_block", "edit", "paperclip"),
];
expect(buildActivityPhases(items, false)[0]?.summary).toBe(
"Searched available tools 6 times, read from Paperclip 3 times, used Paperclip 3 times",
);
});
});
describe("buildTurnTimelineRows (DOT-217)", () => {
const commentary = (id: string, text: string): TaskChatItem => ({
id,
kind: "message",
author: "agent",
text,
interstitial: true,
channel: "progress",
});
const tool = (id: string): TaskChatItem => ({
id,
kind: "tool",
name: "Read",
rawName: "read_file",
target: `${id}.ts`,
status: "completed",
});
const request = (
id: string,
status: "pending" | "resolved" | "cancelled" | "expired",
): TaskChatItem => ({
id: `${id}:${status}`,
kind: "protocol",
surface: "runtime_request",
runId: "run-dot-217",
requestId: id,
requestKind: "user_input",
turnId: "turn-dot-217",
requestType: "input",
status,
prompt: `Question ${id}`,
choices: [],
fields: [],
});
const workspace: TaskChatItem = {
id: "workspace-change",
kind: "protocol",
surface: "workspace_change",
changeSetId: "turn-dot-217:workspace",
revision: 2,
source: "runner_verified",
complete: true,
files: [
{
path: "src/App.tsx",
operation: "modify",
previousPath: null,
additions: 2,
deletions: 1,
binary: false,
diff: "+new\n-old\n",
},
],
totals: { files: 1, additions: 2, deletions: 1 },
patchArtifactRef: null,
};
it("keeps commentary, grouped tools, and latest question receipts in first-seen order", () => {
const input = [
commentary("commentary-1", "Inspecting the workspace."),
tool("tool-1"),
tool("tool-2"),
commentary("commentary-2", "I need two product choices."),
request("questions-1", "pending"),
commentary("commentary-3", "I’ll continue with the selected scope."),
tool("tool-3"),
request("questions-2", "pending"),
commentary("commentary-4", "The final constraint is clear."),
tool("tool-4"),
request("questions-1", "resolved"),
request("questions-2", "cancelled"),
];
const rows = buildTurnTimelineRows(input, false);
expect(
rows.map((row) => {
if (row.kind === "activity_phase") {
return `${row.interstitial?.id ?? "opening"}:${row.items.map((item) => item.id).join(",")}`;
}
if (row.kind === "protocol" && row.surface === "runtime_request") {
return `${row.requestId}:${row.status}`;
}
return row.kind;
}),
).toEqual([
"commentary-1:tool-1,tool-2",
"commentary-2:",
"questions-1:resolved",
"commentary-3:tool-3",
"questions-2:cancelled",
"commentary-4:tool-4",
]);
});
it("keeps a pending request composer-only while preserving its group boundary", () => {
const rows = buildTurnTimelineRows(
[tool("before"), request("pending", "pending"), tool("after")],
true,
);
expect(rows).toHaveLength(2);
expect(rows.every((row) => row.kind === "activity_phase")).toBe(true);
expect(
rows.map((row) =>
row.kind === "activity_phase" ? row.items[0]?.id : null,
),
).toEqual(["before", "after"]);
expect(rows[0]).toMatchObject({ kind: "activity_phase", active: false });
expect(rows[1]).toMatchObject({ kind: "activity_phase", active: true });
});
it("retains progress-only phases and uses the same row order for settled assembly", () => {
const input = [
tool("opening"),
commentary("commentary-only", "Still checking the edge case."),
request("expired", "expired"),
];
expect(settledRunChildren(input)).toEqual(
buildTurnTimelineRows(input, false),
);
expect(buildTurnTimelineRows(input, false).map((row) => row.kind)).toEqual([
"activity_phase",
"activity_phase",
"protocol",
]);
});
it("keeps runtime requests in the Paperclip Runner timeline input", () => {
const pending = request("pending", "pending");
expect(
paperclipRunnerTimelineItems([
commentary("commentary", "Checking."),
pending,
{ id: "usage", kind: "usage", usage: { used: 1, size: 10 } },
]).map((item) => item.id),
).toEqual(["commentary", pending.id, "usage"]);
});
it("keeps aggregate workspace changes at the turn boundary outside activity", () => {
const rows = buildTurnTimelineRows(
paperclipRunnerTimelineItems([tool("before"), workspace, tool("after")]),
false,
);
expect(rows.map((row) => row.kind)).toEqual([
"activity_phase",
"protocol",
"activity_phase",
]);
expect(rows[1]).toMatchObject({
surface: "workspace_change",
changeSetId: "turn-dot-217:workspace",
});
});
it("suppresses file-change fragments only when an aggregate event exists", () => {
const fileChange: TaskChatItem = {
id: "provider-file-change",
kind: "tool",
name: "File change",
rawName: "fileChange",
status: "completed",
diff: { path: "src/App.tsx", added: 2, removed: 1 },
};
expect(paperclipRunnerActivityItems([fileChange])).toEqual([fileChange]);
expect(paperclipRunnerActivityItems([fileChange, workspace])).toEqual([
workspace,
]);
});
it("keeps a yielded run-result and provider wait prose out of the durable final slot", () => {
expect(
paperclipRunnerFinalResponse([
{
id: "provider-wait",
kind: "message",
author: "agent",
text: "Waiting for Review browser RTS plan.",
channel: "final",
streaming: false,
},
{
id: "result",
kind: "protocol",
surface: "run_result",
disposition: "yielded",
summary: "Waiting for Review browser RTS plan.",
objectiveSatisfied: false,
verification: [],
remainingWork: [],
blocker: null,
artifacts: [],
},
]),
).toBeUndefined();
});
it("keeps a saved plan at its write_document boundary", () => {
const plan: TaskChatPlanDocumentItem = {
id: "plan-doc:revision-1",
kind: "plan_document",
document: {} as TaskChatPlanDocumentItem["document"],
};
const embedded = embedPlanDocumentAtWriteBoundary(
[
commentary("commentary-1", "Inspecting."),
tool("read-before-plan"),
commentary("commentary-2", "Publishing the plan."),
{
id: "write-plan",
kind: "tool",
name: "Write document",
rawName: "write_document",
status: "completed",
},
{
id: "write-plan-provider",
kind: "protocol",
surface: "provider_activity",
family: "tool_execution",
eventType: "tool.execution.completed",
status: "completed",
title: "write_document",
details: [{ label: "Name", value: "write_document" }],
steps: [],
links: [],
children: [],
},
commentary("commentary-3", "The plan is published."),
],
plan,
);
const rows = buildTurnTimelineRows(
paperclipRunnerTimelineItems(embedded),
false,
);
expect(
rows.map((row) =>
row.kind === "activity_phase"
? `${row.interstitial?.id}:${row.items.map((item) => item.id).join(",")}`
: row.id,
),
).toEqual([
"commentary-1:read-before-plan",
"commentary-2:write-plan,write-plan-provider",
plan.id,
"commentary-3:",
]);
expect(
embedded.find((item) => item.kind === "plan_document"),
).toMatchObject({ placement: "write_boundary" });
});
it("retains a saved plan as an explicit fallback when its write boundary is unavailable", () => {
const plan: TaskChatPlanDocumentItem = {
id: "plan-doc:revision-fallback",
kind: "plan_document",
document: {} as TaskChatPlanDocumentItem["document"],
};
expect(
embedPlanDocumentAtWriteBoundary(
[commentary("commentary", "Transcript is still hydrating.")],
plan,
),
).toEqual([
commentary("commentary", "Transcript is still hydrating."),
{ ...plan, placement: "fallback" },
]);
});
});
describe("transcriptToTaskChatItems native usage", () => {
it("does not merge progress and final runner messages", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "assistant",
ts: TS,
text: "Checking files.",
channel: "progress",
},
{
kind: "assistant",
ts: TS,
text: "The change is ready.",
channel: "final",
},
],
{ runId: "native-run", running: true },
);
expect(items).toEqual([
expect.objectContaining({
kind: "message",
text: "Checking files.",
channel: "progress",
interstitial: true,
}),
expect.objectContaining({
kind: "message",
text: "The change is ready.",
channel: "final",
interstitial: false,
}),
]);
});
it("keeps separate completed commentary items as separate chronological paragraphs", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "assistant",
ts: TS,
text: "I am checking the existing behavior.",
channel: "progress",
itemId: "message-1",
},
{
kind: "assistant",
ts: "2026-07-31T12:00:01.000Z",
text: "The implementation is ready.",
channel: "progress",
itemId: "message-2",
},
],
{ runId: "native-run", running: false },
);
expect(items).toEqual([
expect.objectContaining({
kind: "message",
text: "I am checking the existing behavior.",
}),
expect.objectContaining({
kind: "message",
text: "The implementation is ready.",
}),
]);
});
it("coalesces only streaming chunks that share an item identity", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "assistant",
ts: TS,
text: "Still ",
channel: "progress",
delta: true,
itemId: "message-1",
},
{
kind: "assistant",
ts: TS,
text: "working",
channel: "progress",
delta: true,
itemId: "message-1",
},
{
kind: "assistant",
ts: TS,
text: "Next message",
channel: "progress",
delta: true,
itemId: "message-2",
},
],
{ runId: "native-run", running: true },
);
expect(items).toEqual([
expect.objectContaining({ kind: "message", text: "Still working" }),
expect.objectContaining({ kind: "message", text: "Next message" }),
]);
});
it("keeps distinct completed reasoning items separate even on the same channel", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "thinking",
ts: TS,
text: "Inspecting transport.",
lifecycle: "completed",
channel: "summary",
itemId: "reason-1",
},
{
kind: "thinking",
ts: "2026-07-31T12:00:01.000Z",
text: "Checking persistence.",
lifecycle: "completed",
channel: "summary",
itemId: "reason-2",
},
],
{ runId: "native-run", running: false },
);
expect(items).toEqual([
expect.objectContaining({
kind: "thinking",
lines: ["Inspecting transport."],
}),
expect.objectContaining({
kind: "thinking",
lines: ["Checking persistence."],
}),
]);
});
it("renders runner usage without inventing a context-window size", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "result",
ts: TS,
text: "",
inputTokens: 40,
outputTokens: 10,
cachedTokens: 5,
costUsd: 0.02,
subtype: "paperclip_runner_usage",
isError: false,
errors: [],
},
],
{ runId: "native-run", running: true },
);
expect(items).toEqual([
{
id: "native-run:usage:0",
kind: "usage",
usage: {
used: 55,
size: 0,
inputTokens: 40,
outputTokens: 10,
costUsd: 0.02,
},
},
]);
});
it("renders cumulative-only runner session usage", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "result",
ts: TS,
text: "",
inputTokens: 80,
outputTokens: 20,
cachedTokens: 0,
costUsd: 0,
subtype: "paperclip_runner_session_usage",
isError: false,
errors: [],
},
],
{ runId: "native-run", running: true },
);
expect(items).toEqual([
expect.objectContaining({
kind: "usage",
label: "Provider session total",
detail: expect.stringContaining("cumulative usage"),
usage: expect.objectContaining({
used: 100,
inputTokens: 80,
outputTokens: 20,
}),
}),
]);
});
it("does not change direct-adapter result presentation", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "result",
ts: TS,
text: "done",
inputTokens: 40,
outputTokens: 10,
cachedTokens: 0,
costUsd: 0,
subtype: "success",
isError: false,
errors: [],
},
],
{ runId: "legacy-run", running: true },
);
expect(items).toEqual([]);
});
});
describe("buildTurnSummary tool counting", () => {
function statusEntry(
toolUseId: string | undefined,
status: string,
): TranscriptEntry {
return {
kind: "tool_call",
ts: TS,
name: "Bash",
toolUseId,
input: { text: `tool call (${status})`, status },
} as TranscriptEntry;
}
it("counts unique tool calls, not per-status transcript entries", () => {
// 4 real calls × 4 status changes each = 16 entries; the summary must
// match the 4 rows the expanded list renders.
const entries = ["tc-1", "tc-2", "tc-3", "tc-4"].flatMap((id) =>
["pending", "in_progress", "in_progress", "completed"].map((status) =>
statusEntry(id, status),
),
);
expect(entries).toHaveLength(16);
expect(buildTurnSummary(entries).toolCount).toBe(4);
});
it("counts id-less legacy entries once each", () => {
const entries = [
statusEntry(undefined, "completed"),
statusEntry(undefined, "completed"),
toolCall("Read"),
];
expect(buildTurnSummary(entries).toolCount).toBe(3);
});
it("keeps session-cumulative runner usage out of run summaries", () => {
const runUsage = {
kind: "result",
ts: TS,
subtype: "paperclip_runner_usage",
inputTokens: 40,
outputTokens: 10,
} as TranscriptEntry;
const sessionUsage = {
kind: "result",
ts: TS,
subtype: "paperclip_runner_session_usage",
inputTokens: 800,
outputTokens: 200,
} as TranscriptEntry;
expect(buildTurnSummary([runUsage, sessionUsage]).tokensLabel).toBe(
"50 tokens",
);
expect(
buildMergedTurnSummary([
{ entries: [runUsage] },
{ entries: [sessionUsage] },
]).tokensLabel,
).toBe("50 tokens");
});
});
describe("deriveRunStatusLabel with generic tail updates", () => {
it("recovers the real tool name from the call's initial entry", () => {
const status = deriveRunStatusLabel([
toolCall("Terminal", { command: "ls -la" }),
{
kind: "tool_call",
ts: TS,
name: "tool call",
toolUseId: "tool-Terminal",
} as TranscriptEntry,
]);
expect(status.label).toBe("Running a command");
expect(status.toolName).toBe("Terminal");
});
it("treats a tail retitle as the invocation, not the identity", () => {
const status = deriveRunStatusLabel([
{
kind: "tool_call",
ts: TS,
name: "Terminal",
toolUseId: "tc-3",
} as TranscriptEntry,
{
kind: "tool_call",
ts: TS,
name: "pnpm test",
toolUseId: "tc-3",
} as TranscriptEntry,
]);
expect(status.label).toBe("Running a command");
expect(status.toolName).toBe("Terminal");
expect(status.detail).toBe("Terminal · pnpm test");
});
});
describe("isNestableLiveChild", () => {
const items: TaskChatItem[] = [
{ id: "t", kind: "tool", name: "Read", status: "completed" },
{ id: "th", kind: "thinking", lines: ["hm"] },
{ id: "u", kind: "usage", usage: { used: 1, size: 2 } },
{
id: "m",
kind: "message",
author: "agent",
text: "finished self-talk",
interstitial: true,
},
{
id: "mk",
kind: "marker",
variant: "session_start",
label: "Session started",
},
{ id: "s", kind: "status", status: "running", label: "Running" },
{ id: "i", kind: "interaction", interaction: {} as never },
];
it("nests tools, provider reasoning, and usage inside the live parent row", () => {
expect(items.filter(isNestableLiveChild).map((it) => it.kind)).toEqual([
"tool",
"thinking",
"usage",
]);
});
it("keeps messages, markers, statuses and interactions out", () => {
// Interstitial updates — finished or streaming — never nest: they are
// ephemeral, living on the live line only while streaming, and the final
// reply keeps its bubble.
for (const kind of ["message", "marker", "status", "interaction"]) {
const item = items.find((it) => it.kind === kind)!;
expect(isNestableLiveChild(item)).toBe(false);
}
expect(
isNestableLiveChild({
id: "m2",
kind: "message",
author: "agent",
text: "typing…",
interstitial: true,
streaming: true,
}),
).toBe(false);
expect(
isNestableLiveChild({
id: "m3",
kind: "message",
author: "agent",
text: "Final reply.",
}),
).toBe(false);
});
});
describe("interstitial self-talk classification (PAP-355)", () => {
const stream: TranscriptEntry[] = [
{
kind: "assistant",
ts: TS,
text: "Let me check the adapter.",
} as TranscriptEntry,
toolCall("Read", { file_path: "a.ts" }),
{
kind: "assistant",
ts: "2026-07-31T12:00:05.000Z",
text: "Found it — fixing now.",
} as TranscriptEntry,
];
it("tags agent messages streamed inside a live run turn as interstitial", () => {
const messages = transcriptToTaskChatItems(stream, {
runId: "run-1",
running: true,
}).filter((it) => it.kind === "message");
expect(messages).toHaveLength(2);
for (const m of messages) {
if (m.kind !== "message") continue;
expect(m.interstitial).toBe(true);
}
});
it("marks only the message still open at the tail as streaming", () => {
const items = transcriptToTaskChatItems(stream, {
runId: "run-1",
running: true,
});
const messages = items.filter((it) => it.kind === "message");
if (messages[0].kind !== "message" || messages[1].kind !== "message")
return;
// The first message was closed by the tool call — finished self-talk.
expect(messages[0].streaming).toBe(false);
expect(messages[1].streaming).toBe(true);
});
it("marks no message streaming when the tail is a tool call", () => {
const items = transcriptToTaskChatItems(
[...stream, toolCall("Bash", { command: "pnpm test" })],
{ runId: "run-1", running: true },
);
for (const m of items) {
if (m.kind === "message") expect(m.streaming).toBe(false);
}
});
it("tags settled-history messages interstitial too, so live and history agree", () => {
const messages = transcriptToTaskChatItems(stream, {
runId: "run-1",
running: false,
}).filter((it) => it.kind === "message");
expect(messages).toHaveLength(2);
for (const m of messages) {
if (m.kind !== "message") continue;
expect(m.interstitial).toBe(true);
expect(m.streaming).toBe(false);
}
});
it("stamps atMs from the first streamed chunk", () => {
const messages = transcriptToTaskChatItems(stream, {
runId: "run-1",
running: false,
}).filter((it) => it.kind === "message");
if (messages[0].kind !== "message" || messages[1].kind !== "message")
return;
expect(messages[0].atMs).toBe(Date.parse(TS));
expect(messages[1].atMs).toBe(Date.parse("2026-07-31T12:00:05.000Z"));
});
});
describe("flattenSelfTalk", () => {
it("strips markdown markers to one plain line", () => {
expect(
flattenSelfTalk(
"## Plan\n\nI'll **extend** the `ipRateLimit` helper:\n- read it\n- patch it",
),
).toBe("Plan I'll extend the ipRateLimit helper: read it patch it");
});
it("keeps link text, drops the url, and is stream-safe on unclosed markers", () => {
expect(flattenSelfTalk("see [the docs](https://x) for **bol")).toBe(
"see the docs for bol",
);
expect(flattenSelfTalk("```ts\nconst a = 1;")).toBe("const a = 1;");
});
});
describe("deriveRunStatusLabel streaming interstitial text (PAP-361)", () => {
it("returns the trailing message's flattened text with the Responding label", () => {
const status = deriveRunStatusLabel([
{
kind: "assistant",
ts: TS,
text: "I'll **extend** ",
} as TranscriptEntry,
{
kind: "assistant",
ts: TS,
text: "the `ipRateLimit` helper.",
} as TranscriptEntry,
]);
expect(status.label).toBe("Responding");
expect(status.selfTalk).toBe("I'll extend the ipRateLimit helper.");
expect(status.detail).toBeUndefined();
});
it("only accumulates the trailing message, not one closed by a tool call", () => {
const status = deriveRunStatusLabel([
{ kind: "assistant", ts: TS, text: "Earlier note." } as TranscriptEntry,
toolCall("Read", { file_path: "a.ts" }),
{ kind: "assistant", ts: TS, text: "Fresh line." } as TranscriptEntry,
]);
expect(status.selfTalk).toBe("Fresh line.");
});
it("carries no selfTalk for tool or thinking tails", () => {
expect(deriveRunStatusLabel([toolCall("Read")]).selfTalk).toBeUndefined();
expect(
deriveRunStatusLabel([
{ kind: "thinking", ts: TS, text: "hm" } as TranscriptEntry,
]).selfTalk,
).toBeUndefined();
expect(deriveRunStatusLabel([]).selfTalk).toBeUndefined();
});
});
describe("settledRunChildren (PAP-361)", () => {
const transcript: TranscriptEntry[] = [
{
kind: "assistant",
ts: TS,
text: "Checking the adapter first.",
} as TranscriptEntry,
toolCall("Read", { file_path: "a.ts" }),
{ kind: "thinking", ts: TS, text: "wire it in" } as TranscriptEntry,
toolCall("Edit", { file_path: "a.ts" }),
{
kind: "assistant",
ts: TS,
text: "Done — the limiter is wired in.",
} as TranscriptEntry,
];
const parsed = transcriptToTaskChatItems(transcript, {
runId: "run-1",
running: false,
});
it("groups tools under the historical assistant boundary and excludes the final reply", () => {
const children = settledRunChildren(parsed);
expect(children.map((c) => c.kind)).toEqual(["activity_phase"]);
const phase = children[0];
expect(phase.kind === "activity_phase" && phase.interstitial?.text).toBe(
"Checking the adapter first.",
);
expect(
phase.kind === "activity_phase" && phase.items.map((item) => item.kind),
).toEqual(["tool", "thinking", "tool"]);
});
it("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);
});
});