From 2fe9fdd5a265b72b7e5040b28bde9713fbf03706 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 21 Aug 2026 12:27:35 -0500 Subject: [PATCH] fix(runner): stream native activity into task chat Co-Authored-By: Paperclip --- packages/adapter-utils/src/types.ts | 2 +- .../crates/runner-core/src/durable/runner.rs | 44 +++ .../crates/runner-core/src/durable/state.rs | 27 +- .../src/live/runnerd-codex-transport.test.ts | 16 + .../src/live/runnerd-codex-transport.ts | 34 +- .../adapters/paperclip-runner/index.test.ts | 38 ++ ui/src/adapters/paperclip-runner/index.ts | 362 ++++++++++++++++-- ui/src/components/TaskChatThread.test.tsx | 3 +- .../task-chat/TaskChatActivityPhase.tsx | 11 +- .../task-chat/TaskChatLiveTail.test.tsx | 16 +- .../components/task-chat/TaskChatLiveTail.tsx | 18 +- .../components/task-chat/TaskChatThinking.tsx | 33 ++ .../task-chat/TaskChatThreadView.tsx | 7 +- .../components/task-chat/task-chat-model.ts | 2 +- .../task-chat/transcript-adapter.test.ts | 14 +- .../task-chat/transcript-adapter.ts | 88 ++++- ui/src/lib/issue-chat-messages.test.ts | 13 + ui/src/lib/issue-chat-messages.ts | 6 +- 18 files changed, 652 insertions(+), 82 deletions(-) create mode 100644 ui/src/components/task-chat/TaskChatThinking.tsx diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index a21b4e69af2..be317de5cbd 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -523,7 +523,7 @@ export type TranscriptEntry = | { kind: "thinking"; ts: string; text: string; delta?: boolean } | { kind: "user"; ts: string; text: string } | { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string; invocationId?: string; actionRequestId?: string } - | { kind: "tool_result"; ts: string; toolUseId: string; toolName?: string; content: string; isError: boolean } + | { kind: "tool_result"; ts: string; toolUseId: string; toolName?: string; content: string; isError: boolean; delta?: boolean } | { kind: "init"; ts: string; model: string; sessionId: string } | { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] } | { kind: "stderr"; ts: string; text: string } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs index 32e090bc0da..18347cdb1df 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs @@ -1119,6 +1119,50 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn coalesced_p2_preserves_every_ordered_provider_notification() { + let root = temporary_root("ordered-p2-coalesce"); + let config = config(&root); + let mut state = DurableRunnerState::new(&config); + let item_id = state.item_id.clone(); + for payload in [ + json!({ "method": "item/started", "params": { "item": { "id": "reasoning-1" } } }), + json!({ "method": "item/reasoning/summaryTextDelta", "params": { "delta": "Inspecting" } }), + json!({ "method": "item/completed", "params": { "item": { "id": "reasoning-1" } } }), + ] { + enqueue_event( + &mut state, + &config, + "provider.event", + 2, + payload, + Some(&item_id), + ) + .unwrap(); + } + + assert_eq!(state.outbox.len(), 1); + let payload = state.outbox[0] + .envelope + .pointer("/payload/payload") + .unwrap(); + assert_eq!(payload["coalescedCount"], json!(3)); + assert_eq!( + payload["events"] + .as_array() + .unwrap() + .iter() + .map(|event| event["method"].as_str().unwrap()) + .collect::>(), + vec![ + "item/started", + "item/reasoning/summaryTextDelta", + "item/completed", + ], + ); + let _ = fs::remove_dir_all(root); + } + #[test] fn oversized_coalesced_p2_retains_prior_event_and_never_exceeds_bounds() { let root = temporary_root("oversized-p2-coalesce"); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs index 4858db25a82..132ed7a516f 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs @@ -996,9 +996,34 @@ fn enqueue_event( .pointer("/payload/payload/coalescedCount") .and_then(Value::as_u64) .unwrap_or(1); + // Keep every provider notification in the compacted envelope. + // The old `latest` shape made durability cheap by silently + // discarding token, reasoning, tool-output, and diff deltas + // whenever runnerd produced them faster than the PRP authority + // acknowledged the previous event. One durable envelope may + // still batch adjacent P2 records, but the consumer can now + // replay the complete ordered batch. + let previous_payload = last + .envelope + .pointer("/payload/payload") + .cloned() + .unwrap_or(Value::Null); + let mut events = previous_payload + .get("events") + .and_then(Value::as_array) + .cloned() + .unwrap_or_else(|| { + previous_payload + .get("latest") + .cloned() + .or_else(|| (!previous_payload.is_null()).then_some(previous_payload)) + .into_iter() + .collect() + }); + events.push(sanitize_value(&payload)); let compacted = json!({ "coalescedCount": previous_count + 1, - "latest": sanitize_value(&payload), + "events": events, }); let mut replacement = last.clone(); replacement.envelope["payload"]["payload"] = compacted; diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 4f54a45cfc9..3ce5c355821 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -7,6 +7,7 @@ import { createCapabilityRunnerdCodexTransport, defaultCapabilityRunnerdBinary, unwrapRunnerdProviderNotification, + unwrapRunnerdProviderNotifications, } from "./runnerd-codex-transport.js"; const fakeCodex = resolve( @@ -27,6 +28,21 @@ it("unwraps a coalesced provider notification without losing its turn identity", }); }); +it("replays every provider notification from a durable coalesced batch", () => { + expect(unwrapRunnerdProviderNotifications({ + coalescedCount: 3, + events: [ + { method: "item/started", params: { item: { id: "reasoning-1" } } }, + { method: "item/reasoning/summaryTextDelta", params: { delta: "Checking the task" } }, + { method: "item/completed", params: { item: { id: "reasoning-1" } } }, + ], + })).toEqual([ + expect.objectContaining({ method: "item/started" }), + expect.objectContaining({ method: "item/reasoning/summaryTextDelta" }), + expect.objectContaining({ method: "item/completed" }), + ]); +}); + it("runs the lab provider boundary through authenticated durable PRP", async () => { const bundle = createCapabilityRunnerdCodexTransport({ runnerBinary: defaultCapabilityRunnerdBinary(), diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index ced8d2ddca2..9183459d58b 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -74,11 +74,21 @@ export interface CapabilityRunnerdCodexTransport { export type RunnerdCodexTransport = CapabilityRunnerdCodexTransport; -export function unwrapRunnerdProviderNotification(input: unknown): Record { +export function unwrapRunnerdProviderNotifications(input: unknown): Record[] { const payload = record(input); - if (typeof payload.method === "string") return payload; + if (typeof payload.method === "string") return [payload]; + if (Array.isArray(payload.events)) { + return payload.events + .map(record) + .filter((event) => typeof event.method === "string"); + } const latest = record(payload.latest); - return typeof latest.method === "string" ? latest : payload; + return typeof latest.method === "string" ? [latest] : []; +} + +export function unwrapRunnerdProviderNotification(input: unknown): Record { + const notifications = unwrapRunnerdProviderNotifications(input); + return notifications.at(-1) ?? record(input); } class NotificationQueue implements AsyncIterable { @@ -483,15 +493,17 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { continue; } if (event.eventType !== "provider.event" && event.eventType !== "turn.completed") continue; - const payload = unwrapRunnerdProviderNotification(record(event.envelope.payload).payload); - const method = payload.method; - if (typeof method !== "string") continue; - const params = record(payload.params); - const providerTurnId = params.turnId ?? record(params.turn).id; - if (typeof providerTurnId === "string" && providerTurnId.length > 0) { - this.#turnId = providerTurnId; + const notifications = unwrapRunnerdProviderNotifications(record(event.envelope.payload).payload); + for (const payload of notifications) { + const method = payload.method; + if (typeof method !== "string") continue; + const params = record(payload.params); + const providerTurnId = params.turnId ?? record(params.turn).id; + if (typeof providerTurnId === "string" && providerTurnId.length > 0) { + this.#turnId = providerTurnId; + } + this.#queue.push({ method, params }); } - this.#queue.push({ method, params }); } } diff --git a/ui/src/adapters/paperclip-runner/index.test.ts b/ui/src/adapters/paperclip-runner/index.test.ts index abca26648b8..5163a343d5e 100644 --- a/ui/src/adapters/paperclip-runner/index.test.ts +++ b/ui/src/adapters/paperclip-runner/index.test.ts @@ -20,4 +20,42 @@ describe("paperclip runner transcript projection", () => { expect(started).toEqual([expect.objectContaining({ kind: "tool_call", name: "get_task_context", toolUseId: "call-1" })]); expect(completed).toEqual([expect.objectContaining({ kind: "tool_result", toolUseId: "call-1", isError: false })]); }); + + it("maps native Codex deltas, camel-case tools, and usage into the shared chat transcript", () => { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + const event = (eventType: string, payload: Record, itemId?: string) => parse(JSON.stringify({ + type: "paperclip.prp.event", + event: { eventType, itemId, payload }, + }), "2026-08-21T12:00:00.000Z"); + + expect(event("item.delta", { kind: "reasoning", text: "Inspecting the runner" }, "reason-1")) + .toEqual([{ kind: "thinking", ts: expect.any(String), text: "Inspecting the runner", delta: true }]); + expect(event("item.delta", { kind: "agentMessage", text: "Here is" }, "message-1")) + .toEqual([{ kind: "assistant", ts: expect.any(String), text: "Here is", delta: true }]); + expect(event("item.started", { + kind: "commandExecution", + item: { id: "exec-1", type: "commandExecution", command: "pnpm test", status: "inProgress" }, + }, "exec-1")).toEqual([ + expect.objectContaining({ kind: "tool_call", name: "command", toolUseId: "exec-1" }), + ]); + expect(event("item.completed", { + kind: "usage", + usage: { total: { inputTokens: 120, outputTokens: 30, cachedInputTokens: 80 } }, + })).toEqual([ + expect.objectContaining({ kind: "result", subtype: "paperclip.usage", inputTokens: 120, outputTokens: 30 }), + ]); + }); + + it("emits a proposed and accepted terminal summary only once", () => { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + const line = (eventType: string, payload: Record) => parse(JSON.stringify({ + type: "paperclip.prp.event", + event: { eventType, payload }, + }), "2026-08-21T12:00:00.000Z"); + + expect(line("run.result.proposed", { summary: "Finished the task" })) + .toEqual([{ kind: "assistant", ts: expect.any(String), text: "Finished the task" }]); + expect(line("run.result.accepted", { result: { summary: "Finished the task" } })) + .toEqual([]); + }); }); diff --git a/ui/src/adapters/paperclip-runner/index.ts b/ui/src/adapters/paperclip-runner/index.ts index 1338caeed48..d71cf42d49d 100644 --- a/ui/src/adapters/paperclip-runner/index.ts +++ b/ui/src/adapters/paperclip-runner/index.ts @@ -1,41 +1,359 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; import type { UIAdapterModule } from "../types"; import { parseCodexStdoutLine, buildPaperclipRunnerConfig } from "@paperclipai/adapter-codex-local/ui"; import { CodexLocalConfigFields } from "../codex-local/config-fields"; -function parsePaperclipRunnerStdoutLine(line: string, ts: string) { +type JsonRecord = Record; + +interface PaperclipRunnerParserState { + assistantDeltaItemIds: Set; + reasoningDeltaItemIds: Set; + resultSummaries: Set; + toolOutputItemIds: Set; +} + +function record(value: unknown): JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as JsonRecord + : {}; +} + +function text(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function number(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function stringify(value: unknown): string { + if (typeof value === "string") return value; + if (value === undefined || value === null) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function itemText(item: JsonRecord, payload: JsonRecord): string { + const direct = text(item.text, text(payload.text)); + if (direct) return direct; + const summary = Array.isArray(item.summary) ? item.summary : []; + return summary + .map((part) => text(record(part).text, text(part))) + .filter(Boolean) + .join("\n"); +} + +function normalizedItemType(item: JsonRecord, payload: JsonRecord): string { + return text(item.type, text(payload.kind)).replaceAll("_", "").toLowerCase(); +} + +function itemId(event: JsonRecord, item: JsonRecord): string { + return text(event.itemId, text(item.id, "paperclip-runner-item")); +} + +function toolFailure(item: JsonRecord): boolean { + const status = text(item.status).toLowerCase(); + const exitCode = item.exitCode ?? item.exit_code; + return item.isError === true + || item.is_error === true + || item.success === false + || (typeof exitCode === "number" && exitCode !== 0) + || ["failed", "error", "errored", "cancelled"].includes(status); +} + +function commandEntries( + event: JsonRecord, + item: JsonRecord, + phase: "started" | "completed", + ts: string, +): TranscriptEntry[] { + const id = itemId(event, item); + const commandActions = Array.isArray(item.commandActions) ? item.commandActions : []; + const command = text(item.command, text(record(commandActions[0]).command)); + if (phase === "started") { + return [{ kind: "tool_call", ts, name: "command", toolUseId: id, input: { command } }]; + } + const output = text(item.aggregatedOutput, text(item.aggregated_output)); + const exitCode = item.exitCode ?? item.exit_code; + const detail = [ + typeof exitCode === "number" ? `exit_code: ${exitCode}` : "", + output, + ].filter(Boolean).join("\n"); + return [{ + kind: "tool_result", + ts, + toolUseId: id, + toolName: "command", + content: detail || text(item.status, "command completed"), + isError: toolFailure(item), + }]; +} + +function diffEntries(change: JsonRecord, ts: string): TranscriptEntry[] { + const path = text(change.path); + const kind = text(record(change.kind).type, text(change.kind, "update")); + const raw = text(change.diff); + const entries: TranscriptEntry[] = []; + if (path) entries.push({ kind: "diff", ts, changeType: "file_header", text: path }); + for (const line of raw.split("\n")) { + if (!line && raw.length === 0) continue; + entries.push({ + kind: "diff", + ts, + changeType: kind === "add" ? "add" : kind === "delete" ? "remove" : "context", + text: line, + }); + } + return entries; +} + +function fileChangeEntries( + event: JsonRecord, + item: JsonRecord, + phase: "started" | "completed", + ts: string, +): TranscriptEntry[] { + const id = itemId(event, item); + const changes = Array.isArray(item.changes) ? item.changes.map(record) : []; + const paths = changes.map((change) => text(change.path)).filter(Boolean); + if (phase === "started") { + return [{ + kind: "tool_call", + ts, + name: "file_change", + toolUseId: id, + input: { path: paths[0] ?? "", paths }, + }]; + } + return [ + ...changes.flatMap((change) => diffEntries(change, ts)), + { + kind: "tool_result" as const, + ts, + toolUseId: id, + toolName: "file_change", + content: paths.length > 0 ? paths.join("\n") : "file change completed", + isError: toolFailure(item), + }, + ]; +} + +function dynamicToolEntries( + event: JsonRecord, + item: JsonRecord, + phase: "started" | "completed", + ts: string, +): TranscriptEntry[] { + const id = itemId(event, item); + const name = text(item.tool, text(item.name, "Paperclip tool")); + if (phase === "started") { + return [{ kind: "tool_call", ts, name, toolUseId: id, input: item.arguments ?? item.input ?? {} }]; + } + return [{ + kind: "tool_result", + ts, + toolUseId: id, + toolName: name, + content: stringify(item.contentItems ?? item.result ?? item.output) || `${name} completed`, + isError: toolFailure(item), + }]; +} + +function genericToolEntries( + event: JsonRecord, + item: JsonRecord, + phase: "started" | "completed", + ts: string, +): TranscriptEntry[] { + const id = itemId(event, item); + const name = text(item.name, text(item.tool, "Tool")); + if (phase === "started") { + return [{ kind: "tool_call", ts, name, toolUseId: id, input: item.input ?? item.arguments ?? {} }]; + } + return [{ + kind: "tool_result", + ts, + toolUseId: text(item.tool_use_id, id), + toolName: name, + content: stringify(item.content ?? item.result ?? item.output ?? item.error) || `${name} completed`, + isError: toolFailure(item), + }]; +} + +function parseItemEvent( + event: JsonRecord, + payload: JsonRecord, + phase: "started" | "completed", + ts: string, + state: PaperclipRunnerParserState, +): TranscriptEntry[] { + const item = record(payload.item); + const type = normalizedItemType(item, payload); + const id = itemId(event, item); + if (type === "agentmessage") { + const value = itemText(item, payload); + if (!value || state.assistantDeltaItemIds.has(id)) return []; + return [{ kind: "assistant", ts, text: value }]; + } + if (type === "reasoning") { + const value = itemText(item, payload); + if (value && !state.reasoningDeltaItemIds.has(id)) return [{ kind: "thinking", ts, text: value }]; + return phase === "started" + ? [{ kind: "system", ts, text: "Reasoning started" }] + : []; + } + if (type === "commandexecution") { + const entries = commandEntries(event, item, phase, ts); + if (phase === "completed" && state.toolOutputItemIds.has(id)) { + const result = entries[0]; + if (result?.kind === "tool_result") result.content = ""; + } + return entries; + } + if (type === "filechange") return fileChangeEntries(event, item, phase, ts); + if (type === "dynamictoolcall") return dynamicToolEntries(event, item, phase, ts); + if (type === "tooluse" || type === "toolresult" || type === "mcptoolcall") { + return genericToolEntries(event, item, phase, ts); + } + if (type === "usage") return []; + if (type === "usermessage") return []; + const detail = itemText(item, payload); + return detail ? [{ kind: "system", ts, text: detail }] : []; +} + +function parseDeltaEvent( + event: JsonRecord, + payload: JsonRecord, + ts: string, + state: PaperclipRunnerParserState, +): TranscriptEntry[] { + const kind = text(payload.kind).replaceAll("_", "").toLowerCase(); + const value = text(payload.text); + const id = text(event.itemId, `${kind || "item"}-delta`); + if (!value) return []; + if (kind === "agentmessage") { + state.assistantDeltaItemIds.add(id); + return [{ kind: "assistant", ts, text: value, delta: true }]; + } + if (kind === "reasoning") { + state.reasoningDeltaItemIds.add(id); + return [{ kind: "thinking", ts, text: value, delta: true }]; + } + if (kind === "commandexecution") { + state.toolOutputItemIds.add(id); + return [{ + kind: "tool_result", + ts, + toolUseId: id, + toolName: "command", + content: value, + isError: false, + delta: true, + }]; + } + if (kind === "filechange" || kind === "diff") { + return value.split("\n").map((line) => ({ + kind: "diff" as const, + ts, + changeType: line.startsWith("+") ? "add" as const : line.startsWith("-") ? "remove" as const : "context" as const, + text: /^[+-]/.test(line) ? line.slice(1) : line, + })); + } + if (kind === "plan") return [{ kind: "system", ts, text: value }]; + return [{ kind: "system", ts, text: value }]; +} + +function usageEntry(payload: JsonRecord, ts: string): TranscriptEntry { + const usage = record(payload.usage); + const total = record(usage.total); + return { + kind: "result", + ts, + text: "", + inputTokens: number(total.inputTokens ?? total.input_tokens), + outputTokens: number(total.outputTokens ?? total.output_tokens), + cachedTokens: number(total.cachedInputTokens ?? total.cached_input_tokens), + costUsd: number(usage.costUsd ?? usage.cost_usd), + subtype: "paperclip.usage", + isError: false, + errors: [], + }; +} + +function parsePrpEvent( + event: JsonRecord, + ts: string, + state: PaperclipRunnerParserState, +): TranscriptEntry[] { + const eventType = text(event.eventType); + const payload = record(event.payload); + if (eventType === "session.started" || eventType === "session.resumed") { + const context = record(payload.context); + const model = text(context.model, text(record(payload.model).name, "Paperclip runner")); + const sessionId = text(payload.providerSessionId, text(payload.driverSessionId, text(event.normalizedSessionId))); + return [{ kind: "system", ts, text: `Paperclip session ${eventType === "session.resumed" ? "resumed" : "started"} · ${model}${sessionId ? ` · ${sessionId}` : ""}` }]; + } + if (eventType === "turn.started") return [{ kind: "system", ts, text: "Turn started" }]; + if (eventType === "turn.completed") return [{ kind: "system", ts, text: "Turn completed" }]; + if (eventType === "turn.failed") return [{ kind: "stderr", ts, text: text(record(payload.error).message, "Turn failed") }]; + if (eventType === "item.started" || eventType === "item.completed") { + if (payload.kind === "usage") return [usageEntry(payload, ts)]; + return parseItemEvent(event, payload, eventType === "item.started" ? "started" : "completed", ts, state); + } + if (eventType === "item.delta") return parseDeltaEvent(event, payload, ts, state); + if (eventType === "run.result.proposed" || eventType === "run.result.accepted") { + const result = eventType === "run.result.accepted" ? record(payload.result) : payload; + const summary = text(result.summary); + if (!summary || state.resultSummaries.has(summary)) return []; + state.resultSummaries.add(summary); + return [{ kind: "assistant", ts, text: summary }]; + } + if (eventType === "harness.diagnostic" || eventType === "session.failed") { + return [{ kind: "system", ts, text: `Runner: ${text(payload.message, text(payload.code, eventType))}` }]; + } + return []; +} + +function createParserState(): PaperclipRunnerParserState { + return { + assistantDeltaItemIds: new Set(), + reasoningDeltaItemIds: new Set(), + resultSummaries: new Set(), + toolOutputItemIds: new Set(), + }; +} + +function parsePaperclipRunnerLine(line: string, ts: string, state: PaperclipRunnerParserState): TranscriptEntry[] { let parsed: unknown; try { parsed = JSON.parse(line); } catch { return parseCodexStdoutLine(line, ts); } - if (typeof parsed !== "object" || parsed === null || (parsed as { type?: unknown }).type !== "paperclip.prp.event") { - return parseCodexStdoutLine(line, ts); - } - const event = (parsed as { event?: unknown }).event; - if (typeof event !== "object" || event === null) return []; - const candidate = event as { eventType?: unknown; payload?: unknown }; - const eventType = typeof candidate.eventType === "string" ? candidate.eventType : ""; - const payload = typeof candidate.payload === "object" && candidate.payload !== null - ? candidate.payload as Record - : {}; - if ((eventType === "item.started" || eventType === "item.completed") && payload.item) { - return parseCodexStdoutLine(JSON.stringify({ type: eventType, item: payload.item }), ts); - } - if (eventType === "turn.started") return parseCodexStdoutLine(JSON.stringify({ type: "turn.started" }), ts); - if (eventType === "turn.completed") { - return parseCodexStdoutLine(JSON.stringify({ type: "turn.completed", result: payload.result ?? "" }), ts); - } - if (eventType === "harness.diagnostic") { - return [{ kind: "system" as const, ts, text: String(payload.message ?? payload.code ?? "Runner diagnostic") }]; - } - return []; + const envelope = record(parsed); + if (envelope.type !== "paperclip.prp.event") return parseCodexStdoutLine(line, ts); + const event = record(envelope.event); + return Object.keys(event).length > 0 ? parsePrpEvent(event, ts, state) : []; +} + +export function parsePaperclipRunnerStdoutLine(line: string, ts: string): TranscriptEntry[] { + return parsePaperclipRunnerLine(line, ts, createParserState()); } export const paperclipRunnerUIAdapter: UIAdapterModule = { type: "paperclip_runner", label: "Paperclip Runner", parseStdoutLine: parsePaperclipRunnerStdoutLine, + createStdoutParser: () => { + let state = createParserState(); + return { + parseLine: (line, ts) => parsePaperclipRunnerLine(line, ts, state), + reset: () => { state = createParserState(); }, + }; + }, ConfigFields: CodexLocalConfigFields, buildAdapterConfig: buildPaperclipRunnerConfig, }; diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 1f3ec7d5b87..a77d1b76a83 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -594,8 +594,7 @@ describe("TaskChatThread live transcript", () => { // Clean content survives: streamed reply markdown + compact phase summary. expect(tail!.textContent).toContain("Streaming through the shared renderer"); const phaseSummary = tail!.querySelector('[data-testid="task-chat-phase-summary"]'); - expect(phaseSummary?.getAttribute("aria-expanded")).toBe("false"); - flushSync(() => phaseSummary!.click()); + expect(phaseSummary?.getAttribute("aria-expanded")).toBe("true"); expect(tail!.textContent).toContain("src/app.ts"); // None of the debug plumbing reaches the thread. for (const noise of ["INITMARKER", "SYSTEMNOISE", "STDOUTNOISE", "STDERRNOISE"]) { diff --git a/ui/src/components/task-chat/TaskChatActivityPhase.tsx b/ui/src/components/task-chat/TaskChatActivityPhase.tsx index aad8cec53bb..a849aba093c 100644 --- a/ui/src/components/task-chat/TaskChatActivityPhase.tsx +++ b/ui/src/components/task-chat/TaskChatActivityPhase.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; import { MarkdownBody } from "@/components/MarkdownBody"; @@ -11,7 +11,14 @@ export function TaskChatActivityPhase({ item: TaskChatActivityPhaseItem; renderChild: (child: TaskChatActivityPhaseItem["items"][number]) => ReactNode; }) { - const [open, setOpen] = useState(false); + const shouldAutoOpen = + item.active || item.items.some((child) => + (child.kind === "thinking" && child.streaming) || + (child.kind === "tool" && child.status === "in_progress")); + const [open, setOpen] = useState(shouldAutoOpen); + useEffect(() => { + if (shouldAutoOpen) setOpen(true); + }, [shouldAutoOpen]); const expandable = item.items.length > 0; return (
diff --git a/ui/src/components/task-chat/TaskChatLiveTail.test.tsx b/ui/src/components/task-chat/TaskChatLiveTail.test.tsx index 57c4c20bb4a..8d205ee0b2e 100644 --- a/ui/src/components/task-chat/TaskChatLiveTail.test.tsx +++ b/ui/src/components/task-chat/TaskChatLiveTail.test.tsx @@ -53,8 +53,7 @@ describe("TaskChatLiveTail", () => { "Looking into the failing test.", ); const phaseSummary = container.querySelector('[data-testid="task-chat-phase-summary"]'); - expect(phaseSummary?.getAttribute("aria-expanded")).toBe("false"); - flushSync(() => phaseSummary!.click()); + expect(phaseSummary?.getAttribute("aria-expanded")).toBe("true"); // Tool row renders with its name + mono target. expect(container.textContent).toContain("Read"); expect(container.textContent).toContain("src/app.ts"); @@ -69,8 +68,7 @@ describe("TaskChatLiveTail", () => { render(items); const phaseSummary = container.querySelector('[data-testid="task-chat-phase-summary"]'); - expect(phaseSummary?.getAttribute("aria-expanded")).toBe("false"); - flushSync(() => phaseSummary!.click()); + expect(phaseSummary?.getAttribute("aria-expanded")).toBe("true"); expect(container.textContent).toContain("const x = 1;"); expect(container.textContent).toContain("+1 −1"); }); @@ -105,8 +103,7 @@ describe("TaskChatLiveTail", () => { "Here is the real reply.", ); const phaseSummary = container.querySelector('[data-testid="task-chat-phase-summary"]'); - expect(phaseSummary?.getAttribute("aria-expanded")).toBe("false"); - flushSync(() => phaseSummary!.click()); + expect(phaseSummary?.getAttribute("aria-expanded")).toBe("true"); const text = container.textContent ?? ""; expect(text).toContain("Here is the real reply."); expect(text).toContain("pnpm test"); @@ -125,15 +122,16 @@ describe("TaskChatLiveTail", () => { } }); - it("does not render a thinking row (its signal is the status pill)", () => { + it("renders provider-supplied reasoning in the live activity stream", () => { const items = parse([ - { kind: "thinking", ts: TS, text: "SECRET internal reasoning" }, + { kind: "thinking", ts: TS, text: "Provider reasoning summary" }, { kind: "assistant", ts: TS, text: "Visible answer." }, ]); render(items); expect(container.textContent).toContain("Visible answer."); - expect(container.textContent).not.toContain("SECRET internal reasoning"); + expect(container.textContent).toContain("Provider reasoning summary"); + expect(container.querySelector('[data-testid="task-chat-thinking"]')).not.toBeNull(); }); it("shows the empty message when nothing renderable has streamed yet", () => { diff --git a/ui/src/components/task-chat/TaskChatLiveTail.tsx b/ui/src/components/task-chat/TaskChatLiveTail.tsx index 2bb9eb8514e..4a0aad2a1ef 100644 --- a/ui/src/components/task-chat/TaskChatLiveTail.tsx +++ b/ui/src/components/task-chat/TaskChatLiveTail.tsx @@ -4,6 +4,8 @@ import type { TaskChatItem } from "./task-chat-model"; import { TaskChatToolCard } from "./TaskChatToolCard"; import { TaskChatUsageReadout } from "./TaskChatUsageReadout"; import { TaskChatActivityPhase } from "./TaskChatActivityPhase"; +import { TaskChatThinking } from "./TaskChatThinking"; +import { TaskChatMarker } from "./TaskChatMarker"; import { buildActivityPhases } from "./transcript-adapter"; /** @@ -77,18 +79,24 @@ function renderTailRow(item: TaskChatItem): ReactElement | null {
); + case "thinking": + return ; case "activity_phase": return ( child.kind === "tool" ? : } + renderChild={(child) => child.kind === "tool" + ? + : child.kind === "thinking" + ? + : child.kind === "marker" + ? + : } /> ); - // Thinking never renders as a row (PAP-361): its live signal is the status - // pill, and the text stays in the run log / classic transcript. Every other - // kind (markers, interactions, briefs, statuses, turns, and the dropped - // debug kinds) cannot appear in a parsed live transcript. + // Markers, interactions, briefs, statuses, turns, and dropped debug kinds + // cannot appear as direct live-tail rows. default: return null; } diff --git a/ui/src/components/task-chat/TaskChatThinking.tsx b/ui/src/components/task-chat/TaskChatThinking.tsx new file mode 100644 index 00000000000..60d8afab89d --- /dev/null +++ b/ui/src/components/task-chat/TaskChatThinking.tsx @@ -0,0 +1,33 @@ +import { useState } from "react"; +import { Brain, ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import type { TaskChatThinkingItem } from "./task-chat-model"; + +/** Provider-authored reasoning summaries and deltas; never reconstructed hidden reasoning. */ +export function TaskChatThinking({ item }: { item: TaskChatThinkingItem }) { + const [open, setOpen] = useState(item.streaming || !item.collapsed); + const body = item.lines.join("\n").trim(); + if (!body) return null; + const label = item.streaming ? "Reasoning…" : item.summaryLabel ?? "Reasoning"; + + return ( +
+ + {open ? ( +
+ {body} +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index a3bb398c3b0..c2f221d5265 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -12,6 +12,7 @@ import { TaskChatStatusPill } from "./TaskChatStatusPill"; import { TaskChatToolCard } from "./TaskChatToolCard"; import { TaskChatUsageReadout } from "./TaskChatUsageReadout"; import { TaskChatActivityPhase } from "./TaskChatActivityPhase"; +import { TaskChatThinking } from "./TaskChatThinking"; import { TaskMessageScroller } from "./TaskMessageScroller"; interface TaskChatThreadViewProps { @@ -89,11 +90,7 @@ function renderItem( case "marker": return ; case "thinking": - // Thinking never renders as a row (PAP-361): its live signal is the - // pill's "Thinking…" state, and the text stays in the run log / classic - // transcript. The kind survives in the model because the transcript - // parser still emits it (both nesting rules filter it out). - return null; + return ; case "tool": return ; case "status": diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index f1cacc14757..80cae1f0b60 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -204,7 +204,7 @@ export interface TaskChatActivityPhaseItem { /** Historical assistant update that introduced this phase. */ interstitial?: TaskChatMessageItem; /** Chronological tool/usage rows owned exclusively by this phase. */ - items: Array; + items: Array; /** Deterministic, taxonomy-based summary (for example "Read 3 files, ran 1 command"). */ summary: string; /** The tail phase of an in-flight run stays foregrounded. */ diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index a368195ac76..6861ef51ff9 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -232,19 +232,19 @@ describe("isNestableLiveChild", () => { { id: "i", kind: "interaction", interaction: {} as never }, ]; - it("nests only tool and usage rows inside the live parent row (PAP-361)", () => { + 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, thinking, markers, statuses and interactions out", () => { + 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. Thinking never - // nests either (its live signal is the pill's "Thinking…" state), and the - // final reply keeps its bubble. - for (const kind of ["message", "thinking", "marker", "status", "interaction"]) { + // 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); } @@ -380,7 +380,7 @@ describe("settledRunChildren (PAP-361)", () => { 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", "tool"]); + expect(phase.kind === "activity_phase" && phase.items.map((item) => item.kind)).toEqual(["tool", "thinking", "tool"]); }); it("matches the folded summary's tool count exactly (row-count parity)", () => { diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 05748a28fd3..19bd77cd2eb 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -30,16 +30,14 @@ export function isTerminalRunStatus(status: string | undefined | null): boolean /** * The live parent row's nesting rule (PAP-354, narrowed by PAP-361): only tool - * calls and usage readouts nest inside the expandable live turn. Messages + * calls, provider-supplied reasoning summaries, and usage readouts nest inside the expandable live turn. Messages * never nest — an interstitial update streams on the parent row's own line * (TaskChatStatusItem.selfTalk) and vanishes when it completes, and the run's - * final reply lands as its posted comment bubble. Thinking never nests either: - * its live signal is the pill's "Thinking…" state, and the text stays in the - * run log / classic transcript. Markers, statuses and interaction cards stay - * in the thread outside. + * final reply lands as its posted comment bubble. Markers, statuses and + * interaction cards stay in the thread outside. */ export function isNestableLiveChild(item: TaskChatItem): item is TaskChatTurnChildItem { - return item.kind === "tool" || item.kind === "usage" || item.kind === "activity_phase"; + return item.kind === "tool" || item.kind === "thinking" || item.kind === "usage" || item.kind === "activity_phase"; } /** @@ -269,14 +267,16 @@ export function transcriptToTaskChatItems( if (idx != null) { const existing = items[idx]; if (existing.kind === "tool") { - existing.status = entry.isError ? "failed" : "completed"; + existing.status = entry.delta ? "in_progress" : entry.isError ? "failed" : "completed"; if (isGenericToolName(existing.rawName) && !isGenericToolName(entry.toolName)) { existing.name = toolDisplayName(entry.toolName); existing.rawName = entry.toolName; } - if (!existing.detail) { - const detail = formatToolResultDetail(entry.content); - if (detail) existing.detail = detail; + const detail = formatToolResultDetail(entry.content); + if (detail) { + existing.detail = entry.delta && existing.detail + ? `${existing.detail}${detail}` + : detail; } } } @@ -284,6 +284,15 @@ export function transcriptToTaskChatItems( break; } case "diff": { + if (entry.changeType === "file_header") { + if (lastToolIndex >= 0 && items[lastToolIndex].kind === "tool") { + const tool = items[lastToolIndex] as TaskChatToolItem; + tool.diff = tool.diff ?? { path: entry.text, added: 0, removed: 0, lines: [] }; + tool.diff.path = entry.text; + } + resetInline(); + break; + } const line = { kind: diffKind(entry.changeType), text: entry.text ?? "" }; if (lastToolIndex >= 0 && items[lastToolIndex].kind === "tool") { const tool = items[lastToolIndex] as TaskChatToolItem; @@ -310,8 +319,54 @@ export function transcriptToTaskChatItems( resetInline(); break; } - // init / result / stderr / stdout / system / user carry no thread-visible - // content in the live turn (status is rendered separately). + case "system": { + if (entry.text.startsWith("Paperclip session ")) { + const [label, ...detail] = entry.text.split(" · "); + items.push({ + id: `${runId}:session:${i}`, + kind: "marker", + variant: "session_start", + label: label.replace("Paperclip ", "").replace(/^./, (character) => character.toUpperCase()), + detail: detail.join(" · ") || undefined, + }); + } else if (entry.text === "Turn started" || entry.text === "Turn completed") { + items.push({ + id: `${runId}:turn-boundary:${i}`, + kind: "marker", + variant: "turn_boundary", + label: entry.text, + }); + } else if (entry.text.startsWith("Runner:")) { + items.push({ + id: `${runId}:runner:${i}`, + kind: "marker", + variant: "turn_boundary", + label: "Runner update", + detail: entry.text.slice("Runner:".length).trim(), + }); + } + resetInline(); + break; + } + case "result": { + if (entry.subtype === "paperclip.usage") { + items.push({ + id: `${runId}:usage:${i}`, + kind: "usage", + usage: { + used: entry.inputTokens + entry.outputTokens, + size: 0, + inputTokens: entry.inputTokens, + outputTokens: entry.outputTokens, + costUsd: entry.costUsd, + }, + }); + } + resetInline(); + break; + } + // init / stderr / stdout / user and unrecognized result/system records + // carry no thread-visible content in the live turn. default: break; } @@ -338,7 +393,7 @@ export function settledRunChildren(parsed: readonly TaskChatItem[]): TaskChatTur return buildActivityPhases(parsed, false); } -function phaseSummary(items: readonly (TaskChatToolItem | { kind: "usage" })[]): string { +function phaseSummary(items: readonly (TaskChatToolItem | { kind: "usage" } | { kind: "thinking" } | { kind: "marker" })[]): string { const counts = new Map(); let generic = 0; for (const item of items) { @@ -363,7 +418,9 @@ function phaseSummary(items: readonly (TaskChatToolItem | { kind: "usage" })[]): const known = new Set(["read", "edit", "terminal", "grep", "search"]); const other = [...counts].reduce((n, [family, count]) => n + (known.has(family) ? 0 : count), 0) + generic; if (other) phrases.push(`Called ${other} ${other === 1 ? "tool" : "tools"}`); - return phrases.join(", ") || "No tool activity"; + if (phrases.length > 0) return phrases.join(", "); + if (items.some((item) => item.kind === "thinking")) return "Reasoning"; + return items.some((item) => item.kind === "marker") ? "Turn activity" : "No tool activity"; } /** Segment parsed transcript rows at assistant boundaries with stable run-derived ids. */ @@ -395,7 +452,7 @@ export function buildActivityPhases( active: false, }; phases.push(current); - } else if (item.kind === "tool" || item.kind === "usage") { + } else if (item.kind === "tool" || item.kind === "usage" || item.kind === "thinking" || item.kind === "marker") { ensureOpening(item.id).items.push(item); } } @@ -726,6 +783,7 @@ export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): { return { label: "Responding", selfTalk: selfTalk || undefined }; } if (entry.kind === "thinking") return { label: "Thinking" }; + if (entry.kind === "system" && entry.text === "Reasoning started") return { label: "Thinking" }; } return { label: "Running" }; } diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index 0182b71e340..a0354454e71 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -175,6 +175,19 @@ describe("buildAssistantPartsFromTranscript", () => { expect(result.notices).toEqual([]); }); + it("accumulates streamed tool output before the completed result", () => { + const result = buildAssistantPartsFromTranscript([ + { kind: "tool_call", ts: "2026-04-06T12:00:00.000Z", name: "shell", toolUseId: "tool-1", input: {} }, + { kind: "tool_result", ts: "2026-04-06T12:00:01.000Z", toolUseId: "tool-1", content: "first\n", delta: true }, + { kind: "tool_result", ts: "2026-04-06T12:00:02.000Z", toolUseId: "tool-1", content: "second\n", delta: true }, + { kind: "tool_result", ts: "2026-04-06T12:00:03.000Z", toolUseId: "tool-1", content: "", isError: false }, + ]); + + expect(result.parts).toMatchObject([ + { type: "tool-call", toolCallId: "tool-1", result: "first\nsecond\n", isError: false }, + ]); + }); + it("preserves transcript ordering when text and tool activity are interleaved", () => { const result = buildAssistantPartsFromTranscript([ { kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "First." }, diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index f1aec74fe0e..c5fadbf31c7 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -885,13 +885,17 @@ export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTra if (entry.kind === "tool_result") { const toolCallId = entry.toolUseId || `tool-result-${index}`; const existing = toolParts.get(toolCallId); + const existingResult = typeof existing?.result === "string" ? existing.result : ""; + const result = entry.delta + ? `${existingResult}${entry.content ?? ""}` + : (entry.content || existingResult); const nextPart: ToolCallMessagePart = { type: "tool-call", toolCallId, toolName: existing?.toolName || entry.toolName || "tool", args: existing?.args ?? {}, argsText: existing?.argsText ?? "", - result: entry.content ?? "", + result, isError: entry.isError === true, }; if (existing) {