Files
PaperClipAI/ui/src/components/task-chat/transcript-adapter.test.ts
T
Dotta 46760cc588 fix(chat): honor dismissal for all composer requests (#17218)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task conversations show requests in the feed and above the message
composer.
> - Earlier fixes kept dismissed questions in history, but excluded
completion reviews and other request kinds.
> - Closing those forms left a pending badge, and the server could still
demand a response.
> - This pull request saves human dismissal and removes the request from
composer reminders and ordinary completion gates.
> - People can continue the conversation and reopen the request from
history.

## Linked Issues or Issue Description

Related work: #15229 and #15392.

**What happened?**

Dismissing a completion review closed its form but left it counted as
pending. It could return after reload, block a retry, or cause the agent
to demand another response. Raw runtime requests could also lose their
only usable control when dismissed.

**Expected behavior**

Dismissal leaves an accessible request in the feed and clears the
composer form and pending count. It persists after reload. It does not
submit an answer, approve an action, or wake the agent.

**Steps to reproduce**

1. Open a task with a pending completion review or another composer
request.
2. Dismiss the request, then reload the task.
3. Check that the composer stays clear and the feed entry can reopen the
request.

## What Changed

- Add nullable dismissal fields and a human-only dismissal endpoint with
company, private-task access, resolver, and locked review-policy checks.
- Persist saved-request dismissal on the server. Keep browser
persistence for raw runtime requests and existing question dismissals.
- Keep confirmations, choices, item verdicts, suggested tasks, and
runtime requests accessible in the feed.
- Exclude dismissed ordinary requests from completion and retry gates.
Tell agents not to repeat deferred requests.
- Preserve tool, secret, connection, plan, company-policy, and
configured review authorization gates. Expire deferred non-question
requests when their task completes.
- Preserve the selected form when another dismissal finishes saving.
Keep reopened reviews visible through delayed comment refreshes.
- Add a completion-review Storybook test drive and update design, API,
database, and capability documentation.

## Verification

- `pnpm -r typecheck`: passed. The final UI changes also passed the UI
typecheck.
- `pnpm build`: passed. The final UI changes also passed the UI
production build.
- Focused UI and transcript tests: 550 passed across nine files after
merging the current base branch.
- Focused database, completion, and prompt tests: 105 passed across
three files, including two review-policy regressions that failed before
the fix. The tests reject a review requester excluded by current policy,
preserve the pending gate on denial, and permit an independent reviewer
to defer the request.
- Route regression suite: 81 passed, including denial before reading or
mutating a private-task request.
- `pnpm check:token-gates`: passed.
- Map and Storybook structural checks: passed; 30 map/catalog script
tests passed.
- Browser: used the production task-thread components in the
completion-review fixture. Dismissed, reloaded, reopened from the feed,
and dismissed again. No pending badge or composer takeover remained.
Story callbacks simulate API saves; separate server tests cover
persistence and permissions.
- `pnpm test:run`: stopped after the general-server group reported one
failure in `workspace-runtime.test.ts`: "returns a bounded structured
conflict and exposes only same-company workspace references". The same
test passed in isolation (1 passed, 178 skipped). The cause of the
full-suite failure is not established. The full PR CI suite subsequently
passed on e243cbb001; its two authorization review findings are fixed in
9e1b4478b3. Fresh [full
CI](https://github.com/paperclipai/paperclip/actions/runs/39871944846)
passes on 9e1b4478b3, including the previously failing server and
browser shards. All 54 executed checks passed; two optional Storybook
deployment/visual jobs were skipped. The legacy security status also
passed. Fresh Greptile review is 5/5, the security scan passes, and no
review threads remain open. This full CI run settles the merge gate; the
earlier local workspace-suite failure remains recorded above because its
cause was not established.
- Additional verification: the original failing browser journey passed
locally after the delayed-refresh fix. The expanded page-reload case
also passes on 9e1b4478b3 (52.7s): dismiss, reload, reopen, approve, and
receive the actual tool failure in the continuing task. It exposed a
fixture reading an accepted request before tool execution finished; the
fixture now waits for a stored execution result. The first reload
attempt stopped on a still-loading page at the default five-second
assertion; the reload now uses the suite’s navigation allowance. A
controlled server-restart run timed out waiting for server health before
the UI assertions. Local route-suite runs timed out during concurrent
module loading; sequential imports passed all 81 tests, including the
privacy regression.
- Initial verification failures: a missing SDK build and a story fixture
type error were fixed. PostgreSQL bootstrap then failed because the host
had exhausted its semaphore allowance. After explicit operator approval,
64 verified abandoned PostgreSQL semaphore sets were reclaimed; no live
process was stopped. The database regressions then passed. The
raw-runtime regression exposed a missing feed control; the
implementation now preserves that entry and the regression passes.

## Risks

- Apply the additive database migration before the new server code. It
adds two nullable columns and does not change existing decisions.
- A dismissal is shared task attention state. Only a human with task
read access and current resolver/review authority can persist it. Review
policy is checked under the task lock. The operation is idempotent and
records the dismissing user.
- Raw runtime dismissal remains specific to a browser. Saved interaction
dismissal persists across browsers.
- A deferred review is not an approval. Tests retain protected-action
and configured review gates.
- The browser walkthrough uses a fixture. This branch has not been
deployed to a live instance.

## Model Used

OpenAI Codex, GPT-6, with code execution and browser tools. The exact
backend model ID and context-window size are not exposed in this
session.

## 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
- [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
2026-10-22 18:37:36 -05:00

2845 lines
84 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, 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);
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).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 a saved plan at its write_document boundary", () => {
const plan: TaskChatPlanDocumentItem = {
id: "plan-doc:revision-1",
kind: "plan_document",
document: {} as TaskChatPlanDocumentItem["document"],
};
const embedded = embedPlanDocumentAtWriteBoundary(
[
commentary("commentary-1", "Inspecting."),
tool("read-before-plan"),
commentary("commentary-2", "Publishing the plan."),
{
id: "write-plan",
kind: "tool",
name: "Write document",
rawName: "write_document",
status: "completed",
},
{
id: "write-plan-provider",
kind: "protocol",
surface: "provider_activity",
family: "tool_execution",
eventType: "tool.execution.completed",
status: "completed",
title: "write_document",
details: [{ label: "Name", value: "write_document" }],
steps: [],
links: [],
children: [],
},
commentary("commentary-3", "The plan is published."),
],
plan,
);
const rows = buildTurnTimelineRows(
paperclipRunnerTimelineItems(embedded),
false,
);
expect(
rows.map((row) =>
row.kind === "activity_phase"
? `${row.interstitial?.id}:${row.items.map((item) => item.id).join(",")}`
: row.id,
),
).toEqual([
"commentary-1:read-before-plan",
"commentary-2:write-plan,write-plan-provider",
plan.id,
"commentary-3:",
]);
expect(
embedded.find((item) => item.kind === "plan_document"),
).toMatchObject({ placement: "write_boundary" });
});
it("retains a saved plan as an explicit fallback when its write boundary is unavailable", () => {
const plan: TaskChatPlanDocumentItem = {
id: "plan-doc:revision-fallback",
kind: "plan_document",
document: {} as TaskChatPlanDocumentItem["document"],
};
expect(
embedPlanDocumentAtWriteBoundary(
[commentary("commentary", "Transcript is still hydrating.")],
plan,
),
).toEqual([
commentary("commentary", "Transcript is still hydrating."),
{ ...plan, placement: "fallback" },
]);
});
});
describe("transcriptToTaskChatItems native usage", () => {
it("does not merge progress and final runner messages", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "assistant",
ts: TS,
text: "Checking files.",
channel: "progress",
},
{
kind: "assistant",
ts: TS,
text: "The change is ready.",
channel: "final",
},
],
{ runId: "native-run", running: true },
);
expect(items).toEqual([
expect.objectContaining({
kind: "message",
text: "Checking files.",
channel: "progress",
interstitial: true,
}),
expect.objectContaining({
kind: "message",
text: "The change is ready.",
channel: "final",
interstitial: false,
}),
]);
});
it("keeps separate completed commentary items as separate chronological paragraphs", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "assistant",
ts: TS,
text: "I am checking the existing behavior.",
channel: "progress",
itemId: "message-1",
},
{
kind: "assistant",
ts: "2026-07-31T12:00:01.000Z",
text: "The implementation is ready.",
channel: "progress",
itemId: "message-2",
},
],
{ runId: "native-run", running: false },
);
expect(items).toEqual([
expect.objectContaining({
kind: "message",
text: "I am checking the existing behavior.",
}),
expect.objectContaining({
kind: "message",
text: "The implementation is ready.",
}),
]);
});
it("coalesces only streaming chunks that share an item identity", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "assistant",
ts: TS,
text: "Still ",
channel: "progress",
delta: true,
itemId: "message-1",
},
{
kind: "assistant",
ts: TS,
text: "working",
channel: "progress",
delta: true,
itemId: "message-1",
},
{
kind: "assistant",
ts: TS,
text: "Next message",
channel: "progress",
delta: true,
itemId: "message-2",
},
],
{ runId: "native-run", running: true },
);
expect(items).toEqual([
expect.objectContaining({ kind: "message", text: "Still working" }),
expect.objectContaining({ kind: "message", text: "Next message" }),
]);
});
it("keeps distinct completed reasoning items separate even on the same channel", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "thinking",
ts: TS,
text: "Inspecting transport.",
lifecycle: "completed",
channel: "summary",
itemId: "reason-1",
},
{
kind: "thinking",
ts: "2026-07-31T12:00:01.000Z",
text: "Checking persistence.",
lifecycle: "completed",
channel: "summary",
itemId: "reason-2",
},
],
{ runId: "native-run", running: false },
);
expect(items).toEqual([
expect.objectContaining({
kind: "thinking",
lines: ["Inspecting transport."],
}),
expect.objectContaining({
kind: "thinking",
lines: ["Checking persistence."],
}),
]);
});
it("renders runner usage without inventing a context-window size", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "result",
ts: TS,
text: "",
inputTokens: 40,
outputTokens: 10,
cachedTokens: 5,
costUsd: 0.02,
subtype: "paperclip_runner_usage",
isError: false,
errors: [],
},
],
{ runId: "native-run", running: true },
);
expect(items).toEqual([
{
id: "native-run:usage:0",
kind: "usage",
usage: {
used: 55,
size: 0,
inputTokens: 40,
outputTokens: 10,
costUsd: 0.02,
},
},
]);
});
it("renders cumulative-only runner session usage", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "result",
ts: TS,
text: "",
inputTokens: 80,
outputTokens: 20,
cachedTokens: 0,
costUsd: 0,
subtype: "paperclip_runner_session_usage",
isError: false,
errors: [],
},
],
{ runId: "native-run", running: true },
);
expect(items).toEqual([
expect.objectContaining({
kind: "usage",
label: "Provider session total",
detail: expect.stringContaining("cumulative usage"),
usage: expect.objectContaining({
used: 100,
inputTokens: 80,
outputTokens: 20,
}),
}),
]);
});
it("does not change direct-adapter result presentation", () => {
const items = transcriptToTaskChatItems(
[
{
kind: "result",
ts: TS,
text: "done",
inputTokens: 40,
outputTokens: 10,
cachedTokens: 0,
costUsd: 0,
subtype: "success",
isError: false,
errors: [],
},
],
{ runId: "legacy-run", running: true },
);
expect(items).toEqual([]);
});
});
describe("buildTurnSummary tool counting", () => {
function statusEntry(
toolUseId: string | undefined,
status: string,
): TranscriptEntry {
return {
kind: "tool_call",
ts: TS,
name: "Bash",
toolUseId,
input: { text: `tool call (${status})`, status },
} as TranscriptEntry;
}
it("counts unique tool calls, not per-status transcript entries", () => {
// 4 real calls × 4 status changes each = 16 entries; the summary must
// match the 4 rows the expanded list renders.
const entries = ["tc-1", "tc-2", "tc-3", "tc-4"].flatMap((id) =>
["pending", "in_progress", "in_progress", "completed"].map((status) =>
statusEntry(id, status),
),
);
expect(entries).toHaveLength(16);
expect(buildTurnSummary(entries).toolCount).toBe(4);
});
it("counts id-less legacy entries once each", () => {
const entries = [
statusEntry(undefined, "completed"),
statusEntry(undefined, "completed"),
toolCall("Read"),
];
expect(buildTurnSummary(entries).toolCount).toBe(3);
});
it("keeps session-cumulative runner usage out of run summaries", () => {
const runUsage = {
kind: "result",
ts: TS,
subtype: "paperclip_runner_usage",
inputTokens: 40,
outputTokens: 10,
} as TranscriptEntry;
const sessionUsage = {
kind: "result",
ts: TS,
subtype: "paperclip_runner_session_usage",
inputTokens: 800,
outputTokens: 200,
} as TranscriptEntry;
expect(buildTurnSummary([runUsage, sessionUsage]).tokensLabel).toBe(
"50 tokens",
);
expect(
buildMergedTurnSummary([
{ entries: [runUsage] },
{ entries: [sessionUsage] },
]).tokensLabel,
).toBe("50 tokens");
});
});
describe("deriveRunStatusLabel with generic tail updates", () => {
it("recovers the real tool name from the call's initial entry", () => {
const status = deriveRunStatusLabel([
toolCall("Terminal", { command: "ls -la" }),
{
kind: "tool_call",
ts: TS,
name: "tool call",
toolUseId: "tool-Terminal",
} as TranscriptEntry,
]);
expect(status.label).toBe("Running a command");
expect(status.toolName).toBe("Terminal");
});
it("treats a tail retitle as the invocation, not the identity", () => {
const status = deriveRunStatusLabel([
{
kind: "tool_call",
ts: TS,
name: "Terminal",
toolUseId: "tc-3",
} as TranscriptEntry,
{
kind: "tool_call",
ts: TS,
name: "pnpm test",
toolUseId: "tc-3",
} as TranscriptEntry,
]);
expect(status.label).toBe("Running a command");
expect(status.toolName).toBe("Terminal");
expect(status.detail).toBe("Terminal · pnpm test");
});
});
describe("isNestableLiveChild", () => {
const items: TaskChatItem[] = [
{ id: "t", kind: "tool", name: "Read", status: "completed" },
{ id: "th", kind: "thinking", lines: ["hm"] },
{ id: "u", kind: "usage", usage: { used: 1, size: 2 } },
{
id: "m",
kind: "message",
author: "agent",
text: "finished self-talk",
interstitial: true,
},
{
id: "mk",
kind: "marker",
variant: "session_start",
label: "Session started",
},
{ id: "s", kind: "status", status: "running", label: "Running" },
{ id: "i", kind: "interaction", interaction: {} as never },
];
it("nests tools, provider reasoning, and usage inside the live parent row", () => {
expect(items.filter(isNestableLiveChild).map((it) => it.kind)).toEqual([
"tool",
"thinking",
"usage",
]);
});
it("keeps messages, markers, statuses and interactions out", () => {
// Interstitial updates — finished or streaming — never nest: they are
// ephemeral, living on the live line only while streaming, and the final
// reply keeps its bubble.
for (const kind of ["message", "marker", "status", "interaction"]) {
const item = items.find((it) => it.kind === kind)!;
expect(isNestableLiveChild(item)).toBe(false);
}
expect(
isNestableLiveChild({
id: "m2",
kind: "message",
author: "agent",
text: "typing…",
interstitial: true,
streaming: true,
}),
).toBe(false);
expect(
isNestableLiveChild({
id: "m3",
kind: "message",
author: "agent",
text: "Final reply.",
}),
).toBe(false);
});
});
describe("interstitial self-talk classification (PAP-355)", () => {
const stream: TranscriptEntry[] = [
{
kind: "assistant",
ts: TS,
text: "Let me check the adapter.",
} as TranscriptEntry,
toolCall("Read", { file_path: "a.ts" }),
{
kind: "assistant",
ts: "2026-07-31T12:00:05.000Z",
text: "Found it — fixing now.",
} as TranscriptEntry,
];
it("tags agent messages streamed inside a live run turn as interstitial", () => {
const messages = transcriptToTaskChatItems(stream, {
runId: "run-1",
running: true,
}).filter((it) => it.kind === "message");
expect(messages).toHaveLength(2);
for (const m of messages) {
if (m.kind !== "message") continue;
expect(m.interstitial).toBe(true);
}
if (messages[0].kind !== "message" || messages[1].kind !== "message")
return;
// The first message was closed by the tool call — finished self-talk.
expect(messages[0].streaming).toBe(false);
expect(messages[1].streaming).toBe(true);
});
it("marks no message streaming when the tail is a tool call", () => {
const items = transcriptToTaskChatItems(
[...stream, toolCall("Bash", { command: "pnpm test" })],
{ runId: "run-1", running: true },
);
for (const m of items) {
if (m.kind === "message") expect(m.streaming).toBe(false);
}
});
it("tags settled-history messages interstitial too, so live and history agree", () => {
const messages = transcriptToTaskChatItems(stream, {
runId: "run-1",
running: false,
}).filter((it) => it.kind === "message");
expect(messages).toHaveLength(2);
for (const m of messages) {
if (m.kind !== "message") continue;
expect(m.interstitial).toBe(true);
expect(m.streaming).toBe(false);
}
if (messages[0].kind !== "message" || messages[1].kind !== "message")
return;
expect(messages[0].atMs).toBe(Date.parse(TS));
expect(messages[1].atMs).toBe(Date.parse("2026-07-31T12:00:05.000Z"));
});
});
describe("flattenSelfTalk", () => {
it("strips markdown markers to one plain line", () => {
expect(
flattenSelfTalk(
"## Plan\n\nI'll **extend** the `ipRateLimit` helper:\n- read it\n- patch it",
),
).toBe("Plan I'll extend the ipRateLimit helper: read it patch it");
});
it("keeps link text, drops the url, and is stream-safe on unclosed markers", () => {
expect(flattenSelfTalk("see [the docs](https://x) for **bol")).toBe(
"see the docs for bol",
);
expect(flattenSelfTalk("```ts\nconst a = 1;")).toBe("const a = 1;");
});
});
describe("deriveRunStatusLabel streaming interstitial text (PAP-361)", () => {
it("returns the trailing message's flattened text with the Responding label", () => {
const status = deriveRunStatusLabel([
{
kind: "assistant",
ts: TS,
text: "I'll **extend** ",
} as TranscriptEntry,
{
kind: "assistant",
ts: TS,
text: "the `ipRateLimit` helper.",
} as TranscriptEntry,
]);
expect(status.label).toBe("Responding");
expect(status.selfTalk).toBe("I'll extend the ipRateLimit helper.");
expect(status.detail).toBeUndefined();
});
it("only accumulates the trailing message, not one closed by a tool call", () => {
const status = deriveRunStatusLabel([
{ kind: "assistant", ts: TS, text: "Earlier note." } as TranscriptEntry,
toolCall("Read", { file_path: "a.ts" }),
{ kind: "assistant", ts: TS, text: "Fresh line." } as TranscriptEntry,
]);
expect(status.selfTalk).toBe("Fresh line.");
});
it("carries no selfTalk for tool or thinking tails", () => {
expect(deriveRunStatusLabel([toolCall("Read")]).selfTalk).toBeUndefined();
expect(
deriveRunStatusLabel([
{ kind: "thinking", ts: TS, text: "hm" } as TranscriptEntry,
]).selfTalk,
).toBeUndefined();
expect(deriveRunStatusLabel([]).selfTalk).toBeUndefined();
});
});
describe("settledRunChildren (PAP-361)", () => {
const transcript: TranscriptEntry[] = [
{
kind: "assistant",
ts: TS,
text: "Checking the adapter first.",
} as TranscriptEntry,
toolCall("Read", { file_path: "a.ts" }),
{ kind: "thinking", ts: TS, text: "wire it in" } as TranscriptEntry,
toolCall("Edit", { file_path: "a.ts" }),
{
kind: "assistant",
ts: TS,
text: "Done — the limiter is wired in.",
} as TranscriptEntry,
];
const parsed = transcriptToTaskChatItems(transcript, {
runId: "run-1",
running: false,
});
it("groups tools under the historical assistant boundary and excludes the final reply", () => {
const children = settledRunChildren(parsed);
expect(children.map((c) => c.kind)).toEqual(["activity_phase"]);
const phase = children[0];
expect(phase.kind === "activity_phase" && phase.interstitial?.text).toBe(
"Checking the adapter first.",
);
expect(
phase.kind === "activity_phase" && phase.items.map((item) => item.kind),
).toEqual(["tool", "thinking", "tool"]);
});
it("excludes an explicit final reply when runner usage follows it", () => {
const finalThenUsage = transcriptToTaskChatItems(
[
{
kind: "assistant",
ts: TS,
text: "Done — the limiter is wired in.",
channel: "final",
} as TranscriptEntry,
{
kind: "result",
ts: TS,
text: "",
inputTokens: 40,
outputTokens: 10,
cachedTokens: 0,
costUsd: 0,
subtype: "paperclip_runner_usage",
isError: false,
errors: [],
} as TranscriptEntry,
],
{ runId: "native-run", running: false },
);
const children = settledRunChildren(finalThenUsage);
expect(children).toHaveLength(1);
const phase = children[0];
expect(phase.kind).toBe("activity_phase");
if (phase.kind !== "activity_phase") return;
expect(phase.interstitial).toBeUndefined();
expect(phase.items.map((item) => item.kind)).toEqual(["usage"]);
});
it("keeps a channel-less final reply out of activity when bookkeeping follows it", () => {
const finalThenBookkeeping = transcriptToTaskChatItems(
[
{
kind: "assistant",
ts: TS,
text: "Done — the direct adapter finished.",
} as TranscriptEntry,
{
kind: "result",
ts: TS,
text: "",
inputTokens: 40,
outputTokens: 10,
cachedTokens: 0,
costUsd: 0,
subtype: "paperclip_runner_usage",
isError: false,
errors: [],
} as TranscriptEntry,
],
{ runId: "legacy-run", running: false },
);
const children = settledRunChildren(finalThenBookkeeping);
expect(children).toHaveLength(1);
const phase = children[0];
expect(phase.kind).toBe("activity_phase");
if (phase.kind !== "activity_phase") return;
expect(phase.interstitial).toBeUndefined();
expect(phase.items.map((item) => item.kind)).toEqual(["usage"]);
});
it("keeps a channel-less final reply out of activity when a workspace summary follows it", () => {
const finalReply = transcriptToTaskChatItems(
[
{
kind: "assistant",
ts: TS,
text: "Done — the workspace is updated.",
} as TranscriptEntry,
],
{ runId: "legacy-run", running: false },
);
const children = settledRunChildren([
...finalReply,
{
id: "workspace-summary",
kind: "protocol",
surface: "workspace_change",
changeSetId: "changes-1",
revision: 1,
source: "harness_reported",
complete: true,
files: [],
totals: { files: 0, additions: 0, deletions: 0 },
patchArtifactRef: null,
},
]);
expect(children).toHaveLength(1);
expect(children[0]?.kind).toBe("protocol");
expect(
children.some(
(child) =>
child.kind === "activity_phase" &&
child.interstitial?.text === "Done — the workspace is updated.",
),
).toBe(false);
});
it("does not hide channel-less commentary when real activity follows it", () => {
const commentaryThenToolAndUsage = transcriptToTaskChatItems(
[
{
kind: "assistant",
ts: TS,
text: "Checking the direct adapter first.",
} as TranscriptEntry,
toolCall("Read", { file_path: "a.ts" }),
{
kind: "result",
ts: TS,
text: "",
inputTokens: 40,
outputTokens: 10,
cachedTokens: 0,
costUsd: 0,
subtype: "paperclip_runner_usage",
isError: false,
errors: [],
} as TranscriptEntry,
],
{ runId: "legacy-run", running: false },
);
const children = settledRunChildren(commentaryThenToolAndUsage);
expect(children).toHaveLength(1);
const phase = children[0];
expect(phase.kind).toBe("activity_phase");
if (phase.kind !== "activity_phase") return;
expect(phase.interstitial?.text).toBe("Checking the direct adapter first.");
expect(phase.items.map((item) => item.kind)).toEqual(["tool", "usage"]);
});
it("matches the folded summary's tool count exactly (row-count parity)", () => {
const children = settledRunChildren(parsed);
const summary = buildTurnSummary(transcript);
const phaseToolCount = children.reduce(
(count, child) =>
count +
(child.kind === "activity_phase"
? child.items.filter((item) => item.kind === "tool").length
: 0),
0,
);
expect(phaseToolCount).toBe(summary.toolCount);
});
it("creates a stable opening phase for calls before the first interstitial", () => {
const opening = transcriptToTaskChatItems(
[
toolCall("Read", { file_path: "a.ts" }),
{ kind: "assistant", ts: TS, text: "Now editing." } as TranscriptEntry,
toolCall("Edit", { file_path: "a.ts" }),
{ kind: "assistant", ts: TS, text: "Done." } as TranscriptEntry,
],
{ runId: "run-opening", running: false },
);
const phases = settledRunChildren(opening);
expect(phases).toHaveLength(2);
expect(phases[0].id).toContain(":phase:opening");
expect(phases[1].kind === "activity_phase" && phases[1].summary).toBe(
"Edited a file",
);
});
});
describe("paperclip runner semantic channels", () => {
it("concatenates fragmented reasoning deltas into logical lines", () => {
const parsed = transcriptToTaskChatItems(
[
{
kind: "thinking",
ts: TS,
text: "Inspect",
delta: true,
channel: "summary",
},
{
kind: "thinking",
ts: TS,
text: "ing the ",
delta: true,
channel: "summary",
},
{
kind: "thinking",
ts: TS,
text: "runner",
delta: true,
channel: "summary",
},
{
kind: "thinking",
ts: TS,
text: "\nThen ",
delta: true,
channel: "summary",
},
{
kind: "thinking",
ts: TS,
text: "verify streaming",
delta: true,
channel: "summary",
},
],
{ runId: "run-fragmented-reasoning", running: true },
);
expect(parsed).toHaveLength(1);
expect(parsed[0]).toMatchObject({
kind: "thinking",
lines: ["Inspecting the runner", "Then verify streaming"],
transcriptIndex: 4,
});
});
it("ends reasoning when a newer transcript surface becomes current", () => {
const parsed = transcriptToTaskChatItems(
[
{
kind: "thinking",
ts: TS,
text: "Inspecting the runner",
channel: "summary",
delta: true,
},
{
kind: "assistant",
ts: TS,
text: "I found the relevant component.",
channel: "progress",
},
{
kind: "thinking",
ts: TS,
text: "Checking the render state",
channel: "summary",
delta: true,
},
toolCall("Read", { file_path: "TaskChatThinking.tsx" }),
{
kind: "thinking",
ts: TS,
text: "Verifying the fix",
channel: "summary",
delta: true,
},
],
{ runId: "run-reasoning-activity", running: true },
);
const thinking = parsed.filter(
(item): item is Extract<TaskChatItem, { kind: "thinking" }> =>
item.kind === "thinking",
);
expect(thinking.map((item) => item.streaming)).toEqual([
false,
false,
true,
]);
});
it("turns empty reasoning lifecycle frames into a visible thinking item", () => {
const parsed = transcriptToTaskChatItems(
[
{
kind: "thinking",
ts: "2026-08-21T12:00:00.000Z",
text: "",
lifecycle: "started",
channel: "summary",
},
{
kind: "thinking",
ts: "2026-08-21T12:00:02.000Z",
text: "",
lifecycle: "completed",
channel: "summary",
},
],
{ runId: "run-empty-reasoning", running: true },
);
expect(parsed).toEqual([
expect.objectContaining({
kind: "thinking",
lines: [],
streaming: false,
lifecycleOnly: true,
summaryLabel: "Thought for 2s",
}),
]);
});
it("keeps progress/final messages and reasoning summary/detail in distinct items", () => {
const parsed = transcriptToTaskChatItems(
[
{
kind: "assistant",
ts: TS,
text: "Checking.",
channel: "progress",
delta: true,
},
{
kind: "thinking",
ts: TS,
text: "Summary",
channel: "summary",
delta: true,
},
{
kind: "thinking",
ts: TS,
text: "Detail",
channel: "detail",
delta: true,
},
{
kind: "assistant",
ts: TS,
text: "Done.",
channel: "final",
delta: true,
},
],
{ runId: "run-channels", running: true },
);
expect(parsed.map((item) => item.kind)).toEqual([
"message",
"thinking",
"thinking",
"message",
]);
expect(parsed[0]).toMatchObject({
interstitial: true,
channel: "progress",
});
expect(parsed[1]).toMatchObject({ channel: "summary", streaming: false });
expect(parsed[2]).toMatchObject({ channel: "detail", streaming: false });
expect(parsed[3]).toMatchObject({ interstitial: false, channel: "final" });
const history = settledRunChildren(parsed);
expect(JSON.stringify(history)).not.toContain("Done.");
});
it("filters lifecycle and usage noise from only the runner history surface", () => {
const items: TaskChatItem[] = [
{
id: "session",
kind: "marker",
variant: "session_start",
label: "Session started",
detail: "session-id",
},
{
id: "start",
kind: "marker",
variant: "turn_boundary",
label: "Turn started",
},
{
id: "usage",
kind: "usage",
usage: {
used: 1_024,
size: 128_000,
inputTokens: 1_000,
outputTokens: 24,
},
},
{
id: "tool",
kind: "tool",
name: "Paperclip_finish",
status: "completed",
target: "done",
},
{
id: "interrupt",
kind: "marker",
variant: "interrupted",
label: "Interrupted",
},
{
id: "runner",
kind: "marker",
variant: "turn_boundary",
label: "Runner update",
},
{
id: "complete",
kind: "marker",
variant: "turn_boundary",
label: "Turn completed",
},
];
expect(paperclipRunnerHistoryItems(items).map((item) => item.id)).toEqual([
"tool",
"interrupt",
"runner",
]);
expect(items).toHaveLength(7);
});
it("projects one semantic activity row per useful logical item", () => {
const items: TaskChatItem[] = [
{
id: "progress",
kind: "message",
author: "agent",
text: "Looking this up.",
interstitial: true,
channel: "progress",
},
{
id: "final",
kind: "message",
author: "agent",
text: "Finished.",
channel: "final",
},
{
id: "empty-thinking",
kind: "thinking",
lines: [],
lifecycleOnly: true,
},
{
id: "thinking",
kind: "thinking",
lines: ["Provider-authored summary"],
},
{
id: "tool",
kind: "tool",
name: "Search",
rawName: "web_search",
status: "completed",
},
{
id: "finish",
kind: "tool",
name: "Paperclip_finish",
rawName: "paperclip_finish",
status: "completed",
},
{
id: "session",
kind: "marker",
variant: "session_start",
label: "Session started",
},
{
id: "interrupt",
kind: "marker",
variant: "interrupted",
label: "Interrupted",
},
{ id: "usage", kind: "usage", usage: { used: 20, size: 100 } },
{
id: "research",
kind: "protocol",
surface: "provider_activity",
family: "research",
eventType: "research.completed",
status: "completed",
title: "Research",
details: [],
steps: [],
links: [],
children: [],
},
{
id: "result",
kind: "protocol",
surface: "run_result",
disposition: "done",
summary: "Finished.",
objectiveSatisfied: true,
verification: [],
remainingWork: [],
blocker: null,
artifacts: [],
},
{
id: "terminal",
kind: "protocol",
surface: "run_terminal",
turnState: "completed",
runState: "succeeded",
disposition: "done",
},
];
expect(paperclipRunnerActivityItems(items).map((item) => item.id)).toEqual([
"progress",
"thinking",
"tool",
"interrupt",
"usage",
"research",
]);
});
});
describe("buildMergedTurnSummary (PAP-362)", () => {
const runA: TranscriptEntry[] = [
{
kind: "tool_call",
ts: "2026-07-31T12:00:00.000Z",
name: "Read",
toolUseId: "a-1",
} as TranscriptEntry,
{
kind: "tool_call",
ts: "2026-07-31T12:00:05.000Z",
name: "Edit",
toolUseId: "a-2",
} as TranscriptEntry,
{
kind: "diff",
ts: "2026-07-31T12:00:06.000Z",
changeType: "add",
text: "+x",
} as TranscriptEntry,
{
kind: "result",
ts: "2026-07-31T12:00:10.000Z",
inputTokens: 500,
outputTokens: 300,
} as TranscriptEntry,
];
const runB: TranscriptEntry[] = [
{
kind: "tool_call",
ts: "2026-07-31T12:30:00.000Z",
name: "Bash",
toolUseId: "b-1",
} as TranscriptEntry,
{
kind: "result",
ts: "2026-07-31T12:30:05.000Z",
inputTokens: 300,
outputTokens: 100,
} as TranscriptEntry,
];
it("sums tool count, diffs, tokens, and duration across runs", () => {
const summary = buildMergedTurnSummary([
{ entries: runA, durationMs: 30_000 },
{ entries: runB, durationMs: 15_000 },
]);
expect(summary.toolCount).toBe(3);
expect(summary.added).toBe(1);
expect(summary.tokensLabel).toBe("1.2k tokens");
expect(summary.durationLabel).toBe("45s");
expect(summary.failed).toBeUndefined();
});
it("sums per-run ts spans — never the idle gap between runs", () => {
// runA spans 10s, runB spans 5s, the gap between them is ~30min.
const summary = buildMergedTurnSummary([
{ entries: runA },
{ entries: runB },
]);
expect(summary.durationLabel).toBe("15s");
});
it("marks the merged turn failed when any run failed", () => {
const summary = buildMergedTurnSummary([
{ entries: runA, durationMs: 1000 },
{ entries: runB, durationMs: 1000, failed: true },
]);
expect(summary.failed).toBe(true);
});
});
describe("coalesceSettledTurns (PAP-362)", () => {
const runA: TranscriptEntry[] = [
{
kind: "tool_call",
ts: TS,
name: "Read",
toolUseId: "a-1",
} as TranscriptEntry,
{
kind: "tool_call",
ts: TS,
name: "Edit",
toolUseId: "a-2",
} as TranscriptEntry,
];
const runB: TranscriptEntry[] = [
{
kind: "tool_call",
ts: TS,
name: "Bash",
toolUseId: "b-1",
} as TranscriptEntry,
];
function agentBubble(id: string): TaskChatItem {
return {
id,
kind: "message",
author: "agent",
authorName: "CEO",
text: "Done.",
};
}
function turn(
id: string,
entries: TranscriptEntry[],
durationMs: number,
): TaskChatItem {
return {
id,
kind: "turn",
settled: true,
items: entries.map((e, i) => ({
id: `${id}:tool:${i}`,
kind: "tool" as const,
name: (e as { name: string }).name,
status: "completed" as const,
})),
summary: buildTurnSummary(entries, { durationMs }),
};
}
const metaById = new Map<string, SettledTurnMergeMeta>([
[
"run-a:turn",
{
agentKey: "agent-1",
agentName: "CEO",
parts: [{ entries: runA, durationMs: 30_000 }],
},
],
[
"run-b:turn",
{
agentKey: "agent-1",
agentName: "CEO",
parts: [{ entries: runB, durationMs: 15_000 }],
},
],
]);
it("merges two back-to-back runs into ONE Worked row summing tools and duration", () => {
// Two terminal runs, same agent, consecutive comments, no user message
// between → the merged turn sits below the LAST bubble.
const items: TaskChatItem[] = [
agentBubble("msg-a"),
turn("run-a:turn", runA, 30_000),
agentBubble("msg-b"),
turn("run-b:turn", runB, 15_000),
];
const out = coalesceSettledTurns(items, metaById);
expect(out.map((i) => i.kind)).toEqual(["message", "message", "turn"]);
const merged = out[2] as Extract<TaskChatItem, { kind: "turn" }>;
expect(merged.id).toBe("run-a:turn"); // stable: keeps the first run's id
expect(merged.summary.toolCount).toBe(3);
expect(merged.summary.durationLabel).toBe("45s");
expect(merged.items.map((c) => c.id)).toEqual([
"run-a:turn:tool:0",
"run-a:turn:tool:1",
"run-b:turn:tool:0",
]);
});
it("keeps two rows when a user message sits between the runs", () => {
const items: TaskChatItem[] = [
agentBubble("msg-a"),
turn("run-a:turn", runA, 30_000),
{ id: "msg-u", kind: "message", author: "human", text: "also do X" },
agentBubble("msg-b"),
turn("run-b:turn", runB, 15_000),
];
const out = coalesceSettledTurns(items, metaById);
expect(out.filter((i) => i.kind === "turn")).toHaveLength(2);
expect(out.map((i) => i.id)).toEqual([
"msg-a",
"run-a:turn",
"msg-u",
"msg-b",
"run-b:turn",
]);
});
it("does not merge across an interaction or into another agent's turn", () => {
const withInteraction: TaskChatItem[] = [
turn("run-a:turn", runA, 30_000),
{ id: "int-1", kind: "interaction", interaction: {} } as TaskChatItem,
turn("run-b:turn", runB, 15_000),
];
expect(
coalesceSettledTurns(withInteraction, metaById).filter(
(i) => i.kind === "turn",
),
).toHaveLength(2);
const otherAgent = new Map(metaById);
otherAgent.set("run-b:turn", {
agentKey: "agent-2",
agentName: "QA",
parts: [{ entries: runB }],
});
const sameShape: TaskChatItem[] = [
turn("run-a:turn", runA, 30_000),
turn("run-b:turn", runB, 15_000),
];
expect(
coalesceSettledTurns(sameShape, otherAgent).filter(
(i) => i.kind === "turn",
),
).toHaveLength(2);
});
it("never merges the live (unsettled) turn and preserves animateFold", () => {
const live: TaskChatItem = {
id: "run-b:turn",
kind: "turn",
settled: false,
items: [],
summary: buildTurnSummary([]),
};
const out = coalesceSettledTurns(
[turn("run-a:turn", runA, 30_000), live],
metaById,
);
expect(out).toHaveLength(2);
const seenLive = {
...turn("run-b:turn", runB, 15_000),
animateFold: true,
} as TaskChatItem;
const merged = coalesceSettledTurns(
[turn("run-a:turn", runA, 30_000), seenLive],
metaById,
);
expect(merged).toHaveLength(1);
expect(
(merged[0] as Extract<TaskChatItem, { kind: "turn" }>).animateFold,
).toBe(true);
});
});
describe("attachSettledTurns (round 9)", () => {
const runA: TranscriptEntry[] = [
{
kind: "tool_call",
ts: TS,
name: "Read",
toolUseId: "a-1",
} as TranscriptEntry,
];
function agentBubble(id: string, authorName = "CEO"): TaskChatItem {
return { id, kind: "message", author: "agent", authorName, text: "Done." };
}
function settledTurn(id: string): TaskChatItem {
return {
id,
kind: "turn",
settled: true,
items: [
{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" },
],
summary: buildTurnSummary(runA, { durationMs: 30_000 }),
};
}
const metaById = new Map<string, SettledTurnMergeMeta>([
[
"run-a:turn",
{
agentKey: "agent-1",
agentName: "CEO",
parts: [{ entries: runA, durationMs: 30_000 }],
},
],
]);
it("folds a settled turn into the agent reply bubble directly above it", () => {
const out = attachSettledTurns(
[agentBubble("msg-a"), settledTurn("run-a:turn")],
metaById,
);
expect(out).toHaveLength(1);
const bubble = out[0] as Extract<TaskChatItem, { kind: "message" }>;
expect(bubble.id).toBe("msg-a");
expect(bubble.attachedTurn?.id).toBe("run-a:turn");
expect(bubble.attachedTurn?.summary.durationLabel).toBe("30s");
});
it("keeps the standalone row when the preceding item is not that agent's bubble", () => {
// Human message directly above.
const afterHuman = attachSettledTurns(
[
{ id: "msg-u", kind: "message", author: "human", text: "hi" },
settledTurn("run-a:turn"),
],
metaById,
);
expect(afterHuman.map((i) => i.kind)).toEqual(["message", "turn"]);
// Another agent's bubble above.
const otherAgent = attachSettledTurns(
[agentBubble("msg-q", "QA"), settledTurn("run-a:turn")],
metaById,
);
expect(otherAgent.map((i) => i.kind)).toEqual(["message", "turn"]);
// No meta (unknown identity) → conservative standalone fallback.
const noMeta = attachSettledTurns(
[agentBubble("msg-a"), settledTurn("run-x:turn")],
metaById,
);
expect(noMeta.map((i) => i.kind)).toEqual(["message", "turn"]);
// Nothing above at all.
const first = attachSettledTurns([settledTurn("run-a:turn")], metaById);
expect(first.map((i) => i.kind)).toEqual(["turn"]);
});
it("never attaches the live (unsettled) turn and attaches at most one turn per bubble", () => {
const live: TaskChatItem = {
id: "run-live:turn",
kind: "turn",
settled: false,
items: [],
summary: buildTurnSummary([]),
};
const out = attachSettledTurns([agentBubble("msg-a"), live], metaById);
expect(out.map((i) => i.kind)).toEqual(["message", "turn"]);
// A second settled turn behind an already-attached bubble stays standalone
// (coalesceSettledTurns runs first, so same-agent turns arrive pre-merged).
const twice = attachSettledTurns(
[
agentBubble("msg-a"),
settledTurn("run-a:turn"),
settledTurn("run-a:turn"),
],
metaById,
);
expect(twice.map((i) => i.kind)).toEqual(["message", "turn"]);
expect(
(twice[0] as Extract<TaskChatItem, { kind: "message" }>).attachedTurn?.id,
).toBe("run-a:turn");
});
});
describe("assembleThreadItems (PAP-367)", () => {
function entry(
id: string,
ms: number,
item?: Partial<TaskChatItem>,
): ThreadBackboneEntry {
return {
ms,
id,
item: {
id,
kind: "message",
author: "human",
text: `msg ${id}`,
...item,
} as TaskChatItem,
};
}
function settledTurn(id: string): TaskChatTurnItem {
return {
id,
kind: "turn",
settled: true,
items: [
{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" },
],
summary: buildTurnSummary([toolCall("Read")], { durationMs: 34_000 }),
};
}
const noAnchors = new Map<string, TaskChatTurnItem[]>();
it("inserts a comment-less settled turn chronologically at its run's start time", () => {
const backbone = [
entry("u1", 1_000),
entry("a1", 2_000, { author: "agent", authorName: "CEO" }),
entry("u2", 3_000),
];
const out = assembleThreadItems(backbone, noAnchors, [
{ turn: settledTurn("run-x:turn"), startMs: 2_500 },
]);
expect(out.map((i) => i.id)).toEqual(["u1", "a1", "run-x:turn", "u2"]);
});
it("does not append a comment-less turn after the newest user message (the P11 bug)", () => {
// The screenshot bug: an OLD stopped run (start 1_500) re-pinning its
// "Worked · 34s" row under every NEW user bubble (3_000).
const backbone = [entry("u1", 1_000), entry("u2", 3_000)];
const out = assembleThreadItems(backbone, noAnchors, [
{ turn: settledTurn("run-x:turn"), startMs: 1_500 },
]);
expect(out.map((i) => i.id)).toEqual(["u1", "run-x:turn", "u2"]);
expect(out[out.length - 1].id).toBe("u2");
});
it("keeps anchored turns directly after their run's reply comment", () => {
const backbone = [
entry("u1", 1_000),
entry("a1", 2_000, { author: "agent", authorName: "CEO" }),
entry("u2", 3_000),
];
const anchors = new Map([["a1", [settledTurn("run-a:turn")]]]);
const out = assembleThreadItems(backbone, anchors, []);
expect(out.map((i) => i.id)).toEqual(["u1", "a1", "run-a:turn", "u2"]);
});
it("keeps the tail slot for a turn with unknown start, and puts ties after the entry", () => {
const backbone = [entry("u1", 1_000), entry("u2", 3_000)];
// Unknown start (no linked meta yet — just-settled run): Infinity → tail.
const tail = assembleThreadItems(backbone, noAnchors, [
{ turn: settledTurn("run-x:turn"), startMs: Number.POSITIVE_INFINITY },
]);
expect(tail.map((i) => i.id)).toEqual(["u1", "u2", "run-x:turn"]);
// Tie with the trigger comment: comment renders first.
const tie = assembleThreadItems(backbone, noAnchors, [
{ turn: settledTurn("run-y:turn"), startMs: 1_000 },
]);
expect(tie.map((i) => i.id)).toEqual(["u1", "run-y:turn", "u2"]);
});
it("chronological insertion after a user message stays a standalone row through coalesce+attach", () => {
// Interplay guard: the inserted turn sits below a HUMAN bubble, so the
// later passes must neither merge it into anything nor attach it.
const backbone = [entry("u1", 1_000), entry("u2", 3_000)];
const metaById = new Map<string, SettledTurnMergeMeta>([
[
"run-x:turn",
{
agentKey: "agent-1",
agentName: "CEO",
parts: [{ entries: [toolCall("Read")], durationMs: 34_000 }],
},
],
]);
const assembled = assembleThreadItems(backbone, noAnchors, [
{ turn: settledTurn("run-x:turn"), startMs: 1_500 },
]);
const out = attachSettledTurns(
coalesceSettledTurns(assembled, metaById),
metaById,
);
expect(out.map((i) => i.id)).toEqual(["u1", "run-x:turn", "u2"]);
expect(
out.every(
(i) =>
i.kind !== "message" ||
(i as Extract<TaskChatItem, { kind: "message" }>).attachedTurn ==
null,
),
).toBe(true);
});
});
describe("prependIssueBrief (PAP-375)", () => {
function entry(id: string, ms: number): ThreadBackboneEntry {
return {
ms,
id,
item: {
id,
kind: "message",
author: "human",
text: `msg ${id}`,
} as TaskChatItem,
};
}
function settledTurn(id: string): TaskChatTurnItem {
return {
id,
kind: "turn",
settled: true,
items: [
{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" },
],
summary: buildTurnSummary([toolCall("Read")], { durationMs: 34_000 }),
};
}
it("keeps the description bubble above an unanchored turn that predates every backbone entry (F15)", () => {
// A stopped run whose startMs (0) is EARLIER than the oldest comment: it
// sorts to the very top of the assembled thread — the brief must still
// render above it, because the prepend runs after assembly.
const backbone = [entry("u1", 1_000), entry("u2", 3_000)];
const assembled = assembleThreadItems(backbone, new Map(), [
{ turn: settledTurn("run-x:turn"), startMs: 0 },
]);
const out = prependIssueBrief(assembled, true);
expect(out.map((i) => i.id)).toEqual([
ISSUE_BRIEF_ITEM_ID,
"run-x:turn",
"u1",
"u2",
]);
expect(out[0].kind).toBe("brief");
});
it("is the first item of an otherwise empty thread, and a no-op without a brief", () => {
expect(prependIssueBrief([], true).map((i) => i.kind)).toEqual(["brief"]);
const items = [entry("u1", 1_000).item];
expect(prependIssueBrief(items, false)).toBe(items);
});
});