diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index d88a604d6..4337c421f 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -362,8 +362,6 @@ export function AgentStreamView({ return ( ) : null} diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index c578c1780..62cfd72f9 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1151,7 +1151,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ interface ToolCallProps { toolName: string; - args: unknown | null; + args?: unknown | null; result?: unknown | null; error?: unknown | null; status: "executing" | "running" | "completed" | "failed" | "canceled"; @@ -1191,29 +1191,53 @@ export const ToolCall = memo(function ToolCall({ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const effectiveDetail = useMemo(() => { + if (detail) { + return detail; + } + if (args !== undefined || result !== undefined) { + return { + type: "unknown", + rawInput: args ?? null, + rawOutput: result ?? null, + }; + } + return undefined; + }, [detail, args, result]); + + const displayDetail = + effectiveDetail ?? { + type: "unknown", + rawInput: null, + rawOutput: null, + }; + const displayModel = useMemo( () => buildToolCallDisplayModel({ name: toolName, status: status === "executing" ? "running" : status, - input: args ?? null, - output: result ?? null, error: error ?? null, - detail, + detail: displayDetail, metadata, cwd, }), - [toolName, status, args, result, error, detail, metadata, cwd] + [toolName, status, error, displayDetail, metadata, cwd] ); const displayName = displayModel.displayName; const summary = displayModel.summary; const errorText = displayModel.errorText; - const iconCategory = detail?.type ?? toolName.trim().toLowerCase(); - const IconComponent = resolveToolCallIcon(toolName, detail); + const iconCategory = effectiveDetail?.type ?? toolName.trim().toLowerCase(); + const IconComponent = resolveToolCallIcon(toolName, effectiveDetail); // Check if there's any content to display const hasDetails = - args !== undefined || result !== undefined || error !== undefined; + Boolean(error) || + (effectiveDetail + ? effectiveDetail.type !== "unknown" || + effectiveDetail.rawInput !== null || + effectiveDetail.rawOutput !== null + : false); const handleToggle = useCallback(() => { if (!isMobile && isPerfLoggingEnabled()) { @@ -1224,15 +1248,13 @@ export const ToolCall = memo(function ToolCall({ toolName, displayName, summary, - detail, - input: args, - output: result, + detail: effectiveDetail, errorText, }); } else { setIsExpanded((prev) => !prev); } - }, [isMobile, openToolCall, toolName, displayName, summary, detail, args, result, errorText]); + }, [isMobile, openToolCall, toolName, displayName, summary, effectiveDetail, errorText]); useEffect(() => { if (isMobile || !isPerfLoggingEnabled()) { @@ -1293,14 +1315,12 @@ export const ToolCall = memo(function ToolCall({ if (isMobile) return null; return ( ); - }, [isMobile, detail, args, result, errorText]); + }, [isMobile, effectiveDetail, errorText]); return ( {detail.query} ); - } else { + } else if (detail?.type === "unknown") { const sectionsFromTopLevel = [ - { title: "Input", value: input }, - { title: "Output", value: output }, + { title: "Input", value: detail.rawInput }, + { title: "Output", value: detail.rawOutput }, ].filter((entry) => entry.value !== null && entry.value !== undefined); for (const section of sectionsFromTopLevel) { diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index d1422af7f..f7eec4285 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -27,8 +27,6 @@ export type ToolCallSheetData = { displayName: string; summary?: string; detail?: ToolCallDetail; - input?: unknown | null; - output?: unknown | null; errorText?: string; }; @@ -132,7 +130,7 @@ interface ToolCallSheetContentProps { } function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { - const { toolName, displayName, detail, input, output, errorText } = data; + const { toolName, displayName, detail, errorText } = data; const IconComponent = resolveToolCallIcon(toolName, detail); @@ -158,8 +156,6 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { > diff --git a/packages/app/src/types/stream-buffer.test.ts b/packages/app/src/types/stream-buffer.test.ts index 3d67a5704..7812b671a 100644 --- a/packages/app/src/types/stream-buffer.test.ts +++ b/packages/app/src/types/stream-buffer.test.ts @@ -26,8 +26,11 @@ const toolCallEvent = (): AgentStreamEventPayload => ({ callId: "buffer-tool-call", name: "run", status: "running", - input: { command: "echo hi" }, - output: null, + detail: { + type: "unknown", + rawInput: { command: "echo hi" }, + rawOutput: null, + }, error: null, }, }); diff --git a/packages/app/src/types/stream.harness.test.ts b/packages/app/src/types/stream.harness.test.ts index b95ba03af..cb914f2df 100644 --- a/packages/app/src/types/stream.harness.test.ts +++ b/packages/app/src/types/stream.harness.test.ts @@ -213,14 +213,18 @@ function buildToolEvent({ error?: unknown; detail?: ToolCallDetail; }): AgentStreamEventPayload { + const canonicalDetail: ToolCallDetail = detail ?? { + type: "unknown", + rawInput: input, + rawOutput: output, + }; + const baseItem = { type: "tool_call" as const, name, status, callId, - input, - output, - ...(detail ? { detail } : {}), + detail: canonicalDetail, }; const item = diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 58d671b43..48e6649d9 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -40,15 +40,19 @@ function canonicalToolTimeline(params: { metadata?: Record; detail?: ToolCallDetail; }): AgentStreamEventPayload { + const detail: ToolCallDetail = params.detail ?? { + type: "unknown", + rawInput: params.input ?? null, + rawOutput: params.output ?? null, + }; + const baseItem = { type: "tool_call" as const, callId: params.callId, name: params.name, status: params.status, - input: params.input ?? null, - output: params.output ?? null, + detail, metadata: params.metadata, - ...(params.detail ? { detail: params.detail } : {}), }; const item = @@ -144,10 +148,13 @@ describe("stream reducer canonical tool calls", () => { assert.strictEqual(tools.length, 1); assert.strictEqual(tools[0].payload.data.status, "completed"); - assert.deepStrictEqual(tools[0].payload.data.input, { command: "pwd" }); - assert.deepStrictEqual(tools[0].payload.data.result, { - output: "/tmp/repo\n", - exitCode: 0, + assert.deepStrictEqual(tools[0].payload.data.detail, { + type: "unknown", + rawInput: { command: "pwd" }, + rawOutput: { + output: "/tmp/repo\n", + exitCode: 0, + }, }); }); @@ -176,8 +183,6 @@ describe("stream reducer canonical tool calls", () => { const summary = buildToolCallDisplayModel({ name: tool.payload.data.name, status: tool.payload.data.status, - input: tool.payload.data.input, - output: tool.payload.data.result, error: tool.payload.data.error, detail: tool.payload.data.detail, }).summary; @@ -209,8 +214,6 @@ describe("stream reducer canonical tool calls", () => { const summary = buildToolCallDisplayModel({ name: tool.payload.data.name, status: tool.payload.data.status, - input: tool.payload.data.input, - output: tool.payload.data.result, error: tool.payload.data.error, detail: tool.payload.data.detail, cwd: "/tmp/repo", @@ -240,8 +243,6 @@ describe("stream reducer canonical tool calls", () => { const display = buildToolCallDisplayModel({ name: tool.payload.data.name, status: tool.payload.data.status, - input: tool.payload.data.input, - output: tool.payload.data.result, error: tool.payload.data.error, detail: tool.payload.data.detail, }); @@ -279,7 +280,11 @@ describe("stream reducer canonical tool calls", () => { const tool = findToolByCallId(state, callId); assert.ok(tool); - assert.deepStrictEqual(tool.payload.data.input, { path: "README.md" }); + assert.deepStrictEqual(tool.payload.data.detail, { + type: "unknown", + rawInput: { path: "README.md" }, + rawOutput: { content: "hello" }, + }); assert.strictEqual(tool.payload.data.status, "completed"); }); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index c9e55ae17..ae653ed9d 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -100,10 +100,8 @@ export interface AgentToolCallData { callId: string; name: string; status: AgentToolCallStatus; - input: unknown | null; - result: unknown | null; error: unknown | null; - detail?: ToolCallDetail; + detail: ToolCallDetail; metadata?: Record; } @@ -328,7 +326,7 @@ function hasNonEmptyObject(value: unknown): boolean { return isRecord(value) && Object.keys(value).length > 0; } -function mergeCanonicalValue( +function mergeUnknownValue( existing: unknown | null, incoming: unknown | null ): unknown | null { @@ -343,6 +341,34 @@ function mergeCanonicalValue( return incoming; } +function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail): ToolCallDetail { + if (existing.type === "unknown" && incoming.type !== "unknown") { + return incoming; + } + + if (incoming.type === "unknown" && existing.type !== "unknown") { + return existing; + } + + if (existing.type === "unknown" && incoming.type === "unknown") { + return { + type: "unknown", + rawInput: mergeUnknownValue(existing.rawInput, incoming.rawInput), + rawOutput: mergeUnknownValue(existing.rawOutput, incoming.rawOutput), + }; + } + + if (existing.type === incoming.type) { + return { ...existing, ...incoming }; + } + + return incoming; +} + +function rawInputFromDetail(detail: ToolCallDetail): unknown | null { + return detail.type === "unknown" ? detail.rawInput : null; +} + function mergeAgentToolCallStatus( existing: AgentToolCallStatus, incoming: AgentToolCallStatus @@ -375,8 +401,6 @@ function appendAgentToolCall( if (!existing || !isAgentToolCallItem(existing)) { return state; } - const mergedInput = mergeCanonicalValue(existing.payload.data.input, data.input); - const mergedResult = mergeCanonicalValue(existing.payload.data.result, data.result); const mergedStatus = mergeAgentToolCallStatus( existing.payload.data.status, data.status @@ -389,6 +413,7 @@ function appendAgentToolCall( data.metadata || existing.payload.data.metadata ? { ...existing.payload.data.metadata, ...data.metadata } : undefined; + const mergedDetail = mergeToolCallDetail(existing.payload.data.detail, data.detail); next[existingIndex] = { ...existing, @@ -399,10 +424,8 @@ function appendAgentToolCall( ...existing.payload.data, ...data, status: mergedStatus, - input: mergedInput, - result: mergedResult, error: mergedError, - detail: data.detail ?? existing.payload.data.detail, + detail: mergedDetail, metadata: mergedMetadata, }, }, @@ -527,7 +550,10 @@ export function reduceStreamUpdate( ) { // For Claude: TodoWrite often appears as a tool call that never resolves. Always render it // as Tasks when possible and otherwise hide it to avoid a stuck loading tool call. - const tasks = extractTaskEntriesFromToolCall(item.name, item.input); + const tasks = extractTaskEntriesFromToolCall( + item.name, + rawInputFromDetail(item.detail) + ); if (tasks) { nextState = appendTodoList( state, @@ -544,7 +570,7 @@ export function reduceStreamUpdate( const tasks = extractTaskEntriesFromToolCall( item.name, - item.input + rawInputFromDetail(item.detail) ); if (tasks) { nextState = appendTodoList( @@ -566,8 +592,6 @@ export function reduceStreamUpdate( callId: item.callId, name: item.name, status: item.status, - input: item.input, - result: item.output, error: item.error, detail: item.detail, metadata: item.metadata, diff --git a/packages/app/src/utils/tool-call-display.test.ts b/packages/app/src/utils/tool-call-display.test.ts index 04c00c354..b85808fb2 100644 --- a/packages/app/src/utils/tool-call-display.test.ts +++ b/packages/app/src/utils/tool-call-display.test.ts @@ -7,8 +7,6 @@ describe("tool-call-display", () => { const display = buildToolCallDisplayModel({ name: "shell", status: "running", - input: { command: "npm test" }, - output: null, error: null, detail: { type: "shell", @@ -26,8 +24,6 @@ describe("tool-call-display", () => { const display = buildToolCallDisplayModel({ name: "read_file", status: "completed", - input: { path: "/tmp/repo/src/index.ts" }, - output: { content: "hello" }, error: null, detail: { type: "read", @@ -46,9 +42,12 @@ describe("tool-call-display", () => { const display = buildToolCallDisplayModel({ name: "task", status: "running", - input: null, - output: null, error: null, + detail: { + type: "unknown", + rawInput: null, + rawOutput: null, + }, metadata: { subAgentActivity: "Running tests", }, @@ -64,9 +63,12 @@ describe("tool-call-display", () => { const display = buildToolCallDisplayModel({ name: "custom_tool_name", status: "completed", - input: null, - output: null, error: null, + detail: { + type: "unknown", + rawInput: null, + rawOutput: null, + }, }); expect(display).toEqual({ @@ -74,13 +76,16 @@ describe("tool-call-display", () => { }); }); - it("does not derive command summary from raw input when detail is missing", () => { + it("does not derive command summary from unknown raw detail", () => { const display = buildToolCallDisplayModel({ name: "exec_command", status: "running", - input: { command: "npm run test" }, - output: null, error: null, + detail: { + type: "unknown", + rawInput: { command: "npm run test" }, + rawOutput: null, + }, }); expect(display).toEqual({ @@ -92,9 +97,12 @@ describe("tool-call-display", () => { const display = buildToolCallDisplayModel({ name: "shell", status: "failed", - input: { command: "false" }, - output: null, error: { message: "boom" }, + detail: { + type: "unknown", + rawInput: { command: "false" }, + rawOutput: null, + }, }); expect(display.errorText).toBe('{\n "message": "boom"\n}'); diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts index ba095b4f9..747240ad7 100644 --- a/packages/app/src/utils/tool-call-icon.ts +++ b/packages/app/src/utils/tool-call-icon.ts @@ -10,6 +10,7 @@ const TOOL_DETAIL_ICONS: Record = edit: Pencil, write: Pencil, search: Search, + unknown: Wrench, }; export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail): ToolCallIconComponent { diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index a1c440221..399c465d8 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -221,8 +221,10 @@ describe("DaemonClient", () => { callId: "call_cli_stream", name: "shell", status: "running", - input: { command: "pwd" }, - output: null, + detail: { + type: "shell", + command: "pwd", + }, error: null, }, }, @@ -241,7 +243,9 @@ describe("DaemonClient", () => { type: "tool_call"; status: string; error: unknown; - output: unknown; + detail: { + type: string; + }; }; }; }; @@ -249,7 +253,7 @@ describe("DaemonClient", () => { expect(streamMsg.payload.event.item.status).toBe("running"); expect(streamMsg.payload.event.item.error).toBeNull(); - expect(streamMsg.payload.event.item.output).toBeNull(); + expect(streamMsg.payload.event.item.detail.type).toBe("shell"); expect(logger.warn).not.toHaveBeenCalled(); }); @@ -288,7 +292,11 @@ describe("DaemonClient", () => { callId: "call_cli_stream_legacy", name: "shell", status: "inProgress", - input: { command: "pwd" }, + detail: { + type: "unknown", + rawInput: { command: "pwd" }, + rawOutput: null, + }, }, }, }, @@ -338,8 +346,10 @@ describe("DaemonClient", () => { callId: "call_cli_snapshot", name: "shell", status: "running", - input: { command: "pwd" }, - output: null, + detail: { + type: "shell", + command: "pwd", + }, error: null, }, }, @@ -361,7 +371,9 @@ describe("DaemonClient", () => { type: "tool_call"; status: string; error: unknown; - output: unknown; + detail: { + type: string; + }; }; }; }>; @@ -373,7 +385,7 @@ describe("DaemonClient", () => { if (firstTimeline?.type === "timeline" && firstTimeline.item.type === "tool_call") { expect(firstTimeline.item.status).toBe("running"); expect(firstTimeline.item.error).toBeNull(); - expect(firstTimeline.item.output).toBeNull(); + expect(firstTimeline.item.detail.type).toBe("shell"); } expect(logger.warn).not.toHaveBeenCalled(); }); diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 2210d1240..6abe05452 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -10,19 +10,23 @@ function toolCallItem(params: { output?: unknown | null; error?: unknown; metadata?: Record; - detail?: Extract['detail']; + detail?: Extract["detail"]; }): Extract { const status = params.status ?? "completed"; + const detail = + params.detail ?? { + type: "unknown" as const, + rawInput: params.input ?? null, + rawOutput: params.output ?? null, + }; return { type: "tool_call", callId: params.callId, name: params.name, status, - input: params.input ?? null, - output: params.output ?? null, + detail, error: status === "failed" ? params.error ?? { message: "failed" } : null, metadata: params.metadata, - detail: params.detail, }; } diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index b2bf8b7fc..46b026c40 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -1,4 +1,4 @@ -import type { AgentTimelineItem } from "./agent-sdk-types.js"; +import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js"; import { isLikelyExternalToolName } from "./tool-name-normalization.js"; import { buildToolCallDisplayModel } from "../../shared/tool-call-display.js"; @@ -45,6 +45,46 @@ function formatToolInputJson(input: unknown): string | null { } } +function hasNonEmptyObject(value: unknown): boolean { + return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0; +} + +function mergeUnknownValue(existing: unknown | null, incoming: unknown | null): unknown | null { + if (incoming === null) { + return existing; + } + + if (!hasNonEmptyObject(incoming) && hasNonEmptyObject(existing)) { + return existing; + } + + return incoming; +} + +function mergeToolDetail(existing: ToolCallDetail, incoming: ToolCallDetail): ToolCallDetail { + if (existing.type === "unknown" && incoming.type !== "unknown") { + return incoming; + } + if (incoming.type === "unknown" && existing.type !== "unknown") { + return existing; + } + if (existing.type === "unknown" && incoming.type === "unknown") { + return { + type: "unknown", + rawInput: mergeUnknownValue(existing.rawInput, incoming.rawInput), + rawOutput: mergeUnknownValue(existing.rawOutput, incoming.rawOutput), + }; + } + if (existing.type === incoming.type) { + return { ...existing, ...incoming }; + } + return incoming; +} + +function rawInputFromDetail(detail: ToolCallDetail): unknown { + return detail.type === "unknown" ? detail.rawInput : null; +} + /** * Collapse timeline items: * - Dedupe tool calls by callId (pending/completed -> single) @@ -95,9 +135,7 @@ function collapseTimeline(items: AgentTimelineItem[]): AgentTimelineItem[] { toolCallMap.set(item.callId, { ...existing, ...item, - input: item.input ?? existing.input, - output: item.output ?? existing.output, - detail: item.detail ?? existing.detail, + detail: mergeToolDetail(existing.detail, item.detail), error: item.error, metadata: item.metadata, }); @@ -105,9 +143,7 @@ function collapseTimeline(items: AgentTimelineItem[]): AgentTimelineItem[] { toolCallMap.set(item.callId, { ...existing, ...item, - input: item.input ?? existing.input, - output: item.output ?? existing.output, - detail: item.detail ?? existing.detail, + detail: mergeToolDetail(existing.detail, item.detail), error: null, metadata: item.metadata, }); @@ -167,12 +203,10 @@ export function curateAgentActivity( break; case "tool_call": { flushBuffers(lines, buffers); - const inputJson = formatToolInputJson(item.input); + const inputJson = formatToolInputJson(rawInputFromDetail(item.detail)); const display = buildToolCallDisplayModel({ name: item.name, status: item.status, - input: item.input, - output: item.output, error: item.error, detail: item.detail, metadata: item.metadata, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 3fa4e46f3..30f795587 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -133,15 +133,18 @@ export type ToolCallDetail = | { type: "search"; query: string; + } + | { + type: "unknown"; + rawInput: unknown | null; + rawOutput: unknown | null; }; type ToolCallBase = { type: "tool_call"; callId: string; name: string; - input: unknown | null; - output: unknown | null; - detail?: ToolCallDetail; + detail: ToolCallDetail; metadata?: Record; }; diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index e44127a0c..2757952ff 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -89,8 +89,21 @@ function extractCommandText(input: unknown): string | null { return null; } +function extractToolCommand(detail: unknown): string | null { + if (!isKeyValueObject(detail) || typeof detail.type !== "string") { + return null; + } + if (detail.type === "shell" && typeof detail.command === "string") { + return detail.command; + } + if (detail.type === "unknown") { + return extractCommandText(detail.rawInput); + } + return null; +} + function isSleepCommandToolCall(item: ToolCallItem): boolean { - const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? ""; + const inputCommand = extractToolCommand(item.detail)?.toLowerCase() ?? ""; return inputCommand.includes("sleep 60"); } @@ -98,7 +111,7 @@ function isPermissionCommandToolCall(item: ToolCallItem): boolean { if (item.name === "permission_request") { return false; } - const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? ""; + const inputCommand = extractToolCommand(item.detail)?.toLowerCase() ?? ""; return inputCommand.includes("permission.txt"); } @@ -376,7 +389,7 @@ async function startAgentMcpServer(): Promise { event.item.name.toLowerCase().includes("bash") && event.item.status === "pending" ) { - pendingCommand = extractCommandText(event.item.input); + pendingCommand = extractToolCommand(event.item.detail); } if (event.type === "turn_completed" || event.type === "turn_failed") { break; @@ -432,28 +445,20 @@ async function startAgentMcpServer(): Promise { item.name !== "permission_request" ); const fileChangeEvent = toolCalls.find((item) => { - // Check for file changes in structured output.files array - if (isKeyValueObject(item.output)) { - const files = item.output.files; - if (Array.isArray(files) && files.some((file) => typeof file?.path === "string" && file.path.includes("tool-test.txt"))) { - return true; - } + if (item.detail.type === "write" || item.detail.type === "edit") { + return item.detail.filePath.includes("tool-test.txt"); } - // Also check for file path in output structure (write/edit tools) - if (isKeyValueObject(item.output)) { - const output = item.output; - if (output.type === "file_write" || output.type === "file_edit") { - const filePath = output.filePath; - if (typeof filePath === "string" && filePath.includes("tool-test.txt")) { - return true; - } - } + if (item.detail.type === "unknown") { + return ( + rawContainsText(item.detail.rawInput, "tool-test.txt") || + rawContainsText(item.detail.rawOutput, "tool-test.txt") + ); } - return false; + return rawContainsText(item.detail, "tool-test.txt"); }); const sawPwdCommand = commandEvents.some( - (item) => (extractCommandText(item.input) ?? "").toLowerCase().includes("pwd") && item.status === "completed" + (item) => (extractToolCommand(item.detail) ?? "").toLowerCase().includes("pwd") && item.status === "completed" ); expect(completed).toBe(true); @@ -950,13 +955,13 @@ async function startAgentMcpServer(): Promise { const liveSnapshots = extractAgentToolSnapshots(liveState); const commandTool = liveSnapshots.find((snapshot) => snapshot.data.name.toLowerCase().includes("bash") && - (extractCommandText(snapshot.data.input) ?? "").toLowerCase().includes("pwd") + (extractToolCommand(snapshot.data.detail) ?? "").toLowerCase().includes("pwd") ); const editTool = liveSnapshots.find((snapshot) => - rawContainsText(snapshot.data.result, "hydrate-proof.txt") + rawContainsText(snapshot.data.detail, "hydrate-proof.txt") ); const readTool = liveSnapshots.find((snapshot) => - rawContainsText(snapshot.data.result, "HYDRATION_PROOF_LINE_TWO") + rawContainsText(snapshot.data.detail, "HYDRATION_PROOF_LINE_TWO") ); expect(commandTool).toBeTruthy(); @@ -993,23 +998,22 @@ async function startAgentMcpServer(): Promise { commandTool!, hydratedMap, (data) => - rawContainsText(data.result, cwd), + rawContainsText(data.detail, cwd), ({ live, hydrated }) => { - expect(rawContainsText(live.result, cwd)).toBe(true); - expect(rawContainsText(hydrated.result, cwd)).toBe(true); - expect((extractCommandText(live.input) ?? "").toLowerCase()).toContain("pwd"); - expect((extractCommandText(hydrated.input) ?? "").toLowerCase()).toContain("pwd"); + expect(rawContainsText(live.detail, cwd)).toBe(true); + expect(rawContainsText(hydrated.detail, cwd)).toBe(true); + expect((extractToolCommand(live.detail) ?? "").toLowerCase()).toContain("pwd"); + expect((extractToolCommand(hydrated.detail) ?? "").toLowerCase()).toContain("pwd"); } ); assertHydratedReplica( editTool!, hydratedMap, (data) => - isFileWriteResult(data.result) && - data.result.filePath.includes("hydrate-proof.txt"), + rawContainsText(data.detail, "hydrate-proof.txt"), ({ live, hydrated }) => { - const liveDiff = JSON.stringify(live.result ?? {}); - const hydratedDiff = JSON.stringify(hydrated.result ?? {}); + const liveDiff = JSON.stringify(live.detail ?? {}); + const hydratedDiff = JSON.stringify(hydrated.detail ?? {}); expect(liveDiff).toContain("hydrate-proof.txt"); expect(hydratedDiff).toContain("hydrate-proof.txt"); } @@ -1018,11 +1022,11 @@ async function startAgentMcpServer(): Promise { readTool!, hydratedMap, (data) => - rawContainsText(data.result, "HYDRATION_PROOF_LINE_ONE") && - rawContainsText(data.result, "HYDRATION_PROOF_LINE_TWO"), + rawContainsText(data.detail, "HYDRATION_PROOF_LINE_ONE") && + rawContainsText(data.detail, "HYDRATION_PROOF_LINE_TWO"), ({ live, hydrated }) => { - const liveReads = JSON.stringify(live.result ?? {}); - const hydratedReads = JSON.stringify(hydrated.result ?? {}); + const liveReads = JSON.stringify(live.detail ?? {}); + const hydratedReads = JSON.stringify(hydrated.detail ?? {}); expect(liveReads).toContain("HYDRATION_PROOF_LINE_ONE"); expect(hydratedReads).toContain("HYDRATION_PROOF_LINE_ONE"); expect(liveReads).toContain("HYDRATION_PROOF_LINE_TWO"); diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts index 84840b581..ece395730 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts @@ -19,8 +19,6 @@ describe("claude tool-call mapper", () => { expect(item.status).toBe("running"); expect(item.error).toBeNull(); expect(item.callId).toBe("claude-call-1"); - expect(item.input).toEqual({ command: "pwd", cwd: "/tmp/repo" }); - expect(item.output).toBeNull(); expect(item.detail?.type).toBe("shell"); if (item.detail?.type === "shell") { expect(item.detail.command).toBe("pwd"); @@ -85,8 +83,6 @@ describe("claude tool-call mapper", () => { expect(item.status).toBe("completed"); expect(item.error).toBeNull(); expect(item.callId).toBe("claude-call-2"); - expect(item.input).toEqual({ file_path: "README.md" }); - expect(item.output).toEqual({ content: "hello" }); expect(item.detail?.type).toBe("read"); if (item.detail?.type === "read") { expect(item.detail.filePath).toBe("README.md"); @@ -141,8 +137,6 @@ describe("claude tool-call mapper", () => { expect(item.status).toBe("failed"); expect(item.error).toEqual({ message: "Command failed" }); expect(item.callId).toBe("claude-call-3"); - expect(item.input).toEqual({ command: "false" }); - expect(item.output).toBeNull(); }); it("maps write/edit/search known shapes with distinct detail types", () => { @@ -181,7 +175,7 @@ describe("claude tool-call mapper", () => { }); }); - it("keeps unknown tools canonical without detail", () => { + it("maps unknown tools to unknown detail with raw payloads", () => { const item = mapClaudeCompletedToolCall({ callId: "claude-call-4", name: "my_custom_tool", @@ -191,8 +185,10 @@ describe("claude tool-call mapper", () => { expect(item.status).toBe("completed"); expect(item.error).toBeNull(); - expect(item.detail).toBeUndefined(); - expect(item.input).toEqual({ foo: "bar" }); - expect(item.output).toEqual({ ok: true }); + expect(item.detail).toEqual({ + type: "unknown", + rawInput: { foo: "bar" }, + rawOutput: { ok: true }, + }); }); }); diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts index 5f1913799..6495add5f 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts @@ -614,22 +614,23 @@ function deriveDetail(name: string, input: unknown, output: unknown): ToolCallDe function buildBase(params: MapperParams): { callId: string; name: string; - input: unknown | null; - output: unknown | null; - detail?: ToolCallDetail; + detail: ToolCallDetail; metadata?: Record; } { const parsedParams = ClaudeMapperParamsSchema.parse(params); const input = parsedParams.input ?? null; const output = parsedParams.output ?? null; - const detail = deriveDetail(parsedParams.name, input, output); + const detail = + deriveDetail(parsedParams.name, input, output) ?? { + type: "unknown", + rawInput: input, + rawOutput: output, + }; return { callId: coerceCallId(parsedParams.callId, parsedParams.name, input), name: parsedParams.name, - input, - output, - ...(detail ? { detail } : {}), + detail, ...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}), }; } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index e275dd982..4c06fa29a 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -49,20 +49,33 @@ function useTempCodexSessionDir(): () => void { function hasShellCommand(item: AgentTimelineItem, commandFragment: string): boolean { if (item.type !== "tool_call" || item.name !== "shell") return false; - const input = item.input as { command?: string } | undefined; - const command = input?.command ?? ""; + if (item.detail.type === "shell") { + return item.detail.command.includes(commandFragment); + } + const unknownInput = + item.detail.type === "unknown" && typeof item.detail.rawInput === "object" && item.detail.rawInput + ? (item.detail.rawInput as { command?: string }) + : undefined; + const command = unknownInput?.command ?? ""; return command.includes(commandFragment); } function hasApplyPatchFile(item: AgentTimelineItem, fileName: string): boolean { if (item.type !== "tool_call" || item.name !== "apply_patch") return false; - const input = item.input as { files?: Array<{ path?: string }> } | undefined; - const output = item.output as - | { files?: Array<{ path?: string; patch?: string }>; diff?: string } - | undefined; - const inInput = (input?.files ?? []).some((file) => file?.path === fileName); - const inOutput = (output?.files ?? []).some((file) => file?.path === fileName); - const inDiff = typeof output?.diff === "string" && output.diff.includes(fileName); + if (item.detail.type === "edit") { + return item.detail.filePath === fileName || (item.detail.unifiedDiff?.includes(fileName) ?? false); + } + const unknownInput = + item.detail.type === "unknown" && typeof item.detail.rawInput === "object" && item.detail.rawInput + ? (item.detail.rawInput as { files?: Array<{ path?: string }> }) + : undefined; + const unknownOutput = + item.detail.type === "unknown" && typeof item.detail.rawOutput === "object" && item.detail.rawOutput + ? (item.detail.rawOutput as { files?: Array<{ path?: string; patch?: string }>; diff?: string }) + : undefined; + const inInput = (unknownInput?.files ?? []).some((file) => file?.path === fileName); + const inOutput = (unknownOutput?.files ?? []).some((file) => file?.path === fileName); + const inDiff = typeof unknownOutput?.diff === "string" && unknownOutput.diff.includes(fileName); return inInput || inOutput || inDiff; } diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index 4798dbccd..b210028cf 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -20,7 +20,11 @@ describe("codex tool-call mapper", () => { expect(item?.error).toBeNull(); expect(item?.callId).toBe("codex-call-1"); expect(item?.name).toBe("shell"); - expect(item?.input).toEqual({ command: "pwd", cwd: "/tmp/repo" }); + expect(item?.detail).toEqual({ + type: "shell", + command: "pwd", + cwd: "/tmp/repo", + }); }); it("maps running known tool variants with detail for early summaries", () => { @@ -133,7 +137,7 @@ describe("codex tool-call mapper", () => { } }); - it("truncates large diff payloads deterministically in canonical output", () => { + it("truncates large diff payloads deterministically in canonical detail", () => { const hugeDiff = `@@\\n-${"a".repeat(14_000)}\\n+${"b".repeat(14_000)}\\n`; const item = mapCodexToolCallFromThreadItem( { @@ -147,11 +151,12 @@ describe("codex tool-call mapper", () => { expect(item).toBeTruthy(); expect(item?.status).toBe("completed"); - expect(item?.output).toBeTruthy(); - const output = item?.output as { files?: Array<{ patch?: string }> }; - expect(output.files?.[0]?.patch).toBeDefined(); - expect(output.files?.[0]?.patch?.includes("...[truncated ")).toBe(true); - expect((output.files?.[0]?.patch?.length ?? 0) < hugeDiff.length).toBe(true); + expect(item?.detail?.type).toBe("edit"); + if (item?.detail?.type === "edit") { + expect(item.detail.unifiedDiff).toBeDefined(); + expect(item.detail.unifiedDiff?.includes("...[truncated ")).toBe(true); + expect((item.detail.unifiedDiff?.length ?? 0) < hugeDiff.length).toBe(true); + } }); it("maps write/edit/search known variants with distinct detail types", () => { @@ -218,7 +223,7 @@ describe("codex tool-call mapper", () => { expect(item?.callId).toBe("codex-call-3"); }); - it("keeps unknown tools canonical without detail", () => { + it("maps unknown tools to unknown detail with raw payloads", () => { const item = mapCodexRolloutToolCall({ callId: "codex-call-4", name: "my_custom_tool", @@ -228,9 +233,11 @@ describe("codex tool-call mapper", () => { expect(item.status).toBe("completed"); expect(item.error).toBeNull(); - expect(item.detail).toBeUndefined(); + expect(item.detail).toEqual({ + type: "unknown", + rawInput: { foo: "bar" }, + rawOutput: { ok: true }, + }); expect(item.callId).toBe("codex-call-4"); - expect(item.input).toEqual({ foo: "bar" }); - expect(item.output).toEqual({ ok: true }); }); }); diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 167cc9f7b..92e220fdf 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -827,26 +827,42 @@ function deriveRolloutDetail(name: string, input: unknown, output: unknown): Too return parsed.data; } +function toCanonicalDetail( + knownDetail: ToolCallDetail | undefined, + rawInput: unknown | null, + rawOutput: unknown | null +): ToolCallDetail { + if (knownDetail) { + return knownDetail; + } + + return { + type: "unknown", + rawInput, + rawOutput, + }; +} + function buildToolCall(params: { callId: string; name: string; status: ToolCallTimelineItem["status"]; - input: unknown | null; - output: unknown | null; + rawInput: unknown | null; + rawOutput: unknown | null; error: unknown | null; detail?: ToolCallDetail; metadata?: Record; }): ToolCallTimelineItem { + const detail = toCanonicalDetail(params.detail, params.rawInput, params.rawOutput); + if (params.status === "failed") { return { type: "tool_call", callId: params.callId, name: params.name, status: "failed", - input: params.input, - output: params.output, error: params.error ?? { message: "Tool call failed" }, - ...(params.detail ? { detail: params.detail } : {}), + detail, ...(params.metadata ? { metadata: params.metadata } : {}), }; } @@ -856,10 +872,8 @@ function buildToolCall(params: { callId: params.callId, name: params.name, status: params.status, - input: params.input, - output: params.output, error: null, - ...(params.detail ? { detail: params.detail } : {}), + detail, ...(params.metadata ? { metadata: params.metadata } : {}), }; } @@ -941,8 +955,8 @@ function mapCommandExecutionItem( callId, name, status, - input, - output, + rawInput: input, + rawOutput: output, error, ...(detail ? { detail } : {}), }); @@ -1003,8 +1017,8 @@ function mapFileChangeItem( callId, name, status, - input, - output, + rawInput: input, + rawOutput: output, error, ...(detail ? { detail } : {}), }); @@ -1027,8 +1041,8 @@ function mapMcpToolCallItem( callId, name, status, - input, - output, + rawInput: input, + rawOutput: output, error, ...(detail ? { detail } : {}), }); @@ -1052,8 +1066,8 @@ function mapWebSearchItem(item: z.infer): ToolC callId, name, status, - input, - output, + rawInput: input, + rawOutput: output, error, ...(detail ? { detail } : {}), }); @@ -1108,8 +1122,8 @@ export function mapCodexRolloutToolCall(params: { callId, name: parsed.name, status, - input, - output, + rawInput: input, + rawOutput: output, error, ...(detail ? { detail } : {}), }); diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts index 8105d99e4..cb5e985dd 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts @@ -181,7 +181,7 @@ describe("opencode tool-call mapper", () => { }); }); - it("keeps unknown tools canonical without detail", () => { + it("maps unknown tools to unknown detail with raw payloads", () => { const item = mapOpencodeToolCall({ toolName: "my_custom_tool", callId: "opencode-call-4", @@ -192,8 +192,10 @@ describe("opencode tool-call mapper", () => { expect(item.status).toBe("completed"); expect(item.error).toBeNull(); - expect(item.detail).toBeUndefined(); - expect(item.input).toEqual({ foo: "bar" }); - expect(item.output).toEqual({ ok: true }); + expect(item.detail).toEqual({ + type: "unknown", + rawInput: { foo: "bar" }, + rawOutput: { ok: true }, + }); }); }); diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts index 397a92df2..a6098d5ac 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts @@ -632,13 +632,29 @@ function deriveDetail(toolName: string, input: unknown, output: unknown): ToolCa return parsed.data; } +function toCanonicalDetail( + knownDetail: ToolCallDetail | undefined, + rawInput: unknown | null, + rawOutput: unknown | null +): ToolCallDetail { + if (knownDetail) { + return knownDetail; + } + + return { + type: "unknown", + rawInput, + rawOutput, + }; +} + export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTimelineItem { const parsedParams = OpencodeToolCallParamsSchema.parse(params); const input = parsedParams.input ?? null; const output = parsedParams.output ?? null; const status = resolveStatus(parsedParams.status, parsedParams.error, output); const callId = coerceCallId(parsedParams.callId, parsedParams.toolName, input); - const detail = deriveDetail(parsedParams.toolName, input, output); + const detail = toCanonicalDetail(deriveDetail(parsedParams.toolName, input, output), input, output); if (status === "failed") { return { @@ -646,10 +662,8 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim callId, name: parsedParams.toolName, status: "failed", - input, - output, error: parsedParams.error ?? { message: "Tool call failed" }, - ...(detail ? { detail } : {}), + detail, ...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}), }; } @@ -659,10 +673,8 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim callId, name: parsedParams.toolName, status, - input, - output, error: null, - ...(detail ? { detail } : {}), + detail, ...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}), }; } diff --git a/packages/server/src/server/messages.test.ts b/packages/server/src/server/messages.test.ts index a5dd83da3..670ae476d 100644 --- a/packages/server/src/server/messages.test.ts +++ b/packages/server/src/server/messages.test.ts @@ -33,8 +33,10 @@ describe("serializeAgentStreamEvent", () => { callId: "call_1", name: "shell", status: "running", - input: { command: "pwd" }, - output: null, + detail: { + type: "shell", + command: "pwd", + }, error: null, }, }; @@ -45,7 +47,6 @@ describe("serializeAgentStreamEvent", () => { throw new Error("Expected timeline.tool_call event"); } expect(serialized.item.status).toBe("running"); - expect(serialized.item.output).toBeNull(); expect(serialized.item.error).toBeNull(); }); @@ -58,7 +59,11 @@ describe("serializeAgentStreamEvent", () => { callId: "call_legacy", name: "shell", status: "inProgress", - input: { command: "pwd" }, + detail: { + type: "unknown", + rawInput: { command: "pwd" }, + rawOutput: null, + }, }, } satisfies unknown; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 51e8b29d8..d361880d6 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1930,11 +1930,14 @@ export class Session { name: "paseo_worktree_setup", callId, status: "running", - input: { - worktreePath: worktree.worktreePath, - branchName: worktree.branchName, + detail: { + type: "unknown", + rawInput: { + worktreePath: worktree.worktreePath, + branchName: worktree.branchName, + }, + rawOutput: null, }, - output: null, error: null, }); if (!started) { @@ -1952,18 +1955,21 @@ export class Session { name: "paseo_worktree_setup", callId, status: "completed", - input: { - worktreePath: worktree.worktreePath, - branchName: worktree.branchName, - }, - output: { - worktreePath: worktree.worktreePath, - commands: results.map((result) => ({ - command: result.command, - cwd: result.cwd, - exitCode: result.exitCode, - output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(), - })), + detail: { + type: "unknown", + rawInput: { + worktreePath: worktree.worktreePath, + branchName: worktree.branchName, + }, + rawOutput: { + worktreePath: worktree.worktreePath, + commands: results.map((result) => ({ + command: result.command, + cwd: result.cwd, + exitCode: result.exitCode, + output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(), + })), + }, }, error: null, }); @@ -1977,18 +1983,21 @@ export class Session { name: "paseo_worktree_setup", callId, status: "failed", - input: { - worktreePath: worktree.worktreePath, - branchName: worktree.branchName, - }, - output: { - worktreePath: worktree.worktreePath, - commands: results.map((result) => ({ - command: result.command, - cwd: result.cwd, - exitCode: result.exitCode, - output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(), - })), + detail: { + type: "unknown", + rawInput: { + worktreePath: worktree.worktreePath, + branchName: worktree.branchName, + }, + rawOutput: { + worktreePath: worktree.worktreePath, + commands: results.map((result) => ({ + command: result.command, + cwd: result.cwd, + exitCode: result.exitCode, + output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(), + })), + }, }, error: { message }, }); diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 141024df0..a5da1ff12 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -246,8 +246,11 @@ class FakeAgentSession implements AgentSession { name: tool.name, callId, status: "running", - input: tool.input ?? null, - output: null, + detail: { + type: "unknown", + rawInput: tool.input ?? null, + rawOutput: null, + }, error: null, }, }; @@ -327,8 +330,11 @@ class FakeAgentSession implements AgentSession { name: tool.name, callId, status: "completed", - input: tool.input ?? null, - output: toolOutput ?? { ok: true }, + detail: { + type: "unknown", + rawInput: tool.input ?? null, + rawOutput: toolOutput ?? { ok: true }, + }, error: null, }, }; diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index 1c307c3aa..1b477867e 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -33,13 +33,11 @@ describe("shared messages stream parsing", () => { callId: "call_live", name: "shell", status: "running", - input: { command: "ls" }, - output: null, - error: null, detail: { type: "shell", command: "ls", }, + error: null, }, }, }, diff --git a/packages/server/src/shared/messages.tool-call-schema.test.ts b/packages/server/src/shared/messages.tool-call-schema.test.ts index 5f0369ab7..a5e091c6a 100644 --- a/packages/server/src/shared/messages.tool-call-schema.test.ts +++ b/packages/server/src/shared/messages.tool-call-schema.test.ts @@ -7,8 +7,10 @@ function canonicalBase() { type: "tool_call" as const, callId: "call_123", name: "shell", - input: { command: "pwd" }, - output: null, + detail: { + type: "shell" as const, + command: "pwd", + }, }; } @@ -18,17 +20,12 @@ describe("shared messages tool_call schema", () => { ...canonicalBase(), status: "running", error: null, - detail: { - type: "shell", - command: "pwd", - }, }); const completed = AgentTimelineItemPayloadSchema.parse({ ...canonicalBase(), status: "completed", error: null, - output: { output: "/tmp/repo" }, }); const failed = AgentTimelineItemPayloadSchema.parse({ @@ -54,8 +51,10 @@ describe("shared messages tool_call schema", () => { type: "tool_call", name: "shell", status: "running", - input: { command: "pwd" }, - output: null, + detail: { + type: "shell", + command: "pwd", + }, error: null, }); @@ -82,12 +81,11 @@ describe("shared messages tool_call schema", () => { error: null, }); - const missingOutput = AgentTimelineItemPayloadSchema.safeParse({ + const missingDetail = AgentTimelineItemPayloadSchema.safeParse({ type: "tool_call", - callId: "call_missing_output", + callId: "call_missing_detail", name: "shell", status: "running", - input: { command: "pwd" }, error: null, }); @@ -99,7 +97,7 @@ describe("shared messages tool_call schema", () => { expect(completedWithError.success).toBe(false); expect(failedWithoutError.success).toBe(false); - expect(missingOutput.success).toBe(false); + expect(missingDetail.success).toBe(false); expect(legacyStatus.success).toBe(false); }); }); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 998821b32..39ade982d 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -140,14 +140,22 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType = z.discriminatedUnion("type", [ z.object({ @@ -180,32 +188,18 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discriminatedUn type: z.literal("search"), query: z.string(), }), -]); - -const NonUndefinedUnknownSchema = z.union([ - z.null(), - z.boolean(), - z.number(), - z.string(), - z.array(z.unknown()), - z.object({}).passthrough(), -]); - -const NonNullUnknownSchema = z.union([ - z.boolean(), - z.number(), - z.string(), - z.array(z.unknown()), - z.object({}).passthrough(), + z.object({ + type: z.literal("unknown"), + rawInput: UnknownValueSchema, + rawOutput: UnknownValueSchema, + }), ]); const ToolCallBasePayloadSchema = z.object({ type: z.literal("tool_call"), callId: z.string(), name: z.string(), - input: NonUndefinedUnknownSchema, - output: NonUndefinedUnknownSchema, - detail: ToolCallDetailPayloadSchema.optional(), + detail: ToolCallDetailPayloadSchema, metadata: z.record(z.unknown()).optional(), }); diff --git a/packages/server/src/shared/tool-call-display.test.ts b/packages/server/src/shared/tool-call-display.test.ts index e27043e13..f0234f653 100644 --- a/packages/server/src/shared/tool-call-display.test.ts +++ b/packages/server/src/shared/tool-call-display.test.ts @@ -7,8 +7,6 @@ describe("shared tool-call display mapping", () => { const display = buildToolCallDisplayModel({ name: "read_file", status: "running", - input: { path: "/tmp/repo/src/index.ts" }, - output: null, error: null, detail: { type: "read", @@ -23,13 +21,16 @@ describe("shared tool-call display mapping", () => { }); }); - it("does not infer summaries from raw input when detail is missing", () => { + it("does not infer summaries from unknown raw detail", () => { const display = buildToolCallDisplayModel({ name: "exec_command", status: "running", - input: { command: "npm test" }, - output: null, error: null, + detail: { + type: "unknown", + rawInput: { command: "npm test" }, + rawOutput: null, + }, }); expect(display).toEqual({ @@ -37,13 +38,16 @@ describe("shared tool-call display mapping", () => { }); }); - it("keeps task metadata summary without detail", () => { + it("keeps task metadata summary on unknown detail", () => { const display = buildToolCallDisplayModel({ name: "task", status: "running", - input: null, - output: null, error: null, + detail: { + type: "unknown", + rawInput: null, + rawOutput: null, + }, metadata: { subAgentActivity: "Running tests", }, @@ -59,9 +63,12 @@ describe("shared tool-call display mapping", () => { const display = buildToolCallDisplayModel({ name: "shell", status: "failed", - input: null, - output: null, error: { message: "boom" }, + detail: { + type: "unknown", + rawInput: null, + rawOutput: null, + }, }); expect(display.errorText).toBe('{\n "message": "boom"\n}'); diff --git a/packages/server/src/shared/tool-call-display.ts b/packages/server/src/shared/tool-call-display.ts index 99a76a7e3..2d6550099 100644 --- a/packages/server/src/shared/tool-call-display.ts +++ b/packages/server/src/shared/tool-call-display.ts @@ -2,7 +2,7 @@ import type { ToolCallTimelineItem } from "../server/agent/agent-sdk-types.js"; export type ToolCallDisplayInput = Pick< ToolCallTimelineItem, - "name" | "status" | "input" | "output" | "error" | "metadata" | "detail" + "name" | "status" | "error" | "metadata" | "detail" > & { cwd?: string; }; @@ -79,33 +79,35 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall let displayName = humanizeToolName(input.name); let summary: string | undefined; - if (input.detail) { - switch (input.detail.type) { - case "shell": - displayName = "Shell"; - summary = input.detail.command; - break; - case "read": - displayName = "Read"; - summary = stripCwdPrefix(input.detail.filePath, input.cwd); - break; - case "edit": - displayName = "Edit"; - summary = stripCwdPrefix(input.detail.filePath, input.cwd); - break; - case "write": - displayName = "Write"; - summary = stripCwdPrefix(input.detail.filePath, input.cwd); - break; - case "search": - displayName = "Search"; - summary = input.detail.query; - break; - } - } else if (lowerName === "task") { + switch (input.detail.type) { + case "shell": + displayName = "Shell"; + summary = input.detail.command; + break; + case "read": + displayName = "Read"; + summary = stripCwdPrefix(input.detail.filePath, input.cwd); + break; + case "edit": + displayName = "Edit"; + summary = stripCwdPrefix(input.detail.filePath, input.cwd); + break; + case "write": + displayName = "Write"; + summary = stripCwdPrefix(input.detail.filePath, input.cwd); + break; + case "search": + displayName = "Search"; + summary = input.detail.query; + break; + case "unknown": + break; + } + + if (lowerName === "task" && input.detail.type === "unknown") { displayName = "Task"; summary = isRecord(input.metadata) ? readString(input.metadata.subAgentActivity) : undefined; - } else if (lowerName === "thinking") { + } else if (lowerName === "thinking" && input.detail.type === "unknown") { displayName = "Thinking"; }