diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts index 70f9c5369f..008360edbf 100644 --- a/ui/src/adapters/transcript.ts +++ b/ui/src/adapters/transcript.ts @@ -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; diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index f420bea630..9bf7a13e97 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -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( + {}} + 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( + {}} + issueStatus="in_progress" + activeRun={{ ...run, runtimeMode: "legacy" }} + />, + ); + + expect(container.querySelector('[data-testid="task-chat-runner-turn"]')).toBeNull(); + + render( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + issueStatus="in_progress" + activeRun={run} + />, + ); + const revealUsage = () => { + const summary = container.querySelector( + '[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", () => { diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 00c1d898b5..86c82b65cd 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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 ? (
- - + {paperclipRunnerTail ? ( + + ) : ( + <> + + + + )}
) : null} {bottomBlockerLinks} diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx new file mode 100644 index 0000000000..357b2548a0 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -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( + 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( + items, + (item): item is TaskChatThinkingItem | TaskChatToolItem => + item.kind === "thinking" || item.kind === "tool", + ); + if (!activity || activity.kind === "thinking") { + return ( +
+ + Thinking +
+ ); + } + const taxonomy = toolTaxonomy(activity.rawName ?? activity.name); + const Icon = taxonomy.icon; + const active = activity.status === "pending" || activity.status === "in_progress"; + return ( +
+ + + {taxonomy.verbLabel} + + {activity.target ? ( + {activity.target} + ) : null} +
+ ); +} + +/** + * 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( + items, + (item): item is TaskChatMessageItem => + item.kind === "message" && + (item.channel === "progress" || (!terminal && item.channel === "unknown")), + ); + const final = lastOf( + 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 ( +
+ +
+ ); + } + + return ( +
+ +
+ +
+ {progress || (!final && !terminal) ? ( +
+ {progress ? ( +
+ + {progress.text} + +
+ ) : null} + {!final && !terminal ? : null} +
+ ) : null} + {final ? ( +
+ + {final.text} + +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 67efeac517..3ab4a96c6b 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -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). */ diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index d95cfb2fdd..c719b066b6 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -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); diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 0487253f6b..55af6aae53 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -170,18 +170,23 @@ export function transcriptToTaskChatItems( const thinkingStartTs = new Map(); 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", diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index 185831cf26..4fbc607f8e 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -42,27 +42,65 @@ function event( }; } +function itemEvent( + seq: number, + eventType: "item.started" | "item.delta" | "item.completed", + itemId: string, + payload: Record, +): HeartbeatRunEvent { + const value = event(seq, eventType, payload); + (value.payload!.prpEvent as Record).itemId = itemId; + return value; +} + +function runResult(summary: string): Record { + 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).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([]); }); }); diff --git a/ui/src/components/transcript/native-run-events.ts b/ui/src/components/transcript/native-run-events.ts index 3041228c85..1ae8cb20fa 100644 --- a/ui/src/components/transcript/native-run-events.ts +++ b/ui/src/components/transcript/native-run-events.ts @@ -15,6 +15,269 @@ function finiteNumber(value: unknown): number { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; } +function normalizedItem(payload: Record): Record { + return record(payload.item) ?? payload; +} + +function normalizedItemKind(payload: Record): 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, + payload: Record, +): string | null { + const item = normalizedItem(payload); + return text(envelope.itemId) ?? text(payload.itemId) ?? text(item.id); +} + +function normalizedItemText(payload: Record): string | null { + const item = normalizedItem(payload); + return text(payload.text) ?? text(item.text); +} + +function assistantChannel( + payload: Record, + 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, + 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, + 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, +): { 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 { const emittedAt = text(envelope.emittedAt); if (emittedAt) return emittedAt; @@ -50,7 +313,7 @@ function toolPresentation(payload: Record): { name: string; inp export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]): TranscriptEntry[] { const entries: TranscriptEntry[] = []; const startedToolIds = new Set(); - 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(); + const completedReasoningIds = new Set(); + const completionItemIdentityById = new Map(); 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(); 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) {