mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
fix(runner): stream native activity into task chat
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d711fe390c
commit
2fe9fdd5a2
18 files changed
+652
-82
No files matched your search
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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." },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user