feat(ui): project native runner turns into task chat (#12617)

## Thinking Path

> - Paperclip is the open source app people use to supervise AI agents
and their work.
> - The task page is the established place to read run progress and
answer agent questions.
> - Native runner events use PRP envelopes instead of the direct-adapter
transcript format.
> - The task page needs a narrow projection for those events without
changing legacy adapter behavior.
> - Unknown event versions and fields must stay hidden until the UI
supports them.
> - This pull request adds a runtime-gated native turn projection and
preserves the classic path.
> - The benefit is one task thread for native Codex runs while direct
adapters keep their current UI.

## Linked Issues or Issue Description

**Subsystem affected**

`ui/` task chat and transcript projection.

**Problem or motivation**

The server can record native runner events, replies, usage, and
structured interactions, but the existing task page cannot safely render
those records. Reusing the native path for direct adapters would also
risk the legacy question and finalization behavior.

**Proposed solution**

Project supported PRP v1 events into the existing transcript model only
when runtime facts identify a native `paperclip_runner` run. Use an
exact event and payload allowlist. Keep direct adapters on the existing
transcript, composer, interaction, and finalization path.

**Alternatives considered**

A separate runner page was rejected because it would split task history.
A universal transcript replacement was rejected because the experimental
runner must not alter legacy adapters.

**Roadmap alignment**

This change supports activity attribution and recoverable runs. It does
not replace the current task page.

## What Changed

- Project supported native PRP v1 assistant, reasoning, tool, activity,
usage, interaction, and result events.
- Render a native runner turn only when both runtime mode and adapter
type match.
- Recognize the canonical `assistant_message` item kind and preserve
final-reply precedence.
- Fail closed for unsupported PRP versions, event types, payload
schemas, and identity fields.
- Keep explicit running and pending provider activities open until a
real terminal state arrives.
- Include channel and lifecycle identity in memoization so
progress-to-final transitions rerender.
- Add focused native, empty-transcript, interaction, final, memoization,
and direct-adapter regressions.

## Verification

- GitHub Actions is the authoritative test environment for this stack.
- This top PR receives the full stack-aware CI suite.
- Greptile will review this exact nine-file delta after the branch is
pushed.

## Risks

- The main risk is changing direct-adapter task behavior. The render
gate requires both native runtime mode and the `paperclip_runner`
adapter.
- The next risk is exposing future provider payloads. Version, event,
schema, and field allowlists fail closed.
- There are no database, lockfile, workflow, build-system, or server
changes in this PR.

## Stack

1. [Runner package, SDK, and developer
tools](https://github.com/paperclipai/paperclip/pull/12608)
2. [Codex production server
integration](https://github.com/paperclipai/paperclip/pull/12616)
3. This PR: provider-neutral task-thread UI

## Model Used

OpenAI Codex with GPT-5, extended reasoning, repository tools, and
parallel review agents.

## 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
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta 2026-08-31 23:32:13 -05:00 committed by GitHub
parent 51ad751e0b
commit 209680409d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1139 additions and 62 deletions

View File

@ -19,7 +19,12 @@ function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource)
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
const last = entries[entries.length - 1];
if (last && last.kind === entry.kind && last.delta) {
if (
last &&
last.kind === entry.kind &&
last.delta &&
last.channel === entry.channel
) {
last.text += entry.text;
last.ts = entry.ts;
return;

View File

@ -16,13 +16,13 @@ const sidebarState = vi.hoisted(() => ({ isMobile: false }));
vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: ({ runs }: { runs: unknown[] }) => {
transcriptHookRuns.legacy.push(runs);
return transcriptState;
return { transcriptByRun: new Map(transcriptState.transcriptByRun) };
},
}));
vi.mock("@/components/transcript/useNativeRunTranscripts", () => ({
useNativeRunTranscripts: (runs: unknown[]) => {
transcriptHookRuns.native.push(runs);
return nativeTranscriptState;
return { transcriptByRun: new Map(nativeTranscriptState.transcriptByRun) };
},
}));
vi.mock("@/context/SidebarContext", () => ({
@ -164,6 +164,225 @@ describe("TaskChatThread runtime transcript selection", () => {
expect(legacyRuns.map((run) => run.id)).toEqual(["legacy-run"]);
expect(nativeRuns.map((run) => run.id)).toEqual(["native-run"]);
});
it("uses runner-only controls only for an actual native Paperclip Runner run", () => {
nativeTranscriptState.transcriptByRun.set("native-run", [
{
kind: "assistant",
ts: "2026-08-25T18:00:01.000Z",
text: "Checking the task.",
channel: "progress",
},
{
kind: "assistant",
ts: "2026-08-25T18:00:02.000Z",
text: "The task is ready.",
channel: "final",
},
]);
const run = {
id: "native-run",
runtimeMode: "native" as const,
status: "running" as const,
invocationSource: "issue" as const,
triggerDetail: null,
startedAt: "2026-08-25T18:00:00.000Z",
finishedAt: null,
createdAt: "2026-08-25T18:00:00.000Z",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
};
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="in_progress"
activeRun={run}
/>,
);
expect(container.querySelector('[data-testid="task-chat-runner-turn"]')).not.toBeNull();
expect(container.textContent).toContain("Checking the task.");
expect(container.textContent).toContain("The task is ready.");
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="in_progress"
activeRun={{ ...run, runtimeMode: "legacy" }}
/>,
);
expect(container.querySelector('[data-testid="task-chat-runner-turn"]')).toBeNull();
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="in_progress"
activeRun={{ ...run, adapterType: "codex_local" }}
/>,
);
expect(container.querySelector('[data-testid="task-chat-runner-turn"]')).toBeNull();
});
it("keeps legacy channel-less native messages readable across settlement", () => {
nativeTranscriptState.transcriptByRun.set("native-run", [
{
kind: "assistant",
ts: "2026-08-25T18:00:01.000Z",
text: "Persisted before message channels existed.",
channel: "unknown",
},
]);
const run = {
id: "native-run",
runtimeMode: "native" as const,
status: "running" as const,
invocationSource: "issue" as const,
triggerDetail: null,
startedAt: "2026-08-25T18:00:00.000Z",
finishedAt: null,
createdAt: "2026-08-25T18:00:00.000Z",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
};
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="in_progress"
activeRun={run}
/>,
);
expect(container.querySelector('[data-testid="task-chat-progress-update"]')?.textContent)
.toContain("Persisted before message channels existed.");
expect(container.querySelector('[data-testid="task-chat-final-response"]')).toBeNull();
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="done"
activeRun={{ ...run, status: "succeeded", finishedAt: "2026-08-25T18:00:02.000Z" }}
/>,
);
expect(container.querySelector('[data-testid="task-chat-progress-update"]')).toBeNull();
expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent)
.toContain("Persisted before message channels existed.");
});
it("recomputes a runner turn when only the message channel changes", () => {
const run = {
id: "native-run",
runtimeMode: "native" as const,
status: "running" as const,
invocationSource: "issue" as const,
triggerDetail: null,
startedAt: "2026-08-25T18:00:00.000Z",
finishedAt: null,
createdAt: "2026-08-25T18:00:00.000Z",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
};
const renderRun = () => render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="in_progress"
activeRun={run}
/>,
);
nativeTranscriptState.transcriptByRun.set("native-run", [{
kind: "assistant",
ts: "2026-08-25T18:00:01.000Z",
text: "Same text.",
channel: "progress",
}]);
renderRun();
expect(container.querySelector('[data-testid="task-chat-progress-update"]')?.textContent)
.toContain("Same text.");
expect(container.querySelector('[data-testid="task-chat-final-response"]')).toBeNull();
nativeTranscriptState.transcriptByRun.set("native-run", [{
kind: "assistant",
ts: "2026-08-25T18:00:01.000Z",
text: "Same text.",
channel: "final",
}]);
renderRun();
expect(container.querySelector('[data-testid="task-chat-progress-update"]')).toBeNull();
expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent)
.toContain("Same text.");
});
it("recomputes a runner turn when only usage totals change", () => {
const run = {
id: "native-run",
runtimeMode: "native" as const,
status: "running" as const,
invocationSource: "issue" as const,
triggerDetail: null,
startedAt: "2026-08-25T18:00:00.000Z",
finishedAt: null,
createdAt: "2026-08-25T18:00:00.000Z",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
};
const usageEntry = (inputTokens: number) => ({
kind: "result" as const,
ts: "2026-08-25T18:00:01.000Z",
text: "",
inputTokens,
outputTokens: 5,
cachedTokens: 0,
costUsd: 0,
subtype: "paperclip_runner_usage",
isError: false,
errors: [],
});
const renderRun = () => render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="in_progress"
activeRun={run}
/>,
);
const revealUsage = () => {
const summary = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
);
expect(summary).not.toBeNull();
if (summary?.getAttribute("aria-expanded") !== "true") {
flushSync(() => summary!.click());
}
};
nativeTranscriptState.transcriptByRun.set("native-run", [usageEntry(10)]);
renderRun();
revealUsage();
expect(container.textContent).toContain("↑10");
nativeTranscriptState.transcriptByRun.set("native-run", [usageEntry(20)]);
renderRun();
revealUsage();
expect(container.textContent).toContain("↑20");
expect(container.textContent).not.toContain("↑10");
});
});
describe("TaskChatThread composer alignment", () => {

View File

@ -6,6 +6,7 @@ import {
} from "@/components/transcript/useLiveRunTranscripts";
import { useNativeRunTranscripts } from "@/components/transcript/useNativeRunTranscripts";
import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail";
import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
import {
assembleThreadItems,
@ -514,11 +515,25 @@ export function TaskChatThread(props: TaskChatThreadProps) {
const tailRunId = liveRun ? liveRun.id : showSettlingTail ? settlingRun!.id : null;
const tailStreaming = Boolean(liveRun);
const tailEntries = tailRunId ? (transcriptByRun.get(tailRunId) ?? []) : [];
const tailContentKey = tailEntries.reduce((total, entry) => {
if ("text" in entry) return total + entry.text.length;
if ("content" in entry) return total + entry.content.length;
return total + entry.kind.length;
}, tailEntries.length);
const tailRunSource = tailRunId
? runs.find((run) => run.id === tailRunId)
: undefined;
const paperclipRunnerTail =
tailRunSource?.runtimeMode === "native" &&
tailRunSource.adapterType === "paperclip_runner";
const tailContentKey = tailEntries.reduce((key, entry) => {
const textIdentity = "text" in entry ? entry.text : "";
const contentIdentity = "content" in entry ? entry.content : "";
const channelIdentity = "channel" in entry ? entry.channel ?? "" : "";
const lifecycleIdentity = "lifecycle" in entry ? entry.lifecycle ?? "" : "";
const statusIdentity = "isError" in entry
? entry.isError ? "error" : "ok"
: "";
const usageIdentity = entry.kind === "result"
? `${entry.subtype}:${entry.inputTokens}:${entry.outputTokens}:${entry.cachedTokens}:${entry.costUsd}`
: "";
return `${key}|${entry.kind}:${channelIdentity}:${lifecycleIdentity}:${statusIdentity}:${usageIdentity}:${textIdentity}:${contentIdentity}`;
}, String(tailEntries.length));
const blockerContentKey = blockerLinks
? `${blockerLinks.directBlocker.id}:${blockerLinks.ultimateBlocker?.id ?? ""}`
: liveWorkLinks
@ -713,26 +728,38 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<>
{tailRunId ? (
<div data-testid="task-chat-live-transcript">
<TaskChatLiveRunPill
status={tailStatus}
startedAtMs={tailStartedAtMs}
finishedAtMs={tailFinishedAtMs}
toolSummary={tailToolSummary}
/>
<TaskChatLiveTail
items={tailItems}
emptyMessage={
tailStatus === "queued"
? "Waiting to start..."
: // Before the first transcript token, surface the run's
// live runtime status (sandbox preparation phases like
// "Syncing workspace to environment" emitted via
// onRuntimeProgress) instead of an opaque wait message.
(liveRun && liveRun.id === tailRunId
? liveRun.currentStatusMessage
: null) || "Waiting for transcript..."
}
/>
{paperclipRunnerTail ? (
<TaskChatRunnerTurn
items={tailItems}
status={tailStatus}
startedAtMs={tailStartedAtMs}
finishedAtMs={tailFinishedAtMs}
toolSummary={tailToolSummary}
/>
) : (
<>
<TaskChatLiveRunPill
status={tailStatus}
startedAtMs={tailStartedAtMs}
finishedAtMs={tailFinishedAtMs}
toolSummary={tailToolSummary}
/>
<TaskChatLiveTail
items={tailItems}
emptyMessage={
tailStatus === "queued"
? "Waiting to start..."
: // Before the first transcript token, surface the run's
// live runtime status (sandbox preparation phases like
// "Syncing workspace to environment" emitted via
// onRuntimeProgress) instead of an opaque wait message.
(liveRun && liveRun.id === tailRunId
? liveRun.currentStatusMessage
: null) || "Waiting for transcript..."
}
/>
</>
)}
</div>
) : null}
{bottomBlockerLinks}

View File

@ -0,0 +1,140 @@
import { Brain } from "lucide-react";
import { MarkdownBody } from "@/components/MarkdownBody";
import { cn } from "@/lib/utils";
import type {
TaskChatItem,
TaskChatMessageItem,
TaskChatThinkingItem,
TaskChatToolItem,
} from "./task-chat-model";
import { TaskChatLiveRunPill } from "./TaskChatLiveRunPill";
import { TaskChatLiveTail } from "./TaskChatLiveTail";
import { isTerminalRunStatus } from "./transcript-adapter";
import { toolTaxonomy } from "./tool-taxonomy";
function lastOf<T extends TaskChatItem>(
items: readonly TaskChatItem[],
predicate: (item: TaskChatItem) => item is T,
): T | undefined {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item && predicate(item)) return item;
}
return undefined;
}
function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) {
const activity = lastOf<TaskChatThinkingItem | TaskChatToolItem>(
items,
(item): item is TaskChatThinkingItem | TaskChatToolItem =>
item.kind === "thinking" || item.kind === "tool",
);
if (!activity || activity.kind === "thinking") {
return (
<div
className="flex items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground"
data-testid="task-chat-current-activity"
>
<Brain className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="shimmer-text shimmer-text-muted">Thinking</span>
</div>
);
}
const taxonomy = toolTaxonomy(activity.rawName ?? activity.name);
const Icon = taxonomy.icon;
const active = activity.status === "pending" || activity.status === "in_progress";
return (
<div
className="flex min-w-0 items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground"
data-testid="task-chat-current-activity"
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className={cn("shrink-0", active && "shimmer-text shimmer-text-muted")}>
{taxonomy.verbLabel}
</span>
{activity.target ? (
<span className="min-w-0 truncate font-mono text-xs">{activity.target}</span>
) : null}
</div>
);
}
/**
* Runner-only live turn. Its compact parent row and final response are driven
* by persisted runtime facts; direct adapters continue through TaskChatLiveTail.
*/
export function TaskChatRunnerTurn({
items,
status,
startedAtMs,
finishedAtMs,
toolSummary,
}: {
items: readonly TaskChatItem[];
status: string;
startedAtMs: number | null;
finishedAtMs?: number | null;
toolSummary: string | null;
}) {
const terminal = isTerminalRunStatus(status);
const progress = lastOf<TaskChatMessageItem>(
items,
(item): item is TaskChatMessageItem =>
item.kind === "message" &&
(item.channel === "progress" || (!terminal && item.channel === "unknown")),
);
const final = lastOf<TaskChatMessageItem>(
items,
(item): item is TaskChatMessageItem =>
item.kind === "message" &&
(item.channel === "final" || (terminal && item.channel === "unknown")),
);
const activityItems = items.filter((item) => item.kind !== "message");
if (status === "queued") {
return (
<div className="py-1" data-testid="task-chat-runner-turn" data-phase="startup">
<CurrentActivity items={items} />
</div>
);
}
return (
<div className="flex min-w-0 flex-col" data-testid="task-chat-runner-turn">
<TaskChatLiveRunPill
status={status}
startedAtMs={startedAtMs}
finishedAtMs={finishedAtMs}
toolSummary={toolSummary}
/>
<div className="flex flex-col gap-2 py-2">
<TaskChatLiveTail items={activityItems} />
</div>
{progress || (!final && !terminal) ? (
<div className="flex min-w-0 flex-col py-1">
{progress ? (
<div
className="tc-enter-cot-line min-w-0 px-1 py-1.5 text-sm text-foreground/90"
data-testid="task-chat-progress-update"
>
<MarkdownBody softBreaks linkIssueReferences>
{progress.text}
</MarkdownBody>
</div>
) : null}
{!final && !terminal ? <CurrentActivity items={items} /> : null}
</div>
) : null}
{final ? (
<div
className="tc-enter-bubble px-1 py-2 text-sm text-foreground"
data-testid="task-chat-final-response"
>
<MarkdownBody softBreaks linkIssueReferences>
{final.text}
</MarkdownBody>
</div>
) : null}
</div>
);
}

View File

@ -65,6 +65,8 @@ export interface TaskChatMessageItem {
author: TaskChatAuthorKind;
authorName?: string;
text: string;
/** Runner-authored output channel. Legacy adapters leave this unset. */
channel?: "progress" | "final" | "unknown";
timestamp?: string;
/** Show a streaming cursor and suppress collapse while true. */
streaming?: boolean;
@ -121,6 +123,8 @@ export interface TaskChatThinkingItem {
collapsed?: boolean;
/** Human-readable elapsed label for the collapsed header. */
summaryLabel?: string;
/** Provider-emitted reasoning surface; never synthesized by the UI. */
channel?: "summary" | "detail" | "unknown";
}
/** A tool invocation row (ACP tool_call / tool_call_update). */

View File

@ -163,6 +163,38 @@ describe("transcriptToTaskChatItems tool_call updates", () => {
});
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("renders runner usage without inventing a context-window size", () => {
const items = transcriptToTaskChatItems([{
kind: "result",
@ -477,6 +509,37 @@ describe("settledRunChildren (PAP-361)", () => {
expect(phase.kind === "activity_phase" && phase.items.map((item) => item.kind)).toEqual(["tool", "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("matches the folded summary's tool count exactly (row-count parity)", () => {
const children = settledRunChildren(parsed);
const summary = buildTurnSummary(transcript);

View File

@ -170,18 +170,23 @@ export function transcriptToTaskChatItems(
const thinkingStartTs = new Map<number, string>();
let lastToolIndex = -1;
let thinkingIndex = -1;
let thinkingChannel: "summary" | "detail" | "unknown" | undefined;
let messageIndex = -1;
let messageChannel: "progress" | "final" | "unknown" | undefined;
const resetInline = () => {
thinkingIndex = -1;
thinkingChannel = undefined;
messageIndex = -1;
messageChannel = undefined;
};
for (const [i, entry] of entries.entries()) {
switch (entry.kind) {
case "thinking": {
if (!entry.text) break;
if (thinkingIndex >= 0) {
const channel = entry.channel;
if (thinkingIndex >= 0 && thinkingChannel === channel) {
const it = items[thinkingIndex];
if (it.kind === "thinking") {
it.lines.push(...entry.text.split("\n"));
@ -198,16 +203,20 @@ export function transcriptToTaskChatItems(
// Settled history folds its thinking behind the header (v7);
// the in-flight run streams it expanded.
collapsed: !running,
channel,
});
thinkingIndex = items.length - 1;
thinkingChannel = channel;
thinkingStartTs.set(thinkingIndex, entry.ts);
messageIndex = -1;
messageChannel = undefined;
}
break;
}
case "assistant": {
if (!entry.text) break;
if (messageIndex >= 0) {
const channel = entry.channel;
if (messageIndex >= 0 && messageChannel === channel) {
const it = items[messageIndex];
if (it.kind === "message") it.text += entry.text;
} else {
@ -218,14 +227,17 @@ export function transcriptToTaskChatItems(
author: "agent",
authorName: agentName,
text: entry.text,
channel,
streaming: running,
// Everything the agent says inside a run turn is self-talk until it
// lands as the posted comment — live and history tag it alike.
interstitial: true,
interstitial: channel !== "final",
atMs: Number.isFinite(atMs) ? atMs : undefined,
});
messageIndex = items.length - 1;
messageChannel = channel;
thinkingIndex = -1;
thinkingChannel = undefined;
}
break;
}
@ -412,9 +424,13 @@ export function buildActivityPhases(
const lastVisible = [...parsed].reverse().find((item) => item.kind !== "thinking");
for (const item of parsed) {
if (item.kind === "message") {
// A settled transcript's trailing assistant text is the posted reply.
// Live/settle-gap tails keep it visible until that canonical reply lands.
if (!running && item === lastVisible) continue;
// Explicit final-channel replies remain canonical even when a later
// usage row makes them non-tail. Channel-less legacy transcripts retain
// the last-visible fallback until the posted reply lands.
const explicitFinal = item.channel === "final" || item.interstitial === false;
const legacyTrailingReply =
(item.channel == null || item.channel === "unknown") && item === lastVisible;
if (!running && (explicitFinal || legacyTrailingReply)) continue;
current = {
id: `${item.id}:phase`,
kind: "activity_phase",

View File

@ -42,27 +42,65 @@ function event(
};
}
function itemEvent(
seq: number,
eventType: "item.started" | "item.delta" | "item.completed",
itemId: string,
payload: Record<string, unknown>,
): HeartbeatRunEvent {
const value = event(seq, eventType, payload);
(value.payload!.prpEvent as Record<string, unknown>).itemId = itemId;
return value;
}
function runResult(summary: string): Record<string, unknown> {
return {
schema: "paperclip.run_result.v1",
reportedWorkDisposition: "done",
summary,
completionClaim: {
contractRevision: "test-v1",
objectiveSatisfied: true,
criteria: [],
remainingWork: [],
},
evidence: [],
verification: [],
attentionRequests: [],
artifacts: [],
};
}
describe("nativeRunEventsToTranscript", () => {
it("projects provider-neutral messages, tools, usage, and the final reply", () => {
const transcript = nativeRunEventsToTranscript([
event(6, "run.result.proposed", { summary: "Done safely." }),
event(6, "run.result.proposed", runResult("Done safely.")),
event(1, "item.delta", { itemId: "message-1", kind: "agentMessage", text: "Done " }),
event(2, "item.delta", { itemId: "message-1", kind: "agentMessage", text: "safely." }),
event(3, "item.completed", { itemId: "message-1", kind: "agentMessage", text: "Done safely." }),
event(4, "tool.execution.started", {
schema: "paperclip.tool.execution.v1",
executionId: "exec-1",
transport: "process",
operation: "execute",
name: "pnpm test",
status: "running",
output: null,
outputBytes: 0,
outputTruncated: false,
outputDigest: null,
}),
event(5, "tool.execution.completed", {
schema: "paperclip.tool.execution.v1",
executionId: "exec-1",
transport: "process",
operation: "execute",
name: "pnpm test",
status: "completed",
output: "all green",
outputBytes: 9,
outputTruncated: false,
outputDigest: null,
}),
event(7, "usage.reported", {
runDeltaAvailable: true,
@ -110,6 +148,49 @@ describe("nativeRunEventsToTranscript", () => {
]);
});
it("streams canonical kind-less deltas using item identity from item.started", () => {
expect(nativeRunEventsToTranscript([
itemEvent(1, "item.started", "message-1", {
kind: "assistant_message",
channel: "progress",
text: "",
}),
itemEvent(2, "item.delta", "message-1", { text: "Still " }),
itemEvent(3, "item.delta", "message-1", { text: "working" }),
])).toEqual([
expect.objectContaining({
kind: "assistant",
text: "Still ",
delta: true,
channel: "progress",
}),
expect.objectContaining({
kind: "assistant",
text: "working",
delta: true,
channel: "progress",
}),
]);
});
it("reads the canonical PRP v1 assistant_message kind as a channel-less final reply", () => {
expect(nativeRunEventsToTranscript([
event(1, "item.completed", {
kind: "assistant_message",
text: "Canonical persisted reply.",
}),
event(2, "run.result.proposed", runResult(
"Structured fallback must not replace the reply.",
)),
])).toEqual([
expect.objectContaining({
kind: "assistant",
text: "Canonical persisted reply.",
channel: "unknown",
}),
]);
});
it("sums run deltas without leaking session-cumulative usage", () => {
const transcript = nativeRunEventsToTranscript([
event(1, "usage.reported", {
@ -228,12 +309,176 @@ describe("nativeRunEventsToTranscript", () => {
it("uses the structured run summary when no agent message was emitted", () => {
expect(nativeRunEventsToTranscript([
event(1, "run.result.proposed", { summary: "Recovered final reply." }),
event(1, "run.result.proposed", runResult("Recovered final reply.")),
])).toEqual([
expect.objectContaining({ kind: "assistant", text: "Recovered final reply." }),
expect.objectContaining({
kind: "assistant",
text: "Recovered final reply.",
channel: "final",
}),
]);
});
it("prefers an explicit final item completed after the result proposal", () => {
expect(nativeRunEventsToTranscript([
event(1, "run.result.proposed", runResult("Structured fallback.")),
itemEvent(2, "item.completed", "message-final", {
kind: "assistant_message",
channel: "final",
text: "The complete final reply.",
}),
])).toEqual([
expect.objectContaining({
kind: "assistant",
text: "The complete final reply.",
channel: "final",
}),
]);
});
it("does not append a fallback after a channel-less final delta", () => {
expect(nativeRunEventsToTranscript([
itemEvent(1, "item.started", "message-final", {
kind: "assistant_message",
text: "",
}),
itemEvent(2, "item.delta", "message-final", {
text: "Streamed final reply.",
}),
event(3, "run.result.proposed", runResult("Structured fallback.")),
])).toEqual([
expect.objectContaining({
kind: "assistant",
text: "Streamed final reply.",
delta: true,
channel: "unknown",
}),
]);
});
it("keeps progress separate from the final runner response", () => {
expect(nativeRunEventsToTranscript([
event(1, "item.completed", {
itemId: "progress-1",
kind: "agentMessage",
channel: "progress",
text: "Checking the implementation.",
}),
event(2, "run.result.accepted", {
result: runResult("The implementation is ready."),
}),
])).toEqual([
expect.objectContaining({
kind: "assistant",
text: "Checking the implementation.",
channel: "progress",
}),
expect.objectContaining({
kind: "assistant",
text: "The implementation is ready.",
channel: "final",
}),
]);
});
it("projects provider-neutral activity without exposing provider envelopes", () => {
expect(nativeRunEventsToTranscript([
event(1, "research.started", {
schema: "paperclip.research.v1",
researchId: "research-1",
query: "current behavior",
status: "running",
}),
event(2, "research.completed", {
schema: "paperclip.research.v1",
researchId: "research-1",
query: "current behavior",
status: "completed",
}),
])).toEqual([
expect.objectContaining({
kind: "tool_call",
name: "research",
toolUseId: "research:research-1",
}),
expect.objectContaining({
kind: "tool_result",
toolUseId: "research:research-1",
content: "current behavior",
}),
]);
});
it.each(["running", "pending", "in_progress"])(
"keeps an explicitly %s activity open even when its event suffix looks terminal",
(status) => {
expect(nativeRunEventsToTranscript([
event(1, "artifact.generated", {
schema: "paperclip.artifact.generated.v1",
artifactId: "artifact-1",
status,
reference: "artifacts/preview.png",
}),
])).toEqual([
expect.objectContaining({
kind: "tool_call",
toolUseId: "artifact:artifact-1",
}),
]);
},
);
it("fails closed for unsupported versions, prefix lookalikes, and mismatched payload schemas", () => {
const unsupportedVersion = event(1, "model.verification.updated", {
schema: "paperclip.model.verification.v1",
verificationId: "verification-1",
status: "completed",
summary: "must not render",
});
(unsupportedVersion.payload!.prpEvent as Record<string, unknown>).schemaVersion = 2;
expect(nativeRunEventsToTranscript([
unsupportedVersion,
event(2, "model.provider_message.recorded", {
schema: "paperclip.model.provider_message.v1",
routeId: "route-1",
message: "provider envelope must not render",
}),
event(3, "model.verification.updated", {
schema: "paperclip.provider.native.v1",
verificationId: "verification-2",
status: "completed",
summary: "wrong payload schema must not render",
}),
])).toEqual([]);
});
it("fails closed for mismatched tool execution and run result schemas", () => {
expect(nativeRunEventsToTranscript([
event(1, "tool.execution.started", {
schema: "paperclip.provider.native.v1",
executionId: "exec-1",
transport: "process",
operation: "execute",
status: "running",
}),
event(2, "run.result.proposed", {
schema: "paperclip.provider.native.v1",
summary: "malformed proposal must not render",
}),
event(3, "run.result.accepted", {
result: {
schema: "paperclip.provider.native.v1",
summary: "malformed accepted result must not render",
},
}),
event(4, "run.result.accepted", {
schema: "paperclip.run_result.v1",
summary: "accepted wrappers must not masquerade as results",
}),
])).toEqual([]);
});
it("fails closed for malformed, mismatched, and unknown event envelopes", () => {
const mismatched = event(1, "item.delta", {
itemId: "message-1",
@ -247,7 +492,7 @@ describe("nativeRunEventsToTranscript", () => {
expect(nativeRunEventsToTranscript([
mismatched,
malformed,
event(3, "plan.updated", { explanation: "not a transcript row" }),
event(3, "extension.unknown", { explanation: "not a transcript row" }),
])).toEqual([]);
});
});

View File

@ -15,6 +15,269 @@ function finiteNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
}
function normalizedItem(payload: Record<string, unknown>): Record<string, unknown> {
return record(payload.item) ?? payload;
}
function normalizedItemKind(payload: Record<string, unknown>): string {
const item = normalizedItem(payload);
return (text(payload.kind) ?? text(item.kind) ?? text(item.type) ?? "")
.replaceAll("_", "")
.toLowerCase();
}
function isAssistantItemKind(kind: string): boolean {
return kind === "agentmessage" || kind === "assistantmessage";
}
function normalizedItemId(
envelope: Record<string, unknown>,
payload: Record<string, unknown>,
): string | null {
const item = normalizedItem(payload);
return text(envelope.itemId) ?? text(payload.itemId) ?? text(item.id);
}
function normalizedItemText(payload: Record<string, unknown>): string | null {
const item = normalizedItem(payload);
return text(payload.text) ?? text(item.text);
}
function assistantChannel(
payload: Record<string, unknown>,
fallback: "progress" | "final" | "unknown" = "unknown",
): "progress" | "final" | "unknown" {
const item = normalizedItem(payload);
const value = text(payload.channel) ?? text(item.channel);
if (value === "progress" || value === "final" || value === "unknown") return value;
return fallback;
}
function reasoningChannel(
payload: Record<string, unknown>,
fallback: "summary" | "detail" | "unknown" = "unknown",
): "summary" | "detail" | "unknown" {
const item = normalizedItem(payload);
const value = text(payload.channel) ?? text(item.channel);
if (value === "summary" || value === "detail" || value === "unknown") return value;
return fallback;
}
interface ItemIdentity {
kind: string;
assistantChannel: "progress" | "final" | "unknown";
reasoningChannel: "summary" | "detail" | "unknown";
}
function resolveItemIdentity(
payload: Record<string, unknown>,
previous?: ItemIdentity,
): ItemIdentity {
return {
kind: normalizedItemKind(payload) || previous?.kind || "",
assistantChannel: assistantChannel(payload, previous?.assistantChannel),
reasoningChannel: reasoningChannel(payload, previous?.reasoningChannel),
};
}
function isItemIdentityEvent(eventType: string): boolean {
return eventType === "item.started"
|| eventType === "item.delta"
|| eventType === "item.completed";
}
const TOOL_EXECUTION_SCHEMA = "paperclip.tool.execution.v1";
const RUN_RESULT_SCHEMA = "paperclip.run_result.v1";
const PROVIDER_ACTIVITY_PRESENTATIONS = {
"plan.updated": {
schema: "paperclip.plan.updated.v1",
idKey: "planId",
name: "plan",
summaryKeys: ["explanation"],
},
"research.started": {
schema: "paperclip.research.v1",
idKey: "researchId",
name: "research",
summaryKeys: ["query", "pattern", "url"],
},
"research.progressed": {
schema: "paperclip.research.v1",
idKey: "researchId",
name: "research",
summaryKeys: ["query", "pattern", "url"],
},
"research.completed": {
schema: "paperclip.research.v1",
idKey: "researchId",
name: "research",
summaryKeys: ["query", "pattern", "url"],
},
"delegation.started": {
schema: "paperclip.delegation.v1",
idKey: "delegationId",
name: "delegation",
summaryKeys: ["action"],
},
"delegation.updated": {
schema: "paperclip.delegation.v1",
idKey: "delegationId",
name: "delegation",
summaryKeys: ["action"],
},
"delegation.completed": {
schema: "paperclip.delegation.v1",
idKey: "delegationId",
name: "delegation",
summaryKeys: ["action"],
},
"model.route.changed": {
schema: "paperclip.model.route_changed.v1",
idKey: "routeId",
name: "model",
summaryKeys: ["reason", "effectiveModel"],
},
"model.verification.updated": {
schema: "paperclip.model.verification.v1",
idKey: "verificationId",
name: "model",
summaryKeys: ["summary"],
},
"context.compacted": {
schema: "paperclip.context.compacted.v1",
idKey: "compactionId",
name: "context",
summaryKeys: ["reason"],
},
"artifact.viewed": {
schema: "paperclip.artifact.viewed.v1",
idKey: "artifactId",
name: "artifact",
summaryKeys: ["title", "reference"],
},
"artifact.generated": {
schema: "paperclip.artifact.generated.v1",
idKey: "artifactId",
name: "artifact",
summaryKeys: ["failure", "reference"],
},
"review.mode.changed": {
schema: "paperclip.review.mode_changed.v1",
idKey: "reviewId",
name: "review",
summaryKeys: ["scope", "state"],
},
"hook.started": {
schema: "paperclip.hook.v1",
idKey: "hookId",
name: "hook",
summaryKeys: ["summary", "event"],
},
"hook.completed": {
schema: "paperclip.hook.v1",
idKey: "hookId",
name: "hook",
summaryKeys: ["summary", "event"],
},
"memory.citation.referenced": {
schema: "paperclip.memory.citation.v1",
idKey: "citationId",
name: "memory",
summaryKeys: ["label"],
},
"safety.review.started": {
schema: "paperclip.safety.review.v1",
idKey: "reviewId",
name: "safety",
summaryKeys: ["summary", "decision"],
},
"safety.review.completed": {
schema: "paperclip.safety.review.v1",
idKey: "reviewId",
name: "safety",
summaryKeys: ["summary", "decision"],
},
"terminal.input.sent": {
schema: "paperclip.terminal.input_sent.v1",
idKey: "executionId",
name: "terminal",
summaryKeys: ["inputClass"],
},
"wait.started": {
schema: "paperclip.wait.v1",
idKey: "waitId",
name: "wait",
summaryKeys: ["reason"],
},
"wait.completed": {
schema: "paperclip.wait.v1",
idKey: "waitId",
name: "wait",
summaryKeys: ["reason"],
},
"provider.notice.recorded": {
schema: "paperclip.provider.notice.v1",
idKey: "noticeId",
name: "Provider notice",
summaryKeys: ["summary"],
},
} as const;
type ProviderActivityEventType = keyof typeof PROVIDER_ACTIVITY_PRESENTATIONS;
const NONTERMINAL_PROVIDER_ACTIVITY_STATUSES = new Set([
"running",
"pending",
"in_progress",
"waiting",
]);
const TERMINAL_PROVIDER_ACTIVITY_STATUSES = new Set([
"completed",
"failed",
"cancelled",
"interrupted",
"closed",
"denied",
]);
function providerActivityPresentation(
event: HeartbeatRunEvent,
payload: Record<string, unknown>,
): { id: string; name: string; summary: string; terminal: boolean; failed: boolean } | null {
if (!Object.prototype.hasOwnProperty.call(PROVIDER_ACTIVITY_PRESENTATIONS, event.eventType)) {
return null;
}
const presentation = PROVIDER_ACTIVITY_PRESENTATIONS[
event.eventType as ProviderActivityEventType
];
if (!presentation || payload.schema !== presentation.schema) return null;
const identity = text(payload[presentation.idKey]);
if (!identity) return null;
const summary = presentation.summaryKeys
.map((key) => text(payload[key]))
.find((value): value is string => value !== null)
?? event.eventType;
const status = text(payload.status);
const failed = status === "failed" || status === "denied" || payload.severity === "error";
const terminal = failed
? true
: NONTERMINAL_PROVIDER_ACTIVITY_STATUSES.has(status ?? "")
? false
: TERMINAL_PROVIDER_ACTIVITY_STATUSES.has(status ?? "")
|| event.eventType.endsWith(".completed")
|| event.eventType.endsWith(".failed")
|| (!event.eventType.endsWith(".started") && !event.eventType.endsWith(".progressed"));
return {
id: `${event.eventType.split(".")[0]}:${identity}`,
name: presentation.name,
summary,
terminal,
failed,
};
}
function timestamp(event: HeartbeatRunEvent, envelope: Record<string, unknown>): string {
const emittedAt = text(envelope.emittedAt);
if (emittedAt) return emittedAt;
@ -50,7 +313,7 @@ function toolPresentation(payload: Record<string, unknown>): { name: string; inp
export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]): TranscriptEntry[] {
const entries: TranscriptEntry[] = [];
const startedToolIds = new Set<string>();
let hasAssistantMessage = false;
let hasFinalAssistantMessage = false;
let usageSummary: {
ts: string;
inputTokens: number;
@ -65,53 +328,138 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
cachedTokens: number;
costUsd: number;
} | null = null;
let runResultFallback: { ts: string; text: string } | null = null;
const orderedEvents = [...events].sort((a, b) => a.seq - b.seq);
const completedAgentMessageIds = new Set<string>();
const completedReasoningIds = new Set<string>();
const completionItemIdentityById = new Map<string, ItemIdentity>();
for (const event of orderedEvents) {
if (event.eventType !== "item.completed") continue;
if (!isItemIdentityEvent(event.eventType)) continue;
const envelope = record(event.payload?.prpEvent);
if (
!envelope
|| envelope.schema !== "paperclip.prp.event.v1"
|| envelope.schemaVersion !== 1
|| envelope.runId !== event.runId
|| envelope.eventType !== event.eventType
) continue;
const payload = record(envelope?.payload);
const itemId = text(payload?.itemId);
if (payload?.kind === "agentMessage" && itemId && text(payload.text)) {
if (!payload) continue;
const itemId = normalizedItemId(envelope, payload);
if (!itemId) continue;
const identity = resolveItemIdentity(
payload,
completionItemIdentityById.get(itemId),
);
if (identity.kind) completionItemIdentityById.set(itemId, identity);
if (event.eventType !== "item.completed") continue;
const kind = identity.kind;
if (isAssistantItemKind(kind) && itemId && normalizedItemText(payload)) {
completedAgentMessageIds.add(itemId);
}
if (kind === "reasoning" && itemId && normalizedItemText(payload)) {
completedReasoningIds.add(itemId);
}
}
const itemIdentityById = new Map<string, ItemIdentity>();
for (const event of orderedEvents) {
const envelope = record(event.payload?.prpEvent);
if (!envelope || envelope.schema !== "paperclip.prp.event.v1") continue;
if (
!envelope
|| envelope.schema !== "paperclip.prp.event.v1"
|| envelope.schemaVersion !== 1
) continue;
if (envelope.runId !== event.runId || envelope.eventType !== event.eventType) continue;
const payload = record(envelope.payload);
if (!payload) continue;
const ts = timestamp(event, envelope);
if (event.eventType === "item.delta" && payload.kind === "agentMessage") {
const value = text(payload.text);
const itemId = text(payload.itemId);
const itemId = normalizedItemId(envelope, payload);
const itemIdentity = resolveItemIdentity(
payload,
itemId ? itemIdentityById.get(itemId) : undefined,
);
if (itemId && itemIdentity.kind && isItemIdentityEvent(event.eventType)) {
itemIdentityById.set(itemId, itemIdentity);
}
const itemKind = itemIdentity.kind;
if (event.eventType === "item.delta" && isAssistantItemKind(itemKind)) {
const value = normalizedItemText(payload);
if (!value || !itemId) continue;
// Once the loss-resistant completion is present, prefer its full text.
// Before that point the deltas still provide the live streaming view.
if (completedAgentMessageIds.has(itemId)) continue;
hasAssistantMessage = true;
entries.push({ kind: "assistant", ts, text: value, delta: true });
const channel = itemIdentity.assistantChannel;
if (channel !== "progress") hasFinalAssistantMessage = true;
entries.push({ kind: "assistant", ts, text: value, delta: true, channel });
continue;
}
if (event.eventType === "item.completed" && payload.kind === "agentMessage") {
const value = text(payload.text);
if (event.eventType === "item.completed" && isAssistantItemKind(itemKind)) {
const value = normalizedItemText(payload);
if (!value) continue;
hasAssistantMessage = true;
entries.push({ kind: "assistant", ts, text: value });
const channel = itemIdentity.assistantChannel;
if (channel !== "progress") hasFinalAssistantMessage = true;
entries.push({ kind: "assistant", ts, text: value, channel });
continue;
}
if (event.eventType === "item.delta" && itemKind === "reasoning") {
const value = normalizedItemText(payload);
if (!value || !itemId || completedReasoningIds.has(itemId)) continue;
entries.push({
kind: "thinking",
ts,
text: value,
delta: true,
lifecycle: "started",
channel: itemIdentity.reasoningChannel,
});
continue;
}
if (event.eventType === "item.completed" && itemKind === "reasoning") {
const value = normalizedItemText(payload);
if (!value) continue;
entries.push({
kind: "thinking",
ts,
text: value,
lifecycle: "completed",
channel: itemIdentity.reasoningChannel,
});
continue;
}
const providerActivity = providerActivityPresentation(event, payload);
if (providerActivity) {
if (!startedToolIds.has(providerActivity.id)) {
startedToolIds.add(providerActivity.id);
entries.push({
kind: "tool_call",
ts,
name: providerActivity.name,
toolUseId: providerActivity.id,
input: { eventType: event.eventType, summary: providerActivity.summary },
});
}
if (providerActivity.terminal) {
entries.push({
kind: "tool_result",
ts,
toolUseId: providerActivity.id,
toolName: providerActivity.name,
content: providerActivity.summary,
isError: providerActivity.failed,
});
}
continue;
}
if (event.eventType === "tool.execution.started" || event.eventType === "tool.execution.completed") {
if (payload.schema !== TOOL_EXECUTION_SCHEMA) continue;
const executionId = text(payload.executionId);
if (!executionId) continue;
const presentation = toolPresentation(payload);
@ -178,19 +526,29 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
continue;
}
if (event.eventType === "run.result.proposed" && !hasAssistantMessage) {
const summary = text(payload.summary);
if (summary) {
hasAssistantMessage = true;
entries.push({ kind: "assistant", ts, text: summary });
}
if (
(event.eventType === "run.result.proposed" || event.eventType === "run.result.accepted")
&& !hasFinalAssistantMessage
) {
const result = event.eventType === "run.result.accepted" ? record(payload.result) : payload;
if (!result || result.schema !== RUN_RESULT_SCHEMA) continue;
const summary = text(result.summary);
if (summary && !runResultFallback) runResultFallback = { ts, text: summary };
continue;
}
if (event.eventType === "provider.notice.recorded" && payload.severity === "error") {
const summary = text(payload.summary);
if (summary) entries.push({ kind: "stderr", ts, text: summary });
}
}
// A structured result can be proposed before its originating final item is
// durably completed. Delay the fallback until every event has been examined
// so the explicit assistant reply wins regardless of source ordering.
if (!hasFinalAssistantMessage && runResultFallback) {
entries.push({
kind: "assistant",
ts: runResultFallback.ts,
text: runResultFallback.text,
channel: "final",
});
}
if (usageSummary) {