diff --git a/packages/app/src/tool-calls/presentation.test.ts b/packages/app/src/tool-calls/presentation.test.ts index fc609ee8f6..7fb26cdcc8 100644 --- a/packages/app/src/tool-calls/presentation.test.ts +++ b/packages/app/src/tool-calls/presentation.test.ts @@ -58,6 +58,24 @@ describe("tool-call presentation", () => { }); }); + it("shows a shell call awaiting its command as Shell, loading its details", () => { + const presentation = buildToolCallPresentation({ + toolName: "Bash", + status: "running", + error: null, + detail: { type: "shell", command: "" }, + resolveIcon: fakeResolveIcon, + }); + + expect(presentation).toMatchObject({ + displayName: "Shell", + summary: undefined, + isLoadingDetails: true, + hasDetails: false, + canOpenDetails: true, + }); + }); + it("marks running calls without meaningful detail as loading details", () => { const presentation = buildToolCallPresentation({ toolName: "exec_command", diff --git a/packages/app/src/utils/tool-call-detail-state.ts b/packages/app/src/utils/tool-call-detail-state.ts index b28848af8e..5aa7f298ae 100644 --- a/packages/app/src/utils/tool-call-detail-state.ts +++ b/packages/app/src/utils/tool-call-detail-state.ts @@ -57,7 +57,7 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined): switch (detail.type) { case "shell": - return true; + return Boolean(detail.command.trim() || detail.output); case "read": return hasMeaningfulReadLikeDetail(detail); case "edit": 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 a63e8b715a..aa84f70370 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 @@ -23,6 +23,7 @@ import { toShellToolDetail, toWriteToolDetail, toolDetailBranchByName, + parseKnownToolDetail, } from "../tool-call-detail-primitives.js"; const ClaudeGrepOutputSchema = z @@ -232,9 +233,9 @@ export function deriveClaudeToolDetail( }; } - const pass2 = ClaudeToolDetailPass2Schema.safeParse(pass1.data); - if (pass2.success && pass2.data) { - return pass2.data; + const detail = parseKnownToolDetail(ClaudeToolDetailPass2Schema, pass1.data); + if (detail) { + return detail; } return { 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 f0b3ea2af1..5aaf4a8220 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 @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { mapClaudeCanceledToolCall, mapClaudeCompletedToolCall, @@ -16,6 +17,69 @@ function expectMapped(item: T | null): T { } describe("claude tool-call mapper", () => { + // Claude streams a tool_use block with `input: {}` first, then the arguments as partial JSON. + // An optional argument can complete before the identifying one. + it.each([ + { + name: "Bash", + stages: [ + { description: "Run a command" }, + { description: "Run a command", command: "ec" }, + { description: "Run a command", command: "echo hi" }, + ], + title: "Shell", + }, + { + name: "Read", + stages: [{ offset: 10 }, { offset: 10, file_path: "/tmp/a" }, { file_path: "/tmp/a.ts" }], + title: "Read", + }, + { + name: "Write", + stages: [{ content: "x" }, { file_path: "/tmp/a.ts", content: "x" }], + title: "Write", + }, + { + name: "MultiEdit", + stages: [ + { file_path: "/tmp/a.ts" }, + { file_path: "/tmp/a.ts", old_string: "a", new_string: "b" }, + ], + title: "Edit", + }, + { + name: "Grep", + stages: [{ output_mode: "content" }, { output_mode: "content", pattern: "foo" }], + title: "Search", + }, + { + name: "Glob", + stages: [{ path: "/tmp" }, { path: "/tmp", pattern: "*.ts" }], + title: "Search", + }, + { + name: "WebFetch", + stages: [{ prompt: "Summarize" }, { prompt: "Summarize", url: "https://example.com" }], + title: "Fetch", + }, + ])("keeps the $name title and detail kind while its arguments stream in", (tool) => { + const stages = [{}, ...tool.stages].map((input) => + expectMapped( + mapClaudeRunningToolCall({ + callId: "claude-stream-1", + name: tool.name, + input, + output: null, + }), + ), + ); + + expect(stages.map((item) => buildToolCallDisplayModel(item).displayName)).toEqual( + stages.map(() => tool.title), + ); + expect(new Set(stages.map((item) => item.detail.type)).size).toBe(1); + }); + it("preserves a plan's original tool identity and outcome for live and replayed results", () => { const proposal = { name: "ExitPlanMode", callId: "plan-tool-1", input: { plan: "Ship it" } }; const pending = expectMapped(mapClaudeRunningToolCall(proposal)); 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 df650e865c..e09c66beb6 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 @@ -18,6 +18,7 @@ import { toShellToolDetail, toWriteToolDetail, toolDetailBranchByNameWithCwd, + parseKnownToolDetail, } from "../tool-call-detail-primitives.js"; export interface CodexToolDetailContext { @@ -205,9 +206,9 @@ export function deriveCodexToolDetail(params: { }; } - const pass2 = CodexToolDetailPass2Schema.safeParse(pass1.data); - if (pass2.success && pass2.data) { - return pass2.data; + const detail = parseKnownToolDetail(CodexToolDetailPass2Schema, pass1.data); + if (detail) { + return detail; } return { 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 b3eb432f86..990c5713c6 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 @@ -20,6 +20,7 @@ import { toShellToolDetail, toWriteToolDetail, toolDetailBranchByToolName, + parseKnownToolDetail, } from "../tool-call-detail-primitives.js"; function isRecord(value: unknown): value is Record { @@ -366,13 +367,13 @@ export function deriveOpencodeToolDetail( } } - const parsed = OpencodeKnownToolDetailSchema.safeParse({ + const detail = parseKnownToolDetail(OpencodeKnownToolDetailSchema, { toolName, - input, + input: input ?? null, output, }); - if (parsed.success && parsed.data) { - return parsed.data; + if (detail) { + return detail; } return { type: "unknown", 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 eb38a04ac6..236ec53b5d 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 @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { mapOpencodeToolCall } from "./tool-call-mapper.js"; function expectMapped(item: T | null): T { @@ -11,6 +12,33 @@ function expectMapped(item: T | null): T { } describe("opencode tool-call mapper", () => { + // OpenCode reports a tool part as `pending` with `input: {}` before the running part carries input. + it.each([ + { toolName: "bash", input: { command: "echo hi" }, title: "Shell" }, + { toolName: "read", input: { filePath: "/tmp/a.ts" }, title: "Read" }, + { toolName: "grep", input: { pattern: "foo" }, title: "Search" }, + ])("keeps the $toolName title and detail kind from the pending part", (tool) => { + const stages = [ + { status: "pending", input: {} }, + { status: "running", input: tool.input }, + ].map((stage) => + expectMapped( + mapOpencodeToolCall({ + toolName: tool.toolName, + callId: "opencode-stream-1", + ...stage, + output: null, + }), + ), + ); + + expect(stages.map((item) => buildToolCallDisplayModel(item).displayName)).toEqual([ + tool.title, + tool.title, + ]); + expect(new Set(stages.map((item) => item.detail.type)).size).toBe(1); + }); + it("maps running shell calls", () => { const item = expectMapped( mapOpencodeToolCall({ diff --git a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts index 6d31961cb5..a2c49d2f27 100644 --- a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts +++ b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts @@ -12,6 +12,13 @@ import { export const CommandValueSchema = z.union([z.string(), z.array(z.string())]); type ToolDetailSchema = z.ZodType; + +// Neither arguments nor output have arrived: the detail keeps its kind with empty fields, +// which clients render as loading until the arguments fill it in. +function isAwaitingArguments(input: unknown, output: unknown): boolean { + return input === null && (output === null || output === undefined); +} + // Zod 4 loses the generic output type when these branch helpers constrain // schemas through z.ZodType, so the paired shape/value interfaces preserve the // transform callback types without changing runtime parsing. @@ -701,7 +708,7 @@ export const ToolGrepOutputSchema = z export const ToolWebFetchInputSchema = z .object({ url: z.string(), - prompt: z.string(), + prompt: z.string().optional(), }) .passthrough(); @@ -812,7 +819,7 @@ export function toShellToolDetail( ): ToolCallDetail | undefined { const command = input?.command ?? output?.command; if (!command) { - return undefined; + return isAwaitingArguments(input, output) ? { type: "shell", command: "" } : undefined; } return { @@ -831,7 +838,7 @@ export function toReadToolDetail( ): ToolCallDetail | undefined { const filePath = normalizeDetailPath(input?.filePath ?? output?.filePath, options?.normalizePath); if (!filePath) { - return undefined; + return isAwaitingArguments(input, output) ? { type: "read", filePath: "" } : undefined; } const stripped = output?.content ? stripReadLineNumberGutter(output.content) : undefined; @@ -854,7 +861,7 @@ export function toWriteToolDetail( ): ToolCallDetail | undefined { const filePath = normalizeDetailPath(input?.filePath ?? output?.filePath, options?.normalizePath); if (!filePath) { - return undefined; + return isAwaitingArguments(input, output) ? { type: "write", filePath: "" } : undefined; } const content = input?.content ?? output?.content; @@ -872,7 +879,7 @@ export function toEditToolDetail( ): ToolCallDetail | undefined { const filePath = normalizeDetailPath(input?.filePath ?? output?.filePath, options?.normalizePath); if (!filePath) { - return undefined; + return isAwaitingArguments(input, output) ? { type: "edit", filePath: "" } : undefined; } const newString = input?.newString ?? output?.newString; @@ -941,7 +948,9 @@ export function toSearchToolDetail(params: { }): ToolCallDetail | undefined { const { input, output, toolName } = params; if (!input?.query) { - return undefined; + return isAwaitingArguments(input, output) + ? { type: "search", query: "", ...(toolName ? { toolName } : {}) } + : undefined; } return { @@ -958,7 +967,7 @@ export function toFetchToolDetail( ): ToolCallDetail | undefined { const url = input?.url ?? output?.url; if (!url) { - return undefined; + return isAwaitingArguments(input, output) ? { type: "fetch", url: "" } : undefined; } return { @@ -1050,3 +1059,28 @@ export function toolDetailBranchByNameWithCwd< return mapper(parsedValue.input, parsedValue.output, parsedValue.cwd ?? null); }); } + +interface ToolDetailEnvelope { + input: unknown; + output: unknown; +} + +// A tool call streams before its arguments are complete: Claude's tool_use block opens with +// `input: {}` and fills in field by field, in whatever order the model writes them, and +// OpenCode's pending part carries `input: {}`. Until the call has output, arguments that do not +// yet satisfy the tool's schema are parsed as absent, so a known tool keeps its canonical detail +// kind (and its title and icon) from its first state. +export function parseKnownToolDetail( + branches: z.ZodType, + envelope: Envelope, +): ToolCallDetail | undefined { + const parsed = branches.safeParse(envelope); + if (parsed.success && parsed.data) { + return parsed.data; + } + if (envelope.input === null || (envelope.output !== null && envelope.output !== undefined)) { + return undefined; + } + const awaiting = branches.safeParse({ ...envelope, input: null }); + return awaiting.success && awaiting.data ? awaiting.data : undefined; +}