From 7ec3f2d3cf4ed168cb6f877824d898ad4c4cc65b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Feb 2026 16:50:04 +0700 Subject: [PATCH] refactor tool-call detail contract for unknown tools --- .../app/src/components/agent-activity.tsx | 10 ++-- .../app/src/components/agent-stream-view.tsx | 4 +- packages/app/src/components/message.tsx | 12 ++--- .../app/src/components/tool-call-details.tsx | 4 +- packages/app/src/types/agent-activity.ts | 36 ++++++------- packages/app/src/types/stream-buffer.test.ts | 4 +- packages/app/src/types/stream.harness.test.ts | 4 +- packages/app/src/types/stream.test.ts | 12 ++--- packages/app/src/types/stream.ts | 14 +++--- .../app/src/utils/tool-call-display.test.ts | 16 +++--- .../server/src/client/daemon-client.test.ts | 4 +- .../src/server/agent/activity-curator.test.ts | 4 +- .../src/server/agent/activity-curator.ts | 12 ++--- .../src/server/agent/agent-sdk-types.ts | 4 +- .../agent/providers/claude-agent.test.ts | 24 +++++---- .../claude/tool-call-detail-parser.ts | 12 +++-- .../providers/claude/tool-call-mapper.test.ts | 4 +- .../providers/claude/tool-call-mapper.ts | 10 ++-- .../providers/codex-app-server-agent.test.ts | 44 +++++++++------- .../codex/tool-call-detail-parser.ts | 12 +++-- .../providers/codex/tool-call-mapper.test.ts | 4 +- .../agent/providers/codex/tool-call-mapper.ts | 50 +++++++++---------- .../opencode/tool-call-detail-parser.ts | 12 +++-- .../opencode/tool-call-mapper.test.ts | 4 +- .../providers/opencode/tool-call-mapper.ts | 8 +-- packages/server/src/server/messages.test.ts | 34 ++++++++++++- packages/server/src/server/session.ts | 12 ++--- .../server/test-utils/fake-agent-client.ts | 8 +-- .../shared/messages.tool-call-schema.test.ts | 9 ++++ packages/server/src/shared/messages.ts | 6 +-- .../src/shared/tool-call-display.test.ts | 12 ++--- 31 files changed, 228 insertions(+), 177 deletions(-) diff --git a/packages/app/src/components/agent-activity.tsx b/packages/app/src/components/agent-activity.tsx index f8ffb165e..b2f806cc1 100644 --- a/packages/app/src/components/agent-activity.tsx +++ b/packages/app/src/components/agent-activity.tsx @@ -106,23 +106,23 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) { {isExpanded && ( - {item.rawInput && ( + {item.input && ( Input: - {JSON.stringify(item.rawInput, null, 2)} + {JSON.stringify(item.input, null, 2)} )} - {item.rawOutput && ( + {item.output && ( Output: - {JSON.stringify(item.rawOutput, null, 2)} + {JSON.stringify(item.output, null, 2)} )} - {!item.rawInput && !item.rawOutput && ( + {!item.input && !item.output && ( No details available diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 9cca92a92..75659e3d9 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -1101,8 +1101,8 @@ function PermissionRequestCard({ diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 62cfd72f9..75a78baed 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1198,8 +1198,8 @@ export const ToolCall = memo(function ToolCall({ if (args !== undefined || result !== undefined) { return { type: "unknown", - rawInput: args ?? null, - rawOutput: result ?? null, + input: args ?? null, + output: result ?? null, }; } return undefined; @@ -1208,8 +1208,8 @@ export const ToolCall = memo(function ToolCall({ const displayDetail = effectiveDetail ?? { type: "unknown", - rawInput: null, - rawOutput: null, + input: null, + output: null, }; const displayModel = useMemo( @@ -1235,8 +1235,8 @@ export const ToolCall = memo(function ToolCall({ Boolean(error) || (effectiveDetail ? effectiveDetail.type !== "unknown" || - effectiveDetail.rawInput !== null || - effectiveDetail.rawOutput !== null + effectiveDetail.input !== null || + effectiveDetail.output !== null : false); const handleToggle = useCallback(() => { diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 4398420a2..0bedd487a 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -139,8 +139,8 @@ export function ToolCallDetailsContent({ ); } else if (detail?.type === "unknown") { const sectionsFromTopLevel = [ - { title: "Input", value: detail.rawInput }, - { title: "Output", value: detail.rawOutput }, + { title: "Input", value: detail.input }, + { title: "Output", value: detail.output }, ].filter((entry) => entry.value !== null && entry.value !== undefined); for (const section of sectionsFromTopLevel) { diff --git a/packages/app/src/types/agent-activity.ts b/packages/app/src/types/agent-activity.ts index d97512bc1..65b999fcd 100644 --- a/packages/app/src/types/agent-activity.ts +++ b/packages/app/src/types/agent-activity.ts @@ -41,8 +41,8 @@ export interface ToolCall { title: string; status?: 'pending' | 'in_progress' | 'completed' | 'failed'; toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other'; - rawInput?: Record; - rawOutput?: Record; + input?: Record; + output?: Record; content?: unknown[]; locations?: unknown[]; } @@ -53,8 +53,8 @@ export interface ToolCallUpdate { title?: string | null; status?: 'pending' | 'in_progress' | 'completed' | 'failed' | null; toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other' | null; - rawInput?: Record; - rawOutput?: Record; + input?: Record; + output?: Record; content?: unknown[] | null; locations?: unknown[] | null; } @@ -109,8 +109,8 @@ export interface MergedToolCall { title: string; status: 'pending' | 'in_progress' | 'completed' | 'failed'; toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other'; - rawInput?: Record; - rawOutput?: Record; + input?: Record; + output?: Record; content?: unknown[]; locations?: unknown[]; startTimestamp: Date; @@ -137,8 +137,8 @@ export function groupActivities(activities: AgentActivity[]): Array; - rawOutput?: Record; + input?: Record; + output?: Record; content?: unknown[]; locations?: unknown[]; startTimestamp: Date; @@ -210,8 +210,8 @@ export function groupActivities(activities: AgentActivity[]): Array ({ status: "running", detail: { type: "unknown", - rawInput: { command: "echo hi" }, - rawOutput: null, + input: { command: "echo hi" }, + output: null, }, error: null, }, diff --git a/packages/app/src/types/stream.harness.test.ts b/packages/app/src/types/stream.harness.test.ts index cb914f2df..0955d6484 100644 --- a/packages/app/src/types/stream.harness.test.ts +++ b/packages/app/src/types/stream.harness.test.ts @@ -215,8 +215,8 @@ function buildToolEvent({ }): AgentStreamEventPayload { const canonicalDetail: ToolCallDetail = detail ?? { type: "unknown", - rawInput: input, - rawOutput: output, + input: input, + output: output, }; const baseItem = { diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 48e6649d9..0468733e9 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -42,8 +42,8 @@ function canonicalToolTimeline(params: { }): AgentStreamEventPayload { const detail: ToolCallDetail = params.detail ?? { type: "unknown", - rawInput: params.input ?? null, - rawOutput: params.output ?? null, + input: params.input ?? null, + output: params.output ?? null, }; const baseItem = { @@ -150,8 +150,8 @@ describe("stream reducer canonical tool calls", () => { assert.strictEqual(tools[0].payload.data.status, "completed"); assert.deepStrictEqual(tools[0].payload.data.detail, { type: "unknown", - rawInput: { command: "pwd" }, - rawOutput: { + input: { command: "pwd" }, + output: { output: "/tmp/repo\n", exitCode: 0, }, @@ -282,8 +282,8 @@ describe("stream reducer canonical tool calls", () => { assert.ok(tool); assert.deepStrictEqual(tool.payload.data.detail, { type: "unknown", - rawInput: { path: "README.md" }, - rawOutput: { content: "hello" }, + input: { path: "README.md" }, + output: { 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 ae653ed9d..ffa8a8a83 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -353,20 +353,20 @@ function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail) if (existing.type === "unknown" && incoming.type === "unknown") { return { type: "unknown", - rawInput: mergeUnknownValue(existing.rawInput, incoming.rawInput), - rawOutput: mergeUnknownValue(existing.rawOutput, incoming.rawOutput), + input: mergeUnknownValue(existing.input, incoming.input), + output: mergeUnknownValue(existing.output, incoming.output), }; } if (existing.type === incoming.type) { - return { ...existing, ...incoming }; + return { ...existing, ...incoming } as ToolCallDetail; } return incoming; } -function rawInputFromDetail(detail: ToolCallDetail): unknown | null { - return detail.type === "unknown" ? detail.rawInput : null; +function inputFromUnknownDetail(detail: ToolCallDetail): unknown | null { + return detail.type === "unknown" ? detail.input : null; } function mergeAgentToolCallStatus( @@ -552,7 +552,7 @@ export function reduceStreamUpdate( // as Tasks when possible and otherwise hide it to avoid a stuck loading tool call. const tasks = extractTaskEntriesFromToolCall( item.name, - rawInputFromDetail(item.detail) + inputFromUnknownDetail(item.detail) ); if (tasks) { nextState = appendTodoList( @@ -570,7 +570,7 @@ export function reduceStreamUpdate( const tasks = extractTaskEntriesFromToolCall( item.name, - rawInputFromDetail(item.detail) + inputFromUnknownDetail(item.detail) ); if (tasks) { nextState = appendTodoList( diff --git a/packages/app/src/utils/tool-call-display.test.ts b/packages/app/src/utils/tool-call-display.test.ts index b85808fb2..c4f1e24ed 100644 --- a/packages/app/src/utils/tool-call-display.test.ts +++ b/packages/app/src/utils/tool-call-display.test.ts @@ -45,8 +45,8 @@ describe("tool-call-display", () => { error: null, detail: { type: "unknown", - rawInput: null, - rawOutput: null, + input: null, + output: null, }, metadata: { subAgentActivity: "Running tests", @@ -66,8 +66,8 @@ describe("tool-call-display", () => { error: null, detail: { type: "unknown", - rawInput: null, - rawOutput: null, + input: null, + output: null, }, }); @@ -83,8 +83,8 @@ describe("tool-call-display", () => { error: null, detail: { type: "unknown", - rawInput: { command: "npm run test" }, - rawOutput: null, + input: { command: "npm run test" }, + output: null, }, }); @@ -100,8 +100,8 @@ describe("tool-call-display", () => { error: { message: "boom" }, detail: { type: "unknown", - rawInput: { command: "false" }, - rawOutput: null, + input: { command: "false" }, + output: null, }, }); diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 1981c30c1..76ebf2487 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -344,8 +344,8 @@ describe("DaemonClient", () => { status: "inProgress", detail: { type: "unknown", - rawInput: { command: "pwd" }, - rawOutput: null, + input: { command: "pwd" }, + output: null, }, }, }, diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 6abe05452..62d871a8e 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -16,8 +16,8 @@ function toolCallItem(params: { const detail = params.detail ?? { type: "unknown" as const, - rawInput: params.input ?? null, - rawOutput: params.output ?? null, + input: params.input ?? null, + output: params.output ?? null, }; return { type: "tool_call", diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 46b026c40..76db52cd4 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -71,18 +71,18 @@ function mergeToolDetail(existing: ToolCallDetail, incoming: ToolCallDetail): To if (existing.type === "unknown" && incoming.type === "unknown") { return { type: "unknown", - rawInput: mergeUnknownValue(existing.rawInput, incoming.rawInput), - rawOutput: mergeUnknownValue(existing.rawOutput, incoming.rawOutput), + input: mergeUnknownValue(existing.input, incoming.input), + output: mergeUnknownValue(existing.output, incoming.output), }; } if (existing.type === incoming.type) { - return { ...existing, ...incoming }; + return { ...existing, ...incoming } as ToolCallDetail; } return incoming; } -function rawInputFromDetail(detail: ToolCallDetail): unknown { - return detail.type === "unknown" ? detail.rawInput : null; +function inputFromUnknownDetail(detail: ToolCallDetail): unknown { + return detail.type === "unknown" ? detail.input : null; } /** @@ -203,7 +203,7 @@ export function curateAgentActivity( break; case "tool_call": { flushBuffers(lines, buffers); - const inputJson = formatToolInputJson(rawInputFromDetail(item.detail)); + const inputJson = formatToolInputJson(inputFromUnknownDetail(item.detail)); const display = buildToolCallDisplayModel({ name: item.name, status: item.status, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 30f795587..633a9b163 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -136,8 +136,8 @@ export type ToolCallDetail = } | { type: "unknown"; - rawInput: unknown | null; - rawOutput: unknown | null; + input: unknown | null; + output: unknown | null; }; type ToolCallBase = { 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 2757952ff..7a8a8063e 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -97,7 +97,7 @@ function extractToolCommand(detail: unknown): string | null { return detail.command; } if (detail.type === "unknown") { - return extractCommandText(detail.rawInput); + return extractCommandText(detail.input); } return null; } @@ -245,7 +245,6 @@ async function startAgentMcpServer(): Promise { let hydrateStreamState: (updates: unknown[]) => unknown = () => { throw new Error("hydrateStreamState not initialized"); }; - let isAgentToolCallItem: (item: unknown) => boolean = () => false; let agentMcpServer: AgentMcpServerHandle; let restoreClaudeConfigDir: (() => void) | null = null; const buildConfig = ( @@ -271,7 +270,6 @@ async function startAgentMcpServer(): Promise { beforeAll(async () => { const stream = await import("../../../../../app/src/types/stream.js"); hydrateStreamState = stream.hydrateStreamState as typeof hydrateStreamState; - isAgentToolCallItem = stream.isAgentToolCallItem as typeof isAgentToolCallItem; }); beforeAll(async () => { agentMcpServer = await startAgentMcpServer(); @@ -450,8 +448,8 @@ async function startAgentMcpServer(): Promise { } if (item.detail.type === "unknown") { return ( - rawContainsText(item.detail.rawInput, "tool-test.txt") || - rawContainsText(item.detail.rawOutput, "tool-test.txt") + rawContainsText(item.detail.input, "tool-test.txt") || + rawContainsText(item.detail.output, "tool-test.txt") ); } return rawContainsText(item.detail, "tool-test.txt"); @@ -1330,10 +1328,18 @@ function stateIncludesUserMessage(state: StreamItem[], marker: string): boolean } function extractAgentToolSnapshots(state: StreamItem[]): ToolSnapshot[] { - return state.filter(isAgentToolCallItem).map((item) => ({ - key: buildToolSnapshotKey(item.payload.data, item.id), - data: item.payload.data, - })); + return state + .filter( + (item): item is { kind: "tool_call"; id: string; payload: { source: "agent"; data: AgentToolCallData } } => + Boolean(item) && + item.kind === "tool_call" && + item.payload?.source === "agent" && + item.payload?.data + ) + .map((item) => ({ + key: buildToolSnapshotKey(item.payload.data, item.id), + data: item.payload.data, + })); } function buildToolSnapshotKey(data: AgentToolCallData, fallbackId: string): string { diff --git a/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts index 73ecdf4da..b1d21ff55 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts @@ -54,14 +54,18 @@ export function deriveClaudeToolDetail( name: string, input: unknown, output: unknown -): ToolCallDetail | undefined { +): ToolCallDetail { const parsed = ClaudeKnownToolDetailSchema.safeParse({ name, input, output, }); - if (!parsed.success) { - return undefined; + if (parsed.success && parsed.data) { + return parsed.data; } - return parsed.data; + return { + type: "unknown", + input: input ?? null, + output: output ?? null, + }; } 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 ece395730..9304cb4d3 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 @@ -187,8 +187,8 @@ describe("claude tool-call mapper", () => { expect(item.error).toBeNull(); expect(item.detail).toEqual({ type: "unknown", - rawInput: { foo: "bar" }, - rawOutput: { ok: true }, + input: { foo: "bar" }, + output: { 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 53eb0e949..b7b3d1e48 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 @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js"; +import type { ToolCallTimelineItem } from "../../agent-sdk-types.js"; import { coerceToolCallId } from "../tool-call-mapper-utils.js"; import { deriveClaudeToolDetail } from "./tool-call-detail-parser.js"; @@ -38,9 +38,7 @@ function coerceCallId(callId: string | null | undefined, name: string, input: un function buildBase(params: MapperParams): { callId: string; name: string; - input: unknown | null; - output: unknown | null; - detail?: ToolCallDetail; + detail: Extract["detail"]; metadata?: Record; } { const parsedParams = ClaudeMapperParamsSchema.parse(params); @@ -51,9 +49,7 @@ function buildBase(params: MapperParams): { 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 4c06fa29a..085430355 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 @@ -48,35 +48,43 @@ function useTempCodexSessionDir(): () => void { } function hasShellCommand(item: AgentTimelineItem, commandFragment: string): boolean { - if (item.type !== "tool_call" || item.name !== "shell") return false; + if (item.type !== "tool_call") return false; 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 }) + item.detail.type === "unknown" && typeof item.detail.input === "object" && item.detail.input + ? (item.detail.input as { command?: string | string[]; cmd?: string | string[] }) : undefined; - const command = unknownInput?.command ?? ""; + const commandValue = unknownInput?.command ?? unknownInput?.cmd; + const command = + typeof commandValue === "string" + ? commandValue + : Array.isArray(commandValue) + ? commandValue.filter((value): value is string => typeof value === "string").join(" ") + : ""; return command.includes(commandFragment); } function hasApplyPatchFile(item: AgentTimelineItem, fileName: string): boolean { - if (item.type !== "tool_call" || item.name !== "apply_patch") return false; + if (item.type !== "tool_call") return false; 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 }> }) + item.detail.type === "unknown" && typeof item.detail.input === "object" && item.detail.input + ? (item.detail.input as { path?: string; file_path?: string; filePath?: string; 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 }) + item.detail.type === "unknown" && typeof item.detail.output === "object" && item.detail.output + ? (item.detail.output as { path?: string; file_path?: string; filePath?: string; files?: Array<{ path?: string; patch?: string }>; diff?: string }) : undefined; + const inputPath = unknownInput?.path ?? unknownInput?.file_path ?? unknownInput?.filePath; + const outputPath = unknownOutput?.path ?? unknownOutput?.file_path ?? unknownOutput?.filePath; 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; + return inInput || inOutput || inDiff || inputPath === fileName || outputPath === fileName; } async function waitForFileToContainText( @@ -363,7 +371,7 @@ describe("Codex app-server provider (integration)", () => { if (call.name !== "paseo_test.echo") { continue; } - const key = String(call.callId ?? `${call.name}:${JSON.stringify(call.input ?? {})}`); + const key = String(call.callId ?? `${call.name}:${JSON.stringify(call.detail)}`); const existing = distinctMcpCalls.get(key); if (!existing || call.status === "completed") { distinctMcpCalls.set(key, call); @@ -382,8 +390,9 @@ describe("Codex app-server provider (integration)", () => { expect(toolNames.some((name) => name.toLowerCase().includes("shell"))).toBe(false); // Hard assertion: roundtrip token must be present in the MCP tool I/O. - expect(JSON.stringify(mcpToolCall.input ?? {})).toContain(token); - expect(JSON.stringify(mcpToolCall.output ?? {})).toContain(`ECHO:${token}`); + const mcpDetail = mcpToolCall.detail.type === "unknown" ? mcpToolCall.detail : null; + expect(JSON.stringify(mcpDetail?.input ?? {})).toContain(token); + expect(JSON.stringify(mcpDetail?.output ?? {})).toContain(`ECHO:${token}`); expect(result.finalText).toContain(`ECHO:${token}`); } finally { cleanup(); @@ -496,7 +505,7 @@ describe("Codex app-server provider (integration)", () => { } expect(sawPermission || timelineItems.length > 0).toBe(true); expect( - timelineItems.some((item) => item.type === "tool_call" && item.name === "shell") + timelineItems.some((item) => hasShellCommand(item, "printf")) ).toBe(true); expect(existsSync(filePath)).toBe(true); expect(readFileSync(filePath, "utf8")).toContain("ok"); @@ -629,12 +638,10 @@ describe("Codex app-server provider (integration)", () => { expect(shellItem?.type).toBe("tool_call"); expect(patchItem?.type).toBe("tool_call"); if (shellItem?.type === "tool_call") { - expect(shellItem.name).toBe("shell"); - expect(shellItem.input).toBeTruthy(); + expect(hasShellCommand(shellItem, "printf")).toBe(true); } if (patchItem?.type === "tool_call") { - expect(patchItem.name).toBe("apply_patch"); - expect(patchItem.input || patchItem.output).toBeTruthy(); + expect(hasApplyPatchFile(patchItem, "patch.txt")).toBe(true); } } finally { cleanup(); @@ -698,7 +705,6 @@ describe("Codex app-server provider (integration)", () => { if ( event.type === "timeline" && event.item.type === "tool_call" && - event.item.name === "shell" && hasShellCommand(event.item, "sleep 60") ) { sawSleepCommand = true; diff --git a/packages/server/src/server/agent/providers/codex/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/codex/tool-call-detail-parser.ts index 545d722e3..268e298d6 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-detail-parser.ts @@ -134,15 +134,19 @@ export function deriveCodexToolDetail(params: { input: unknown; output: unknown; cwd?: string | null; -}): ToolCallDetail | undefined { +}): ToolCallDetail { const parsed = CodexKnownToolDetailSchema.safeParse({ name: params.name, input: params.input, output: params.output, cwd: params.cwd ?? null, }); - if (!parsed.success) { - return undefined; + if (parsed.success && parsed.data) { + return parsed.data; } - return parsed.data; + return { + type: "unknown", + input: params.input ?? null, + output: params.output ?? null, + }; } 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 b210028cf..c8bfd1db4 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 @@ -235,8 +235,8 @@ describe("codex tool-call mapper", () => { expect(item.error).toBeNull(); expect(item.detail).toEqual({ type: "unknown", - rawInput: { foo: "bar" }, - rawOutput: { ok: true }, + input: { foo: "bar" }, + output: { ok: true }, }); expect(item.callId).toBe("codex-call-4"); }); 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 02e5182dc..471e5e85d 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 @@ -140,10 +140,8 @@ function buildToolCall(params: { callId: string; name: string; status: ToolCallTimelineItem["status"]; - input: unknown | null; - output: unknown | null; error: unknown | null; - detail?: ToolCallDetail; + detail: ToolCallDetail; metadata?: Record; }): ToolCallTimelineItem { if (params.status === "failed") { @@ -152,10 +150,8 @@ function buildToolCall(params: { 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.detail, ...(params.metadata ? { metadata: params.metadata } : {}), }; } @@ -165,10 +161,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.detail, ...(params.metadata ? { metadata: params.metadata } : {}), }; } @@ -221,7 +215,11 @@ function mapCommandExecutionItem( ...(item.aggregatedOutput ? { output: item.aggregatedOutput } : {}), ...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}), } - : undefined; + : { + type: "unknown" as const, + input, + output, + }; const name = "shell"; const callId = coerceCallId(item.id, name, input); @@ -232,10 +230,8 @@ function mapCommandExecutionItem( callId, name, status, - input, - output, error, - ...(detail ? { detail } : {}), + detail, }); } @@ -290,7 +286,11 @@ function mapFileChangeItem( filePath: firstFile.path, ...(firstFile.diff !== undefined ? { unifiedDiff: truncateDiffText(firstFile.diff) } : {}), } - : undefined; + : { + type: "unknown" as const, + input, + output, + }; const name = "apply_patch"; const callId = coerceCallId(item.id, name, input); @@ -301,10 +301,8 @@ function mapFileChangeItem( callId, name, status, - input, - output, error, - ...(detail ? { detail } : {}), + detail, }); } @@ -330,10 +328,8 @@ function mapMcpToolCallItem( callId, name, status, - input, - output, error, - ...(detail ? { detail } : {}), + detail, }); } @@ -349,16 +345,18 @@ function mapWebSearchItem(item: z.infer): ToolC type: "search" as const, query: item.query, } - : undefined; + : { + type: "unknown" as const, + input, + output, + }; return buildToolCall({ callId, name, status, - input, - output, error, - ...(detail ? { detail } : {}), + detail, }); } @@ -420,9 +418,7 @@ export function mapCodexRolloutToolCall(params: { callId, name: parsed.name, status, - input, - output, error, - ...(detail ? { detail } : {}), + detail, }); } diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts index 9b85c78c6..f1681c30c 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts @@ -43,14 +43,18 @@ export function deriveOpencodeToolDetail( toolName: string, input: unknown, output: unknown -): ToolCallDetail | undefined { +): ToolCallDetail { const parsed = OpencodeKnownToolDetailSchema.safeParse({ toolName, input, output, }); - if (!parsed.success) { - return undefined; + if (parsed.success && parsed.data) { + return parsed.data; } - return parsed.data; + return { + type: "unknown", + input: input ?? null, + output: output ?? null, + }; } 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 cb5e985dd..8a8aaf757 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 @@ -194,8 +194,8 @@ describe("opencode tool-call mapper", () => { expect(item.error).toBeNull(); expect(item.detail).toEqual({ type: "unknown", - rawInput: { foo: "bar" }, - rawOutput: { ok: true }, + input: { foo: "bar" }, + output: { 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 593bf114d..a9af1efca 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 @@ -81,10 +81,8 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim callId, name: parsedParams.toolName, status: "failed", - input, - output, + detail, error: parsedParams.error ?? { message: "Tool call failed" }, - ...(detail ? { detail } : {}), ...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}), }; } @@ -94,10 +92,8 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim callId, name: parsedParams.toolName, status, - input, - output, + detail, error: null, - ...(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 670ae476d..2e6184b20 100644 --- a/packages/server/src/server/messages.test.ts +++ b/packages/server/src/server/messages.test.ts @@ -50,6 +50,36 @@ describe("serializeAgentStreamEvent", () => { expect(serialized.item.error).toBeNull(); }); + test("passes unknown-detail tool_call payloads through unchanged", () => { + const event: AgentStreamEvent = { + type: "timeline", + provider: "codex", + item: { + type: "tool_call", + callId: "call_unknown", + name: "paseo_voice.speak", + status: "completed", + detail: { + type: "unknown", + input: { text: "hello" }, + output: { ok: true }, + }, + error: null, + }, + }; + + const serialized = serializeAgentStreamEvent(event); + expect(serialized).not.toBeNull(); + if (!serialized || serialized.type !== "timeline" || serialized.item.type !== "tool_call") { + throw new Error("Expected timeline.tool_call event"); + } + expect(serialized.item.detail).toEqual({ + type: "unknown", + input: { text: "hello" }, + output: { ok: true }, + }); + }); + test("drops invalid legacy tool_call items", () => { const event = { type: "timeline", @@ -61,8 +91,8 @@ describe("serializeAgentStreamEvent", () => { status: "inProgress", detail: { type: "unknown", - rawInput: { command: "pwd" }, - rawOutput: null, + input: { command: "pwd" }, + output: null, }, }, } satisfies unknown; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 6c35c387a..246c1eb37 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2092,11 +2092,11 @@ export class Session { status: "running", detail: { type: "unknown", - rawInput: { + input: { worktreePath: worktree.worktreePath, branchName: worktree.branchName, }, - rawOutput: null, + output: null, }, error: null, }); @@ -2117,11 +2117,11 @@ export class Session { status: "completed", detail: { type: "unknown", - rawInput: { + input: { worktreePath: worktree.worktreePath, branchName: worktree.branchName, }, - rawOutput: { + output: { worktreePath: worktree.worktreePath, commands: results.map((result) => ({ command: result.command, @@ -2145,11 +2145,11 @@ export class Session { status: "failed", detail: { type: "unknown", - rawInput: { + input: { worktreePath: worktree.worktreePath, branchName: worktree.branchName, }, - rawOutput: { + output: { worktreePath: worktree.worktreePath, commands: results.map((result) => ({ command: result.command, 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 a5da1ff12..20bc0d75d 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -248,8 +248,8 @@ class FakeAgentSession implements AgentSession { status: "running", detail: { type: "unknown", - rawInput: tool.input ?? null, - rawOutput: null, + input: tool.input ?? null, + output: null, }, error: null, }, @@ -332,8 +332,8 @@ class FakeAgentSession implements AgentSession { status: "completed", detail: { type: "unknown", - rawInput: tool.input ?? null, - rawOutput: toolOutput ?? { ok: true }, + input: tool.input ?? null, + output: toolOutput ?? { ok: true }, }, 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 a5e091c6a..690a5cebe 100644 --- a/packages/server/src/shared/messages.tool-call-schema.test.ts +++ b/packages/server/src/shared/messages.tool-call-schema.test.ts @@ -64,8 +64,17 @@ describe("shared messages tool_call schema", () => { error: null, }); + const withTopLevelInputOutput = AgentTimelineItemPayloadSchema.safeParse({ + ...canonicalBase(), + status: "running", + error: null, + input: { command: "pwd" }, + output: { exitCode: 0 }, + }); + expect(missingCallId.success).toBe(false); expect(unknownStatus.success).toBe(false); + expect(withTopLevelInputOutput.success).toBe(false); }); it("rejects legacy status/error combinations without normalization", () => { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 5fbfa4eef..b0d87c752 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -190,8 +190,8 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discriminatedUn }), z.object({ type: z.literal("unknown"), - rawInput: UnknownValueSchema, - rawOutput: UnknownValueSchema, + input: UnknownValueSchema, + output: UnknownValueSchema, }), ]); @@ -201,7 +201,7 @@ const ToolCallBasePayloadSchema = z.object({ name: z.string(), detail: ToolCallDetailPayloadSchema, metadata: z.record(z.unknown()).optional(), -}); +}).strict(); const ToolCallRunningPayloadSchema = ToolCallBasePayloadSchema.extend({ status: z.literal("running"), diff --git a/packages/server/src/shared/tool-call-display.test.ts b/packages/server/src/shared/tool-call-display.test.ts index f0234f653..22c909f7d 100644 --- a/packages/server/src/shared/tool-call-display.test.ts +++ b/packages/server/src/shared/tool-call-display.test.ts @@ -28,8 +28,8 @@ describe("shared tool-call display mapping", () => { error: null, detail: { type: "unknown", - rawInput: { command: "npm test" }, - rawOutput: null, + input: { command: "npm test" }, + output: null, }, }); @@ -45,8 +45,8 @@ describe("shared tool-call display mapping", () => { error: null, detail: { type: "unknown", - rawInput: null, - rawOutput: null, + input: null, + output: null, }, metadata: { subAgentActivity: "Running tests", @@ -66,8 +66,8 @@ describe("shared tool-call display mapping", () => { error: { message: "boom" }, detail: { type: "unknown", - rawInput: null, - rawOutput: null, + input: null, + output: null, }, });