refactor: tighten canonical tool-call typing and display mapping
This commit is contained in:
@@ -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}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user