fix(runner): stream native activity into task chat

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-21 12:27:35 -05:00
1 parent d711fe390c
commit 2fe9fdd5a2
18 files changed
+652 -82

No files matched your search

+1 -1
View File
@@ -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 }
@@ -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<_>>(),
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");
@@ -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;
@@ -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(),
@@ -74,11 +74,21 @@ export interface CapabilityRunnerdCodexTransport {
export type RunnerdCodexTransport = CapabilityRunnerdCodexTransport;
export function unwrapRunnerdProviderNotification(input: unknown): Record<string, unknown> {
export function unwrapRunnerdProviderNotifications(input: unknown): Record<string, unknown>[] {
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<string, unknown> {
const notifications = unwrapRunnerdProviderNotifications(input);
return notifications.at(-1) ?? record(input);
}
class NotificationQueue implements AsyncIterable<CodexRpcNotification> {
@@ -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 });
}
}
@@ -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<string, unknown>, 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<string, unknown>) => 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([]);
});
});
+340 -22
View File
@@ -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<string, unknown>;
interface PaperclipRunnerParserState {
assistantDeltaItemIds: Set<string>;
reasoningDeltaItemIds: Set<string>;
resultSummaries: Set<string>;
toolOutputItemIds: Set<string>;
}
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<string, unknown>
: {};
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,
};
+1 -2
View File
@@ -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<HTMLButtonElement>('[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"]) {
@@ -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 (
<div className="flex min-w-0 flex-col gap-1" data-testid="task-chat-activity-phase">
@@ -53,8 +53,7 @@ describe("TaskChatLiveTail", () => {
"Looking into the failing test.",
);
const phaseSummary = container.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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", () => {
@@ -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 {
<TaskChatUsageReadout item={item} />
</div>
);
case "thinking":
return <TaskChatThinking key={item.id} item={item} />;
case "activity_phase":
return (
<TaskChatActivityPhase
key={item.id}
item={item}
renderChild={(child) => child.kind === "tool" ? <TaskChatToolCard item={child} /> : <TaskChatUsageReadout item={child} />}
renderChild={(child) => child.kind === "tool"
? <TaskChatToolCard item={child} />
: child.kind === "thinking"
? <TaskChatThinking item={child} />
: child.kind === "marker"
? <TaskChatMarker item={child} />
: <TaskChatUsageReadout item={child} />}
/>
);
// 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;
}
@@ -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 (
<div className="flex min-w-0 flex-col text-xs" data-testid="task-chat-thinking">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
className="group flex min-w-0 items-center gap-1.5 rounded-sm px-1 py-0.5 text-left text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Brain className={cn("h-3.5 w-3.5 shrink-0", item.streaming && "text-(--status-agent-running)")} aria-hidden />
<span>{label}</span>
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open && "rotate-90")} aria-hidden />
</button>
{open ? (
<div className="ml-2.5 mt-1 border-l-2 border-border pl-2.5 text-muted-foreground">
<MarkdownBody softBreaks linkIssueReferences>{body}</MarkdownBody>
</div>
) : null}
</div>
);
}
@@ -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 <TaskChatMarker item={item} />;
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 <TaskChatThinking item={item} />;
case "tool":
return <TaskChatToolCard item={item} />;
case "status":
@@ -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<TaskChatToolItem | TaskChatUsageItem>;
items: Array<TaskChatToolItem | TaskChatUsageItem | TaskChatThinkingItem | TaskChatMarkerItem>;
/** Deterministic, taxonomy-based summary (for example "Read 3 files, ran 1 command"). */
summary: string;
/** The tail phase of an in-flight run stays foregrounded. */
@@ -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)", () => {
@@ -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<string, number>();
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" };
}
+13
View File
@@ -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." },
+5 -1
View File
@@ -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<JsonObject, unknown> = {
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) {