From abcccce846ac16f00f29cae8d504613ecfbdcf6a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 10 Nov 2026 11:05:31 +0700 Subject: [PATCH] Show a streamed tool call's final title and icon from its first frame (#9248) * fix(server): give streamed tool calls their final title from the first frame Claude opens a tool_use block with `input: {}` and OpenCode reports a pending part with `input: {}` before the arguments arrive. The shared detail primitives rejected the empty input, so the first row carried an `unknown` detail and rendered the raw tool name with a wrench icon ("Bash"), then switched to "Shell" and the terminal icon once the command arrived. Grep/Glob, WebFetch and MultiEdit flipped the same way, and WebFetch flipped again while only `url` had streamed. A known tool with no arguments and no output now yields its canonical detail with empty fields. The app already reads empty read/edit/write/ search/fetch fields as loading details; a shell with no command and no output now reads as loading too, instead of opening an empty sheet. * fix(server): keep a tool call's title while optional arguments arrive first Claude writes a tool's arguments in whatever order the model chooses, so a Bash call can stream `{description}` before `command`. That partial input failed the Bash schema and the row fell back to "Bash" with the wrench icon until the command arrived. Parsing now retries a call without output as having no arguments when its input does not yet satisfy its tool's schema. This happens once, over the whole branch set, so tools with several input shapes (OpenCode apply_patch) still reach the branch their complete input matches. --- .../app/src/tool-calls/presentation.test.ts | 18 ++++++ .../app/src/utils/tool-call-detail-state.ts | 2 +- .../claude/tool-call-detail-parser.ts | 7 +- .../providers/claude/tool-call-mapper.test.ts | 64 +++++++++++++++++++ .../codex/tool-call-detail-parser.ts | 7 +- .../opencode/tool-call-detail-parser.ts | 9 +-- .../opencode/tool-call-mapper.test.ts | 28 ++++++++ .../providers/tool-call-detail-primitives.ts | 48 ++++++++++++-- 8 files changed, 165 insertions(+), 18 deletions(-) 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; +}