diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index 22551f4ef1..4e7455ee2c 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -257,6 +257,65 @@ describe("nativeRunEventsToTranscript", () => { ]); }); + it("projects ACPX item-only tool lifecycles at the named write boundary", () => { + const transcript = nativeRunEventsToTranscript([ + itemEvent(1, "item.started", "2", { + kind: "dynamicToolCall", + item: { + type: "tool_use", + id: "2", + name: "write_document", + input: { + key: "plan", + title: "Plan", + body: "Ship the durable runner.", + }, + }, + }), + itemEvent(2, "item.completed", "2", { + kind: "dynamicToolCall", + item: { + type: "tool_result", + id: "2", + tool_use_id: "2", + result: { + disposition: "applied", + document: { + key: "plan", + latestRevisionId: "revision-1", + }, + }, + }, + }), + ]); + + expect(transcript).toEqual([ + expect.objectContaining({ + kind: "tool_call", + name: "write_document", + toolUseId: "2", + input: { + key: "plan", + title: "Plan", + body: "Ship the durable runner.", + }, + }), + expect.objectContaining({ + kind: "tool_result", + toolUseId: "2", + toolName: "write_document", + content: JSON.stringify({ + disposition: "applied", + document: { + key: "plan", + latestRevisionId: "revision-1", + }, + }), + isError: false, + }), + ]); + }); + it("streams deltas until a loss-resistant completed item is available", () => { expect(nativeRunEventsToTranscript([ event(1, "item.delta", { itemId: "message-1", kind: "agentMessage", text: "Still " }), diff --git a/ui/src/components/transcript/native-run-events.ts b/ui/src/components/transcript/native-run-events.ts index a57fa9c6d8..2796d5bcfe 100644 --- a/ui/src/components/transcript/native-run-events.ts +++ b/ui/src/components/transcript/native-run-events.ts @@ -512,7 +512,11 @@ interface NativeToolItemDetails { function nativeToolItemDetails( payload: Record, -): { id: string | null; details: NativeToolItemDetails } | null { +): { + id: string | null; + kind: "tooluse" | "toolresult"; + details: NativeToolItemDetails; +} | null { const item = normalizedItem(payload); const kind = (text(item.type) ?? "").replaceAll("_", "").toLowerCase(); if (kind !== "tooluse" && kind !== "toolresult") return null; @@ -521,6 +525,7 @@ function nativeToolItemDetails( : text(item.id); return { id, + kind, details: { name: text(item.name), ...(Object.prototype.hasOwnProperty.call(item, "input") @@ -534,6 +539,15 @@ function nativeToolItemDetails( }; } +function serializedNativeToolResult(item: NativeToolItemDetails): string { + if (item.result === undefined) return ""; + try { + return JSON.stringify(item.result) ?? ""; + } catch { + return "Tool result could not be serialized"; + } +} + function toolPresentation( payload: Record, item?: NativeToolItemDetails, @@ -726,6 +740,50 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] continue; } + // Some provider transports expose their complete dynamic-tool lifecycle + // directly as item.started/item.completed events and do not emit the + // parallel tool.execution.* activity stream. Project those canonical + // tool items at their own stable item boundary so saved documents can be + // embedded beside the write that created them instead of falling back to + // the end of the run timeline. + const nativeToolEvent = isItemIdentityEvent(event.eventType) + ? nativeToolItemDetails(payload) + : null; + if (nativeToolEvent) { + const toolId = nativeToolEvent.id ?? itemId; + if (!toolId) continue; + const nativeToolItem = nativeToolItemsById.get(toolId) + ?? nativeToolEvent.details; + const presentation = toolPresentation({}, nativeToolItem); + if (!startedToolIds.has(toolId)) { + startedToolIds.add(toolId); + entries.push({ + kind: "tool_call", + ts, + name: presentation.name, + input: presentation.input, + toolUseId: toolId, + }); + } + if ( + event.eventType === "item.completed" + && (nativeToolEvent.kind === "toolresult" + || nativeToolEvent.details.result !== undefined) + && !completedToolIds.has(toolId) + ) { + completedToolIds.add(toolId); + entries.push({ + kind: "tool_result", + ts, + toolUseId: toolId, + toolName: presentation.name, + content: serializedNativeToolResult(nativeToolItem), + isError: nativeToolItem.isError, + }); + } + continue; + } + const providerActivity = providerActivityPresentation(event, payload); if (providerActivity) { if (!startedToolIds.has(providerActivity.id)) { @@ -770,14 +828,9 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] if (event.eventType === "tool.execution.completed" && !completedToolIds.has(executionId)) { completedToolIds.add(executionId); const output = text(payload.output); - let content = output ?? ""; - if (!output && nativeToolItem?.result !== undefined) { - try { - content = JSON.stringify(nativeToolItem.result) ?? ""; - } catch { - content = "Tool result could not be serialized"; - } - } + const content = output ?? (nativeToolItem + ? serializedNativeToolResult(nativeToolItem) + : ""); entries.push({ kind: "tool_result", ts,