mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<T>(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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
toShellToolDetail,
|
||||
toWriteToolDetail,
|
||||
toolDetailBranchByToolName,
|
||||
parseKnownToolDetail,
|
||||
} from "../tool-call-detail-primitives.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -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",
|
||||
|
||||
@@ -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<T>(item: T | null): T {
|
||||
@@ -11,6 +12,33 @@ function expectMapped<T>(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({
|
||||
|
||||
@@ -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<Envelope extends ToolDetailEnvelope>(
|
||||
branches: z.ZodType<ToolCallDetail | null | undefined>,
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user