From 633b94326ec4b33fe19d65d02ef78d8a6b14e4e9 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 15 Oct 2026 09:19:43 +0900 Subject: feat(tool-call-cards): English tool names, no '{}' for argument-less calls, thinking-style dots while running --- src/features/chat/logic/tool-summary.test.ts | 81 +++++++++++++++++++++---- src/features/chat/logic/tool-summary.ts | 89 +++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 13 deletions(-) (limited to 'src/features/chat/logic') diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts index 876ede2..31c7002 100644 --- a/src/features/chat/logic/tool-summary.test.ts +++ b/src/features/chat/logic/tool-summary.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ToolEntry } from "../../../core/chunks"; -import { viewToolEntry } from "./tool-summary"; +import { toolLabel, viewToolEntry } from "./tool-summary"; function entry( name: string, @@ -23,6 +23,44 @@ const view = (name: string, input: unknown, content: string | null = "ok", isErr viewToolEntry(entry(name, input, content, isError)); const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`); +describe("tool labels", () => { + it.each([ + ["run_shell", "Run command"], + ["read_file", "Read file"], + ["edit_file", "Edit file"], + ["write_file", "Write file"], + ["web_search", "Web search"], + ["lsp", "Code intelligence"], + ["todo_write", "Update to-dos"], + ["load_skill", "Load skill"], + ["consult_vision", "Ask vision model"], + ["read_image", "Read image"], + ["youtube_transcript", "YouTube transcript"], + ["summon_agent", "Summon agent"], + ["send_agent_message", "Message agent"], + ["read_agent", "Read agent reply"], + ["open_agent", "Open agent"], + ["cancel_agent_message", "Cancel agent message"], + ["list_agents", "List agents"], + ["list_agent_models", "List agent models"], + ])("built-in %s → %s", (name, label) => { + expect(toolLabel(name)).toBe(label); + expect(view(name, {}).label).toBe(label); + expect(view(name, {}).name).toBe(name); + }); + it.each([ + ["github__create_issue", "Github: Create issue"], + ["mcp__github__create_issue", "Github: Create issue"], + ["playwright__browserTakeScreenshot", "Playwright: Browser take screenshot"], + ["scaffold_echo", "Scaffold echo"], + ["fetchUrl", "Fetch url"], + ["some-kebab-tool", "Some kebab tool"], + ["toString", "To string"], + ])("unknown %s is humanized → %s", (name, label) => { + expect(toolLabel(name)).toBe(label); + }); +}); + describe("tool summaries", () => { it.each([ ["run_shell", { command: "bun run test\n --run" }, "bun run test --run"], @@ -156,26 +194,38 @@ describe("tool summaries", () => { ])("generic fallback recognizes %s", (key) => { expect(view("mcp__foo", { [key]: "target" }).callSummary).toBe("target"); }); - it("generic fallback prefers the first meaningful argument, else compact JSON", () => { + it("generic fallback prefers the first meaningful argument, else readable key: value pairs", () => { expect(view("mcp__foo", { command: " ", path: "a", query: "b" }).callSummary).toBe("a"); - expect(view("mcp__foo", { a: 1, b: "x" }).callSummary).toBe('{"a":1,"b":"x"}'); + expect(view("mcp__foo", { a: 1, b: "x" }).callSummary).toBe("a: 1 · b: x"); + // Only nested values → no summary rather than a JSON dump. + expect(view("mcp__foo", { opts: { deep: true } }).callSummary).toBe(""); }); it.each([ "not JSON", "{bad", - null, - undefined, 42, true, [1, "a"], - {}, - "null", '"hi"', ])("tolerates malformed/non-object input %j", (input) => { expect(() => view("read_file", input)).not.toThrow(); expect(view("read_file", input).callSummary).not.toBe(""); expect(view("read_file", input).callDetail.kind).toBe("fields"); }); + it.each([ + null, + undefined, + {}, + [], + "", + " ", + "null", + "{}", + ])("an argument-less call (%j) shows no summary and a 'no arguments' detail — never `{}`", (input) => { + const card = view("list_agent_models", input); + expect(card.callSummary).toBe(""); + expect(card.callDetail).toEqual({ kind: "none" }); + }); it.each([ "run_shell", "read_file", @@ -190,10 +240,12 @@ describe("tool summaries", () => { "summon_agent", "read_agent", ])("%s falls back safely for missing arguments", (name) => { - for (const input of [null, undefined, {}, "{bad"]) { + for (const input of [null, undefined, {}]) { expect(() => view(name, input)).not.toThrow(); - expect(view(name, input).callSummary.trim()).not.toBe(""); + expect(view(name, input).callSummary).toBe(""); + expect(view(name, input).callDetail).toEqual({ kind: "none" }); } + expect(view(name, "{bad").callSummary).toBe("{bad"); }); it("parses JSON-string inputs", () => { const card = view("run_shell", '{"command":"bun run test"}'); @@ -201,10 +253,11 @@ describe("tool summaries", () => { expect(card.callDetail).toEqual({ kind: "command", text: "bun run test" }); }); it("handles unexpected built-in argument types and prototype-looking tool names", () => { - expect(view("todo_write", { todos: "wrong" }).callSummary).toBe('{"todos":"wrong"}'); + expect(view("todo_write", { todos: "wrong" }).callSummary).toBe("todos: wrong"); expect(view("run_shell", { command: { nested: true } }).callDetail.kind).toBe("fields"); expect(view("constructor", { path: "a" }).callSummary).toBe("a"); - expect(view("__proto__", {}).callSummary).toBe("{}"); + expect(view("__proto__", {}).callSummary).toBe(""); + expect(view("__proto__", {}).label).toBe("Proto"); expect( view("mcp", JSON.parse('{"__proto__":{"command":"bad"},"path":"good"}')).callSummary, ).toBe("good"); @@ -212,7 +265,11 @@ describe("tool summaries", () => { it("does not throw on unserializable input", () => { const cyclic: Record = {}; cyclic.self = cyclic; - expect(view("mcp", cyclic).callSummary).toBe("[unserializable input]"); + expect(view("mcp", cyclic).callSummary).toBe(""); + expect(view("mcp", cyclic).callDetail).toEqual({ + kind: "fields", + fields: [{ key: "self", value: "[unserializable input]", multiline: false }], + }); expect(() => view("mcp", { integer: 1n })).not.toThrow(); }); it("caps and whitespace-collapses header strings but retains full detail", () => { diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts index 3ea4a8e..75682de 100644 --- a/src/features/chat/logic/tool-summary.ts +++ b/src/features/chat/logic/tool-summary.ts @@ -1,7 +1,10 @@ import type { ToolEntry } from "../../../core/chunks"; export interface ToolCardView { + /** The raw wire tool name (tooltip / debugging). */ readonly name: string; + /** English display name, e.g. `run_shell` → "Run command" (see {@link toolLabel}). */ + readonly label: string; readonly status: "pending" | "ok" | "error"; readonly callSummary: string; readonly callBadges: readonly { @@ -12,6 +15,8 @@ export interface ToolCardView { readonly compactResultSummary: string | null; readonly callDetail: | { readonly kind: "unavailable"; readonly text: string } + /** The call took no arguments (`{}` / absent input) — rendered as a muted note. */ + | { readonly kind: "none" } | { readonly kind: "command"; readonly text: string } | { readonly kind: "edit"; @@ -130,6 +135,77 @@ function todoSummary(args: Args): string { const summarizeAgent: CallSummary = (args) => arg(args, "title", "task", "agent", "model"); const summarizeConversation: CallSummary = (args) => arg(args, "conversationId", "message"); +/** English names for Dispatch's built-in tools; anything else goes through {@link humanize}. */ +const TOOL_LABELS: Readonly> = { + run_shell: "Run command", + read_file: "Read file", + edit_file: "Edit file", + write_file: "Write file", + web_search: "Web search", + lsp: "Code intelligence", + todo_write: "Update to-dos", + load_skill: "Load skill", + consult_vision: "Ask vision model", + read_image: "Read image", + youtube_transcript: "YouTube transcript", + summon_agent: "Summon agent", + send_agent_message: "Message agent", + read_agent: "Read agent reply", + open_agent: "Open agent", + cancel_agent_message: "Cancel agent message", + list_agents: "List agents", + list_agent_models: "List agent models", +}; + +/** `create_issue` / `createIssue` / `create-issue` → "Create issue". */ +function sentenceCase(raw: string): string { + const words = raw + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[\s_\-.]+/) + .filter((w) => w !== "") + .map((w) => w.toLowerCase()); + if (words.length === 0) return raw; + const text = words.join(" "); + return text.charAt(0).toUpperCase() + text.slice(1); +} + +/** + * Generic English name for an unknown tool. MCP tools arrive namespaced as + * `__` (backend `mcp/registry.ts`), shown as "Server: Tool name". + */ +function humanize(name: string): string { + const parts = name.split("__").filter((p) => p !== ""); + if (parts[0]?.toLowerCase() === "mcp" && parts.length > 1) parts.shift(); + if (parts.length >= 2) { + const server = sentenceCase(parts[0] ?? ""); + return `${server}: ${sentenceCase(parts.slice(1).join(" "))}`; + } + return sentenceCase(name); +} + +/** The English display name for a tool (built-in table, else humanized). */ +export function toolLabel(name: string): string { + return Object.hasOwn(TOOL_LABELS, name) ? (TOOL_LABELS[name] ?? name) : humanize(name); +} + +/** True for input that carries no arguments at all (absent, `null`, `{}`, `[]`, blank string). */ +function isEmptyInput(input: unknown): boolean { + if (input === undefined || input === null) return true; + if (typeof input === "string") return input.trim() === ""; + if (Array.isArray(input)) return input.length === 0; + return typeof input === "object" && Object.keys(input).length === 0; +} + +/** Fallback summary: scalar args as `key: value` (never raw JSON braces). */ +function scalarPairs(args: Args): string { + return Object.entries(args) + .filter( + ([, v]) => ["string", "number", "boolean"].includes(typeof v) && String(v).trim() !== "", + ) + .map(([k, v]) => `${k}: ${String(v)}`) + .join(" · "); +} + /** Optional built-in polish; all unknown/MCP names use the same generic path. */ const summaries: Readonly> = { run_shell: (args) => arg(args, "command"), @@ -163,6 +239,7 @@ function detail(name: string, input: unknown, args: Args): ToolCardView["callDet newText: typeof args.newString === "string" ? args.newString : "", }; } + if (isEmptyInput(input)) return { kind: "none" }; const entries = Object.entries(args); return { kind: "fields", @@ -249,7 +326,16 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView { const callSummary = entry.call === null ? "Call in earlier history" - : oneLine(builtInSummary || generic || serialize(input)); + : isEmptyInput(input) + ? "" + : oneLine( + builtInSummary || + generic || + scalarPairs(args) || + // Non-object input (e.g. a bare string); objects with only nested + // values stay unsummarized rather than dumping JSON braces. + (Object.keys(args).length === 0 ? serialize(input) : ""), + ); const callBadges: ToolCardView["callBadges"][number][] = []; if (entry.toolName === "edit_file" && entry.call !== null && !entry.result?.isError) { if (typeof args.oldString === "string" && typeof args.newString === "string") { @@ -265,6 +351,7 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView { result === null ? null : responseSummary(entry.toolName, result.content, result.isError); return { name: entry.toolName, + label: toolLabel(entry.toolName), status: result === null ? "pending" : result.isError ? "error" : "ok", callSummary, callBadges, -- cgit v1.2.3