refactor: tighten canonical tool-call typing and display mapping

This commit is contained in:
Mohamed Boudra
2026-02-09 08:53:27 +07:00
parent 0176614467
commit 6d4992b2f5
25 changed files with 2833 additions and 1764 deletions
@@ -1,11 +1,15 @@
import { describe, expect, it } from "vitest";
import { buildToolCallDisplayModel, formatToolCallError } from "./tool-call-display";
import { buildToolCallDisplayModel } from "./tool-call-display";
describe("tool-call-display", () => {
it("builds display model from canonical shell detail", () => {
const display = buildToolCallDisplayModel({
name: "shell",
status: "running",
input: { command: "npm test" },
output: null,
error: null,
detail: {
type: "shell",
command: "npm test",
@@ -13,7 +17,6 @@ describe("tool-call-display", () => {
});
expect(display).toEqual({
kind: "execute",
displayName: "Shell",
summary: "npm test",
});
@@ -22,6 +25,10 @@ describe("tool-call-display", () => {
it("builds display model from canonical read detail", () => {
const display = buildToolCallDisplayModel({
name: "read_file",
status: "completed",
input: { path: "/tmp/repo/src/index.ts" },
output: { content: "hello" },
error: null,
detail: {
type: "read",
filePath: "/tmp/repo/src/index.ts",
@@ -30,7 +37,6 @@ describe("tool-call-display", () => {
});
expect(display).toEqual({
kind: "read",
displayName: "Read",
summary: "src/index.ts",
});
@@ -39,13 +45,16 @@ describe("tool-call-display", () => {
it("uses metadata summary for task tool calls", () => {
const display = buildToolCallDisplayModel({
name: "task",
status: "running",
input: null,
output: null,
error: null,
metadata: {
subAgentActivity: "Running tests",
},
});
expect(display).toEqual({
kind: "agent",
displayName: "Task",
summary: "Running tests",
});
@@ -54,15 +63,40 @@ describe("tool-call-display", () => {
it("falls back to humanized tool name for unknown tools", () => {
const display = buildToolCallDisplayModel({
name: "custom_tool_name",
status: "completed",
input: null,
output: null,
error: null,
});
expect(display).toEqual({
kind: "tool",
displayName: "Custom Tool Name",
});
});
it("formats non-string errors", () => {
expect(formatToolCallError({ message: "boom" })).toBe('{\n "message": "boom"\n}');
it("does not derive command summary from raw input when detail is missing", () => {
const display = buildToolCallDisplayModel({
name: "exec_command",
status: "running",
input: { command: "npm run test" },
output: null,
error: null,
});
expect(display).toEqual({
displayName: "Exec Command",
});
});
it("returns formatted errorText from the same display pipeline", () => {
const display = buildToolCallDisplayModel({
name: "shell",
status: "failed",
input: { command: "false" },
output: null,
error: { message: "boom" },
});
expect(display.errorText).toBe('{\n "message": "boom"\n}');
});
});
+5 -211
View File
@@ -1,211 +1,5 @@
import { z } from "zod";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
export type ToolCallKind =
| "read"
| "edit"
| "write"
| "execute"
| "search"
| "agent"
| "tool"
| "thinking";
type SummaryParams = {
name: string;
detail?: ToolCallDetail;
metadata?: Record<string, unknown>;
cwd?: string;
};
export type ToolCallDisplayModel = {
kind: ToolCallKind;
displayName: string;
summary?: string;
};
const TOOL_CALL_DETAIL_SCHEMA: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
z.object({
type: z.literal("shell"),
command: z.string(),
cwd: z.string().optional(),
output: z.string().optional(),
exitCode: z.number().nullable().optional(),
}),
z.object({
type: z.literal("read"),
filePath: z.string(),
content: z.string().optional(),
offset: z.number().optional(),
limit: z.number().optional(),
}),
z.object({
type: z.literal("edit"),
filePath: z.string(),
oldString: z.string().optional(),
newString: z.string().optional(),
unifiedDiff: z.string().optional(),
}),
z.object({
type: z.literal("write"),
filePath: z.string(),
content: z.string().optional(),
}),
z.object({
type: z.literal("search"),
query: z.string(),
}),
]);
const TOOL_CALL_DISPLAY_INPUT_SCHEMA = z.object({
name: z.string().min(1),
detail: TOOL_CALL_DETAIL_SCHEMA.optional(),
metadata: z.record(z.unknown()).optional(),
cwd: z.string().optional(),
});
function toTitleCase(words: string): string {
return words
.split(" ")
.filter((segment) => segment.length > 0)
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
.join(" ");
}
function humanizeToolName(name: string): string {
const trimmed = name.trim();
if (!trimmed) {
return name;
}
return toTitleCase(trimmed.replace(/[._-]+/g, " "));
}
export function stripCwdPrefix(filePath: string, cwd?: string): string {
if (!cwd || !filePath) return filePath;
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = filePath.replace(/\\/g, "/");
const prefix = `${normalizedCwd}/`;
if (normalizedPath.startsWith(prefix)) {
return normalizedPath.slice(prefix.length);
}
if (normalizedPath === normalizedCwd) {
return ".";
}
return filePath;
}
function buildDisplayFromDetail(detail: ToolCallDetail, cwd?: string): ToolCallDisplayModel {
switch (detail.type) {
case "shell":
return {
kind: "execute",
displayName: "Shell",
summary: detail.command,
};
case "read":
return {
kind: "read",
displayName: "Read",
summary: stripCwdPrefix(detail.filePath, cwd),
};
case "edit":
return {
kind: "edit",
displayName: "Edit",
summary: stripCwdPrefix(detail.filePath, cwd),
};
case "write":
return {
kind: "write",
displayName: "Write",
summary: stripCwdPrefix(detail.filePath, cwd),
};
case "search":
return {
kind: "search",
displayName: "Search",
summary: detail.query,
};
default:
return {
kind: "tool",
displayName: "Tool",
};
}
}
function buildDisplayWithoutDetail(params: {
toolNameLower: string;
rawName: string;
metadata?: Record<string, unknown>;
}): ToolCallDisplayModel {
if (params.toolNameLower === "task") {
const summary = params.metadata?.subAgentActivity;
return {
kind: "agent",
displayName: "Task",
summary: typeof summary === "string" && summary.trim().length > 0 ? summary : undefined,
};
}
if (params.toolNameLower === "thinking") {
return {
kind: "thinking",
displayName: "Thinking",
};
}
return {
kind: "tool",
displayName: humanizeToolName(params.rawName),
};
}
export function buildToolCallDisplayModel(params: SummaryParams): ToolCallDisplayModel {
const parsed = TOOL_CALL_DISPLAY_INPUT_SCHEMA.parse(params);
if (parsed.detail) {
return buildDisplayFromDetail(parsed.detail, parsed.cwd);
}
return buildDisplayWithoutDetail({
toolNameLower: parsed.name.trim().toLowerCase(),
rawName: parsed.name,
metadata: parsed.metadata,
});
}
export function resolveToolCallDisplayName(name: string, detail?: ToolCallDetail): string {
return buildToolCallDisplayModel({ name, detail }).displayName;
}
export function resolveToolCallKind(name: string, detail?: ToolCallDetail): ToolCallKind {
return buildToolCallDisplayModel({ name, detail }).kind;
}
export function resolveToolCallSummary(params: SummaryParams): string | undefined {
return buildToolCallDisplayModel(params).summary;
}
export function formatToolCallError(error: unknown): string | undefined {
if (error === null || error === undefined) {
return undefined;
}
if (typeof error === "string") {
return error;
}
if (
typeof error === "object" &&
"content" in (error as Record<string, unknown>) &&
typeof (error as Record<string, unknown>).content === "string"
) {
return (error as Record<string, unknown>).content as string;
}
try {
return JSON.stringify(error, null, 2);
} catch {
return String(error);
}
}
export {
buildToolCallDisplayModel,
type ToolCallDisplayInput,
type ToolCallDisplayModel,
} from "@server/shared/tool-call-display";
+28
View File
@@ -0,0 +1,28 @@
import type { ComponentType } from "react";
import { Bot, Brain, Eye, Pencil, Search, SquareTerminal, Wrench } from "lucide-react-native";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
export type ToolCallIconComponent = ComponentType<{ size?: number; color?: string }>;
const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> = {
shell: SquareTerminal,
read: Eye,
edit: Pencil,
write: Pencil,
search: Search,
};
export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail): ToolCallIconComponent {
if (detail) {
return TOOL_DETAIL_ICONS[detail.type];
}
const lowerName = toolName.trim().toLowerCase();
if (lowerName === "task") {
return Bot;
}
if (lowerName === "thinking") {
return Brain;
}
return Wrench;
}