Unified tool call display schema, compaction timeline, and auth fixes
- Replace extractPrincipalParam, parseToolCallDisplay, getToolKindFromName with a single unified parseToolCallDisplay on the server, consumed by both server (activity-curator) and app - Parse tool calls exactly once and pass ToolCallDisplayInfo down to all consumers (message, sheet, details, stream view) - Add compaction timeline item: detect SDKStatusMessage (compacting) and SDKCompactBoundaryMessage, show loading/completed marker in timeline - Suppress compact summary user message in live stream (flag) and JSONL resume (isCompactSummary check) - Add CompactionMarker component with scissors icon and token count - Hide sub-agent tool calls from timeline, collapse into Task metadata - Add Bot icon for Task tool calls - Fix stripShellWrapperPrefix to strip /bin/zsh -lc wrappers without cd - Replace CLAUDE_SESSION_TOKEN with CLAUDE_CODE_OAUTH_TOKEN
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
stripShellWrapperPrefix,
|
||||
extractPrincipalParam,
|
||||
normalizeToolDisplayName,
|
||||
stripCwdPrefix,
|
||||
parseToolCallDisplay,
|
||||
} from "./tool-call-parsers.js";
|
||||
|
||||
describe("stripShellWrapperPrefix", () => {
|
||||
@@ -37,57 +37,22 @@ describe("stripShellWrapperPrefix", () => {
|
||||
expect(stripShellWrapperPrefix(command)).toBe("npm run build");
|
||||
});
|
||||
|
||||
test("returns command unchanged for partial match", () => {
|
||||
test("strips shell prefix even without cd", () => {
|
||||
const command = "/bin/zsh -lc npm run build";
|
||||
expect(stripShellWrapperPrefix(command)).toBe("/bin/zsh -lc npm run build");
|
||||
expect(stripShellWrapperPrefix(command)).toBe("npm run build");
|
||||
});
|
||||
|
||||
test("strips when cd path includes spaces in quotes", () => {
|
||||
const command = '/bin/zsh -lc cd "/path with spaces" && npm test';
|
||||
expect(stripShellWrapperPrefix(command)).toBe("npm test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractPrincipalParam", () => {
|
||||
test("extracts and strips shell wrapper from command string", () => {
|
||||
const args = { command: "/bin/zsh -lc cd /Users/dev/project && npm run format" };
|
||||
expect(extractPrincipalParam(args)).toBe("npm run format");
|
||||
});
|
||||
|
||||
test("extracts and strips shell wrapper from command array", () => {
|
||||
const args = { command: ["/bin/bash", "-lc", "cd /path && git status"] };
|
||||
// Array is joined with spaces first: "/bin/bash -lc cd /path && git status"
|
||||
// Then shell wrapper is stripped
|
||||
expect(extractPrincipalParam(args)).toBe("git status");
|
||||
});
|
||||
|
||||
test("extracts plain command without shell wrapper", () => {
|
||||
const args = { command: "npm run build" };
|
||||
expect(extractPrincipalParam(args)).toBe("npm run build");
|
||||
});
|
||||
|
||||
test("extracts file_path and strips cwd", () => {
|
||||
const args = { file_path: "/Users/dev/project/src/file.ts" };
|
||||
expect(extractPrincipalParam(args, "/Users/dev/project")).toBe("src/file.ts");
|
||||
});
|
||||
|
||||
test("extracts pattern without modification", () => {
|
||||
const args = { pattern: "*.ts" };
|
||||
expect(extractPrincipalParam(args)).toBe("*.ts");
|
||||
});
|
||||
|
||||
test("extracts query without modification", () => {
|
||||
const args = { query: "search term" };
|
||||
expect(extractPrincipalParam(args)).toBe("search term");
|
||||
});
|
||||
|
||||
test("extracts url without modification", () => {
|
||||
const args = { url: "https://example.com" };
|
||||
expect(extractPrincipalParam(args)).toBe("https://example.com");
|
||||
});
|
||||
|
||||
test("returns undefined for empty object", () => {
|
||||
expect(extractPrincipalParam({})).toBeUndefined();
|
||||
test("strips /bin/zsh -lc with quoted complex command", () => {
|
||||
const command =
|
||||
'/bin/zsh -lc "adb shell pm list packages | rg \'sh\\.paseo(\\.dev)?\' && adb shell cmd package resolve-activity --brief sh.paseo.dev | tail -n 1"';
|
||||
expect(stripShellWrapperPrefix(command)).toBe(
|
||||
"adb shell pm list packages | rg 'sh\\.paseo(\\.dev)?' && adb shell cmd package resolve-activity --brief sh.paseo.dev | tail -n 1"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,3 +93,193 @@ describe("stripCwdPrefix", () => {
|
||||
expect(stripCwdPrefix("/Users/dev/project/file.ts")).toBe("/Users/dev/project/file.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseToolCallDisplay", () => {
|
||||
describe("summary (was extractPrincipalParam)", () => {
|
||||
test("extracts and strips shell wrapper from command string", () => {
|
||||
const result = parseToolCallDisplay({
|
||||
name: "Bash",
|
||||
input: { command: "/bin/zsh -lc cd /Users/dev/project && npm run format" },
|
||||
});
|
||||
expect(result.summary).toBe("npm run format");
|
||||
});
|
||||
|
||||
test("extracts and strips shell wrapper from command array", () => {
|
||||
const result = parseToolCallDisplay({
|
||||
name: "shell",
|
||||
input: { command: ["/bin/bash", "-lc", "cd /path && git status"] },
|
||||
});
|
||||
expect(result.summary).toBe("git status");
|
||||
});
|
||||
|
||||
test("extracts plain command without shell wrapper", () => {
|
||||
const result = parseToolCallDisplay({
|
||||
name: "Bash",
|
||||
input: { command: "npm run build" },
|
||||
});
|
||||
expect(result.summary).toBe("npm run build");
|
||||
});
|
||||
|
||||
test("extracts file_path and strips cwd", () => {
|
||||
const result = parseToolCallDisplay({
|
||||
name: "Read",
|
||||
input: { file_path: "/Users/dev/project/src/file.ts" },
|
||||
cwd: "/Users/dev/project",
|
||||
});
|
||||
expect(result.summary).toBe("src/file.ts");
|
||||
});
|
||||
|
||||
test("extracts pattern without modification", () => {
|
||||
const result = parseToolCallDisplay({
|
||||
name: "Grep",
|
||||
input: { pattern: "*.ts" },
|
||||
});
|
||||
expect(result.summary).toBe("*.ts");
|
||||
});
|
||||
|
||||
test("extracts query without modification", () => {
|
||||
const result = parseToolCallDisplay({
|
||||
name: "WebSearch",
|
||||
input: { query: "search term" },
|
||||
});
|
||||
expect(result.summary).toBe("search term");
|
||||
});
|
||||
|
||||
test("extracts url without modification", () => {
|
||||
const result = parseToolCallDisplay({
|
||||
name: "WebFetch",
|
||||
input: { url: "https://example.com" },
|
||||
});
|
||||
expect(result.summary).toBe("https://example.com");
|
||||
});
|
||||
|
||||
test("returns undefined summary for empty input", () => {
|
||||
const result = parseToolCallDisplay({ name: "Unknown", input: {} });
|
||||
expect(result.summary).toBeUndefined();
|
||||
});
|
||||
|
||||
test("extracts description from Task tool", () => {
|
||||
const result = parseToolCallDisplay({
|
||||
name: "Task",
|
||||
input: { description: "Explore the codebase" },
|
||||
});
|
||||
expect(result.summary).toBe("Explore the codebase");
|
||||
});
|
||||
});
|
||||
|
||||
describe("summary from metadata", () => {
|
||||
test("subAgentActivity in metadata takes priority over input", () => {
|
||||
const result = parseToolCallDisplay({
|
||||
name: "Task",
|
||||
input: { description: "Explore codebase" },
|
||||
metadata: { subAgentActivity: "Read" },
|
||||
});
|
||||
expect(result.summary).toBe("Read");
|
||||
});
|
||||
|
||||
test("metadata merges with input for summary parsing", () => {
|
||||
const result = parseToolCallDisplay({
|
||||
name: "Task",
|
||||
input: {},
|
||||
metadata: { subAgentActivity: "Bash" },
|
||||
});
|
||||
expect(result.summary).toBe("Bash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kind", () => {
|
||||
test("Read -> read", () => {
|
||||
expect(parseToolCallDisplay({ name: "Read" }).kind).toBe("read");
|
||||
});
|
||||
|
||||
test("read_file -> read", () => {
|
||||
expect(parseToolCallDisplay({ name: "read_file" }).kind).toBe("read");
|
||||
});
|
||||
|
||||
test("Edit -> edit", () => {
|
||||
expect(parseToolCallDisplay({ name: "Edit" }).kind).toBe("edit");
|
||||
});
|
||||
|
||||
test("Write -> edit", () => {
|
||||
expect(parseToolCallDisplay({ name: "Write" }).kind).toBe("edit");
|
||||
});
|
||||
|
||||
test("apply_patch -> edit", () => {
|
||||
expect(parseToolCallDisplay({ name: "apply_patch" }).kind).toBe("edit");
|
||||
});
|
||||
|
||||
test("Bash -> execute", () => {
|
||||
expect(parseToolCallDisplay({ name: "Bash" }).kind).toBe("execute");
|
||||
});
|
||||
|
||||
test("shell -> execute", () => {
|
||||
expect(parseToolCallDisplay({ name: "shell" }).kind).toBe("execute");
|
||||
});
|
||||
|
||||
test("Grep -> search", () => {
|
||||
expect(parseToolCallDisplay({ name: "Grep" }).kind).toBe("search");
|
||||
});
|
||||
|
||||
test("Glob -> search", () => {
|
||||
expect(parseToolCallDisplay({ name: "Glob" }).kind).toBe("search");
|
||||
});
|
||||
|
||||
test("thinking -> thinking", () => {
|
||||
expect(parseToolCallDisplay({ name: "thinking" }).kind).toBe("thinking");
|
||||
});
|
||||
|
||||
test("unknown tool -> tool", () => {
|
||||
expect(parseToolCallDisplay({ name: "MyCustomTool" }).kind).toBe("tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("displayName", () => {
|
||||
test("shell -> Shell", () => {
|
||||
expect(parseToolCallDisplay({ name: "shell" }).displayName).toBe("Shell");
|
||||
});
|
||||
|
||||
test("Bash -> Shell", () => {
|
||||
expect(parseToolCallDisplay({ name: "Bash" }).displayName).toBe("Shell");
|
||||
});
|
||||
|
||||
test("read_file -> Read", () => {
|
||||
expect(parseToolCallDisplay({ name: "read_file" }).displayName).toBe("Read");
|
||||
});
|
||||
|
||||
test("apply_patch -> Edit", () => {
|
||||
expect(parseToolCallDisplay({ name: "apply_patch" }).displayName).toBe("Edit");
|
||||
});
|
||||
|
||||
test("preserves unknown tool names", () => {
|
||||
expect(parseToolCallDisplay({ name: "MyCustomTool" }).displayName).toBe("MyCustomTool");
|
||||
});
|
||||
|
||||
test("normalizes speak variants", () => {
|
||||
expect(parseToolCallDisplay({ name: "paseo_voice.speak" }).displayName).toBe("Speak");
|
||||
expect(parseToolCallDisplay({ name: "mcp__paseo_voice__speak" }).displayName).toBe("Speak");
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorText", () => {
|
||||
test("formats string error", () => {
|
||||
const result = parseToolCallDisplay({
|
||||
name: "Bash",
|
||||
error: "something broke",
|
||||
});
|
||||
expect(result.errorText).toBe("something broke");
|
||||
});
|
||||
|
||||
test("extracts content from tool_result error", () => {
|
||||
const result = parseToolCallDisplay({
|
||||
name: "Bash",
|
||||
error: { type: "tool_result", content: "Exit code 1", is_error: true },
|
||||
});
|
||||
expect(result.errorText).toBe("Exit code 1");
|
||||
});
|
||||
|
||||
test("returns undefined for no error", () => {
|
||||
const result = parseToolCallDisplay({ name: "Read" });
|
||||
expect(result.errorText).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,45 +1,69 @@
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { z } from "zod";
|
||||
|
||||
// ---- Principal Parameter Extraction ----
|
||||
// ---- Tool Call Kind (icon category) ----
|
||||
|
||||
// Schema for file entries in arrays (e.g., apply_patch files)
|
||||
const FileEntrySchema = z.object({ path: z.string() });
|
||||
export type ToolCallKind = "read" | "edit" | "execute" | "search" | "thinking" | "agent" | "tool";
|
||||
|
||||
// Schema for TodoWrite todos array
|
||||
const TodoEntrySchema = z.object({
|
||||
content: z.string(),
|
||||
status: z.enum(["pending", "in_progress", "completed"]),
|
||||
activeForm: z.string().optional(),
|
||||
});
|
||||
const TOOL_KIND_MAP: Record<string, ToolCallKind> = {
|
||||
read: "read",
|
||||
read_file: "read",
|
||||
edit: "edit",
|
||||
write: "edit",
|
||||
apply_patch: "edit",
|
||||
bash: "execute",
|
||||
shell: "execute",
|
||||
grep: "search",
|
||||
glob: "search",
|
||||
web_search: "search",
|
||||
thinking: "thinking",
|
||||
task: "agent",
|
||||
};
|
||||
|
||||
const PrincipalParamSchema = z.union([
|
||||
// Direct path keys
|
||||
z.object({ file_path: z.string() }).transform((d) => ({ type: "path" as const, value: d.file_path })),
|
||||
z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })),
|
||||
z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })),
|
||||
// Command as string
|
||||
z.object({ command: z.string() }).transform((d) => ({ type: "command" as const, value: d.command })),
|
||||
// Command as array (Codex sends this)
|
||||
z.object({ command: z.array(z.string()).nonempty() }).transform((d) => ({ type: "command" as const, value: d.command.join(" ") })),
|
||||
// Other text params
|
||||
z.object({ title: z.string() }).transform((d) => ({ type: "text" as const, value: d.title })),
|
||||
z.object({ name: z.string() }).transform((d) => ({ type: "text" as const, value: d.name })),
|
||||
z.object({ branch: z.string() }).transform((d) => ({ type: "text" as const, value: d.branch })),
|
||||
z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })),
|
||||
z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })),
|
||||
z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })),
|
||||
z.object({ text: z.string() }).transform((d) => ({ type: "text" as const, value: d.text })),
|
||||
// Files array (Codex apply_patch)
|
||||
z.object({ files: z.array(FileEntrySchema).nonempty() }).transform((d) => ({ type: "path" as const, value: d.files[0].path })),
|
||||
// TodoWrite - show in_progress item or count
|
||||
z.object({ todos: z.array(TodoEntrySchema).nonempty() }).transform((d) => {
|
||||
const inProgress = d.todos.find((t) => t.status === "in_progress");
|
||||
if (inProgress) {
|
||||
return { type: "text" as const, value: inProgress.activeForm ?? inProgress.content };
|
||||
}
|
||||
return { type: "text" as const, value: `${d.todos.length} tasks` };
|
||||
}),
|
||||
]);
|
||||
// ---- Tool Name Normalization ----
|
||||
|
||||
const TOOL_NAME_MAP: Record<string, string> = {
|
||||
shell: "Shell",
|
||||
Bash: "Shell",
|
||||
read_file: "Read",
|
||||
apply_patch: "Edit",
|
||||
paseo_worktree_setup: "Setup",
|
||||
thinking: "Thinking",
|
||||
};
|
||||
|
||||
const TOOL_TOKEN_REGEX = /[a-z0-9]+/g;
|
||||
|
||||
export function normalizeToolDisplayName(toolName: string): string {
|
||||
const normalized = toolName.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return toolName;
|
||||
}
|
||||
|
||||
const tokens = normalized.match(TOOL_TOKEN_REGEX) ?? [];
|
||||
const leaf = tokens[tokens.length - 1];
|
||||
if (leaf === "speak") {
|
||||
return "Speak";
|
||||
}
|
||||
return toolName;
|
||||
}
|
||||
|
||||
function resolveDisplayName(rawName: string): string {
|
||||
return normalizeToolDisplayName(TOOL_NAME_MAP[rawName] ?? rawName);
|
||||
}
|
||||
|
||||
function resolveKind(rawName: string): ToolCallKind {
|
||||
const lower = rawName.trim().toLowerCase();
|
||||
if (TOOL_KIND_MAP[lower]) {
|
||||
return TOOL_KIND_MAP[lower];
|
||||
}
|
||||
// Check prefix for read variants (e.g. "read_pdf")
|
||||
if (lower.startsWith("read")) {
|
||||
return "read";
|
||||
}
|
||||
return "tool";
|
||||
}
|
||||
|
||||
// ---- Path/Command Utilities ----
|
||||
|
||||
export function stripCwdPrefix(filePath: string, cwd?: string): string {
|
||||
if (!cwd || !filePath) return filePath;
|
||||
@@ -63,21 +87,6 @@ export function stripCwdPrefix(filePath: string, cwd?: string): string {
|
||||
// This is used for display purposes to show the actual command being run.
|
||||
const SHELL_WRAPPER_PREFIX_PATTERN = /^\/bin\/(?:zsh|bash|sh)\s+(?:-[a-zA-Z]+\s+)?/;
|
||||
const CD_AND_PATTERN = /^cd\s+(?:"[^"]+"|'[^']+'|\S+)\s+&&\s+/;
|
||||
const TOOL_TOKEN_REGEX = /[a-z0-9]+/g;
|
||||
|
||||
export function normalizeToolDisplayName(toolName: string): string {
|
||||
const normalized = toolName.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return toolName;
|
||||
}
|
||||
|
||||
const tokens = normalized.match(TOOL_TOKEN_REGEX) ?? [];
|
||||
const leaf = tokens[tokens.length - 1];
|
||||
if (leaf === "speak") {
|
||||
return "Speak";
|
||||
}
|
||||
return toolName;
|
||||
}
|
||||
|
||||
export function stripShellWrapperPrefix(command: string): string {
|
||||
const prefixMatch = command.match(SHELL_WRAPPER_PREFIX_PATTERN);
|
||||
@@ -94,16 +103,67 @@ export function stripShellWrapperPrefix(command: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const stripped = rest.replace(CD_AND_PATTERN, "");
|
||||
if (stripped !== rest) {
|
||||
return stripped;
|
||||
}
|
||||
|
||||
return command;
|
||||
return rest.replace(CD_AND_PATTERN, "");
|
||||
}
|
||||
|
||||
export function extractPrincipalParam(args: unknown, cwd?: string): string | undefined {
|
||||
const parsed = PrincipalParamSchema.safeParse(args);
|
||||
// ---- Summary Schema (was PrincipalParamSchema) ----
|
||||
|
||||
const FileEntrySchema = z.object({ path: z.string() });
|
||||
|
||||
const TodoEntrySchema = z.object({
|
||||
content: z.string(),
|
||||
status: z.enum(["pending", "in_progress", "completed"]),
|
||||
activeForm: z.string().optional(),
|
||||
});
|
||||
|
||||
const SummarySchema = z.union([
|
||||
// Sub-agent activity (from tool call metadata, highest priority)
|
||||
z.object({ subAgentActivity: z.string() }).transform((d) => ({ type: "text" as const, value: d.subAgentActivity })),
|
||||
// Direct path keys
|
||||
z.object({ file_path: z.string() }).transform((d) => ({ type: "path" as const, value: d.file_path })),
|
||||
z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })),
|
||||
z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })),
|
||||
// Command as string
|
||||
z.object({ command: z.string() }).transform((d) => ({ type: "command" as const, value: d.command })),
|
||||
// Command as array (Codex sends this)
|
||||
z.object({ command: z.array(z.string()).nonempty() }).transform((d) => ({ type: "command" as const, value: d.command.join(" ") })),
|
||||
// Task tool description (short summary)
|
||||
z.object({ description: z.string() }).transform((d) => ({ type: "text" as const, value: d.description })),
|
||||
// Other text params
|
||||
z.object({ title: z.string() }).transform((d) => ({ type: "text" as const, value: d.title })),
|
||||
z.object({ name: z.string() }).transform((d) => ({ type: "text" as const, value: d.name })),
|
||||
z.object({ branch: z.string() }).transform((d) => ({ type: "text" as const, value: d.branch })),
|
||||
z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })),
|
||||
z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })),
|
||||
z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })),
|
||||
z.object({ text: z.string() }).transform((d) => ({ type: "text" as const, value: d.text })),
|
||||
// Files array (Codex apply_patch)
|
||||
z.object({ files: z.array(FileEntrySchema).nonempty() }).transform((d) => ({ type: "path" as const, value: d.files[0].path })),
|
||||
// TodoWrite - show in_progress item or count
|
||||
z.object({ todos: z.array(TodoEntrySchema).nonempty() }).transform((d) => {
|
||||
const inProgress = d.todos.find((t) => t.status === "in_progress");
|
||||
if (inProgress) {
|
||||
return { type: "text" as const, value: inProgress.activeForm ?? inProgress.content };
|
||||
}
|
||||
return { type: "text" as const, value: `${d.todos.length} tasks` };
|
||||
}),
|
||||
]);
|
||||
|
||||
const RecordSchema = z.record(z.unknown());
|
||||
|
||||
function extractSummary(input: unknown, metadata: Record<string, unknown> | undefined, cwd: string | undefined): string | undefined {
|
||||
// Merge input + metadata into one object for the summary schema to match against.
|
||||
// metadata fields take priority (e.g. subAgentActivity overrides input fields).
|
||||
const inputRecord = RecordSchema.safeParse(input);
|
||||
const merged = metadata
|
||||
? { ...(inputRecord.success ? inputRecord.data : {}), ...metadata }
|
||||
: inputRecord.success ? inputRecord.data : undefined;
|
||||
|
||||
if (!merged) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = SummarySchema.safeParse(merged);
|
||||
if (!parsed.success) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -118,6 +178,401 @@ export function extractPrincipalParam(args: unknown, cwd?: string): string | und
|
||||
return value;
|
||||
}
|
||||
|
||||
// ---- Detail Schemas ----
|
||||
|
||||
export interface KeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function stringifyValue(value: unknown): string {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
if (value === undefined) {
|
||||
return "undefined";
|
||||
}
|
||||
if (value === "") {
|
||||
return "";
|
||||
}
|
||||
const str = z.string().safeParse(value);
|
||||
if (str.success) {
|
||||
return str.data;
|
||||
}
|
||||
const num = z.number().safeParse(value);
|
||||
if (num.success) {
|
||||
return String(num.data);
|
||||
}
|
||||
const bool = z.boolean().safeParse(value);
|
||||
if (bool.success) {
|
||||
return String(bool.data);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
const KeyValuePairsSchema = z.record(z.unknown()).transform((data) =>
|
||||
Object.entries(data).map(([key, value]) => ({
|
||||
key,
|
||||
value: stringifyValue(value),
|
||||
}))
|
||||
);
|
||||
|
||||
// Shell input: { command: "pwd", description?: "..." }
|
||||
const ShellInputSchema = z.object({
|
||||
command: z.union([z.string(), z.array(z.string())]),
|
||||
}).passthrough();
|
||||
|
||||
// Shell result (when completed): { type: "command", command: "pwd", output: "..." }
|
||||
const ShellResultSchema = z.object({
|
||||
type: z.literal("command"),
|
||||
output: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
// Shell error result: { type: "tool_result", content: "Exit code 128\n...", is_error: true }
|
||||
const ShellErrorResultSchema = z.object({
|
||||
type: z.literal("tool_result"),
|
||||
content: z.string(),
|
||||
is_error: z.literal(true),
|
||||
}).passthrough();
|
||||
|
||||
const ShellToolCallSchema = z
|
||||
.object({
|
||||
input: ShellInputSchema,
|
||||
output: z.unknown(),
|
||||
})
|
||||
.transform((data) => {
|
||||
const commandRaw = Array.isArray(data.input.command)
|
||||
? data.input.command.join(" ")
|
||||
: data.input.command;
|
||||
const command = stripShellWrapperPrefix(commandRaw);
|
||||
|
||||
const resultParsed = ShellResultSchema.safeParse(data.output);
|
||||
if (resultParsed.success) {
|
||||
return {
|
||||
type: "shell" as const,
|
||||
command,
|
||||
output: stripAnsi(resultParsed.data.output),
|
||||
};
|
||||
}
|
||||
|
||||
const errorParsed = ShellErrorResultSchema.safeParse(data.output);
|
||||
if (errorParsed.success) {
|
||||
return {
|
||||
type: "shell" as const,
|
||||
command,
|
||||
output: stripAnsi(errorParsed.data.content),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "shell" as const,
|
||||
command,
|
||||
output: "",
|
||||
};
|
||||
});
|
||||
|
||||
// Edit input: { file_path: string, old_string: string, new_string: string }
|
||||
const EditInputSchema = z.union([
|
||||
z.object({
|
||||
file_path: z.string(),
|
||||
old_string: z.string(),
|
||||
new_string: z.string(),
|
||||
}).passthrough(),
|
||||
z.object({
|
||||
file_path: z.string(),
|
||||
old_str: z.string(),
|
||||
new_str: z.string(),
|
||||
}).passthrough(),
|
||||
]);
|
||||
|
||||
const EditToolCallSchema = z
|
||||
.object({
|
||||
input: EditInputSchema,
|
||||
output: z.unknown(),
|
||||
})
|
||||
.transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => {
|
||||
const filePath = data.input.file_path;
|
||||
const oldString = "old_string" in data.input
|
||||
? (data.input as { old_string: string }).old_string
|
||||
: (data.input as { old_str: string }).old_str;
|
||||
const newString = "new_string" in data.input
|
||||
? (data.input as { new_string: string }).new_string
|
||||
: (data.input as { new_str: string }).new_str;
|
||||
|
||||
return {
|
||||
type: "edit",
|
||||
filePath,
|
||||
oldString,
|
||||
newString,
|
||||
};
|
||||
});
|
||||
|
||||
// Codex apply_patch
|
||||
const ApplyPatchFileKindSchema = z
|
||||
.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
type: z.string().optional(),
|
||||
move_path: z.string().nullable().optional(),
|
||||
movePath: z.string().nullable().optional(),
|
||||
}).passthrough(),
|
||||
])
|
||||
.optional();
|
||||
|
||||
const ApplyPatchMovePathSchema = z.object({
|
||||
movePath: z.string().optional(),
|
||||
move_path: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
function getApplyPatchMovePath(kind: unknown): string | undefined {
|
||||
const parsed = ApplyPatchMovePathSchema.safeParse(kind);
|
||||
if (!parsed.success) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed.data.movePath ?? parsed.data.move_path ?? undefined;
|
||||
}
|
||||
|
||||
const ApplyPatchInputSchema = z.object({
|
||||
files: z.array(z.object({
|
||||
path: z.string(),
|
||||
kind: ApplyPatchFileKindSchema,
|
||||
})).min(1),
|
||||
}).passthrough();
|
||||
|
||||
const ApplyPatchResultSchema = z.object({
|
||||
files: z.array(z.object({
|
||||
path: z.string(),
|
||||
patch: z.string().optional(),
|
||||
kind: ApplyPatchFileKindSchema,
|
||||
})).optional(),
|
||||
message: z.string().optional(),
|
||||
success: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const ApplyPatchToolCallSchema = z
|
||||
.object({
|
||||
input: ApplyPatchInputSchema,
|
||||
output: z.unknown(),
|
||||
})
|
||||
.transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => {
|
||||
const firstFile = data.input.files[0];
|
||||
const movePath = getApplyPatchMovePath(firstFile.kind);
|
||||
const filePath = movePath ?? firstFile.path;
|
||||
|
||||
const resultParsed = ApplyPatchResultSchema.safeParse(data.output);
|
||||
let unifiedDiff: string | undefined;
|
||||
if (resultParsed.success && resultParsed.data.files) {
|
||||
const matchPaths = new Set<string>([firstFile.path]);
|
||||
if (movePath) matchPaths.add(movePath);
|
||||
|
||||
const resultFile =
|
||||
resultParsed.data.files.find((f) => matchPaths.has(f.path)) ??
|
||||
resultParsed.data.files[0];
|
||||
unifiedDiff = resultFile?.patch;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "edit",
|
||||
filePath,
|
||||
oldString: "",
|
||||
newString: "",
|
||||
unifiedDiff,
|
||||
};
|
||||
});
|
||||
|
||||
// Read (Claude): { file_path: string, offset?: number, limit?: number }
|
||||
const ReadInputSchema = z.object({
|
||||
file_path: z.string(),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const ReadResultSchema = z.object({
|
||||
type: z.literal("file_read"),
|
||||
filePath: z.string(),
|
||||
content: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
const ReadToolCallSchema = z
|
||||
.object({
|
||||
input: ReadInputSchema,
|
||||
output: ReadResultSchema,
|
||||
})
|
||||
.transform((data): { type: "read"; filePath: string; content: string; offset?: number; limit?: number } => ({
|
||||
type: "read",
|
||||
filePath: data.input.file_path,
|
||||
content: data.output.content,
|
||||
offset: data.input.offset,
|
||||
limit: data.input.limit,
|
||||
}));
|
||||
|
||||
// Codex read_file: { path: string }
|
||||
const CodexReadInputSchema = z.object({
|
||||
path: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexReadResultSchema = z.object({
|
||||
type: z.literal("read_file"),
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexReadToolCallSchema = z
|
||||
.object({
|
||||
input: CodexReadInputSchema,
|
||||
output: CodexReadResultSchema,
|
||||
})
|
||||
.transform((data): { type: "read"; filePath: string; content: string; offset?: number; limit?: number } => ({
|
||||
type: "read",
|
||||
filePath: data.input.path,
|
||||
content: data.output.content,
|
||||
}));
|
||||
|
||||
// Thinking: input is the thinking text content
|
||||
const ThinkingInputSchema = z.string();
|
||||
|
||||
const ThinkingToolCallSchema = z
|
||||
.object({
|
||||
input: ThinkingInputSchema,
|
||||
})
|
||||
.transform((data) => ({
|
||||
type: "thinking" as const,
|
||||
content: data.input,
|
||||
}));
|
||||
|
||||
// Generic tool call (fallback)
|
||||
const GenericToolCallSchema = z
|
||||
.object({
|
||||
input: z.unknown(),
|
||||
output: z.unknown(),
|
||||
})
|
||||
.transform((data) => {
|
||||
const inputPairs = KeyValuePairsSchema.safeParse(data.input);
|
||||
const outputPairs = KeyValuePairsSchema.safeParse(data.output);
|
||||
|
||||
return {
|
||||
type: "generic" as const,
|
||||
input: inputPairs.success ? inputPairs.data : [],
|
||||
output: outputPairs.success ? outputPairs.data : [],
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Detail type ----
|
||||
|
||||
export type ToolCallDetail =
|
||||
| { type: "shell"; command: string; output: string }
|
||||
| { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string }
|
||||
| { type: "read"; filePath: string; content: string; offset?: number; limit?: number }
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "generic"; input: KeyValuePair[]; output: KeyValuePair[] };
|
||||
|
||||
function parseDetail(toolName: string, input: unknown, output: unknown): ToolCallDetail {
|
||||
// Thinking is matched by tool name since input is a string, not an object
|
||||
if (toolName === "thinking") {
|
||||
const thinkingParsed = ThinkingToolCallSchema.safeParse({ input });
|
||||
if (thinkingParsed.success) {
|
||||
return thinkingParsed.data;
|
||||
}
|
||||
return { type: "thinking", content: "" };
|
||||
}
|
||||
|
||||
const shellParsed = ShellToolCallSchema.safeParse({ input, output });
|
||||
if (shellParsed.success) {
|
||||
return shellParsed.data;
|
||||
}
|
||||
|
||||
const editParsed = EditToolCallSchema.safeParse({ input, output });
|
||||
if (editParsed.success) {
|
||||
return editParsed.data;
|
||||
}
|
||||
|
||||
const applyPatchParsed = ApplyPatchToolCallSchema.safeParse({ input, output });
|
||||
if (applyPatchParsed.success) {
|
||||
return applyPatchParsed.data;
|
||||
}
|
||||
|
||||
const readParsed = ReadToolCallSchema.safeParse({ input, output });
|
||||
if (readParsed.success) {
|
||||
return readParsed.data;
|
||||
}
|
||||
|
||||
const codexReadParsed = CodexReadToolCallSchema.safeParse({ input, output });
|
||||
if (codexReadParsed.success) {
|
||||
return codexReadParsed.data;
|
||||
}
|
||||
|
||||
const genericParsed = GenericToolCallSchema.parse({ input, output });
|
||||
return genericParsed;
|
||||
}
|
||||
|
||||
// ---- Error formatting ----
|
||||
|
||||
const ToolResultErrorSchema = z.object({
|
||||
type: z.literal("tool_result"),
|
||||
content: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
function formatError(error: unknown): string | undefined {
|
||||
if (error === undefined || error === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const str = z.string().safeParse(error);
|
||||
if (str.success) {
|
||||
return str.data;
|
||||
}
|
||||
|
||||
const toolResult = ToolResultErrorSchema.safeParse(error);
|
||||
if (toolResult.success) {
|
||||
return toolResult.data.content;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(error, null, 2);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Unified ToolCallDisplayInfo ----
|
||||
|
||||
export interface ToolCallInput {
|
||||
name: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
error?: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface ToolCallDisplayInfo {
|
||||
displayName: string;
|
||||
kind: ToolCallKind;
|
||||
summary?: string;
|
||||
detail: ToolCallDetail;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
export function parseToolCallDisplay(toolCall: ToolCallInput): ToolCallDisplayInfo {
|
||||
const displayName = resolveDisplayName(toolCall.name);
|
||||
const kind = resolveKind(toolCall.name);
|
||||
const summary = extractSummary(toolCall.input, toolCall.metadata, toolCall.cwd);
|
||||
const detail = parseDetail(toolCall.name, toolCall.input, toolCall.output);
|
||||
const errorText = formatError(toolCall.error);
|
||||
|
||||
return {
|
||||
displayName,
|
||||
kind,
|
||||
summary,
|
||||
detail,
|
||||
errorText,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- TodoWrite Extraction ----
|
||||
|
||||
export interface TodoItem {
|
||||
@@ -127,18 +582,17 @@ export interface TodoItem {
|
||||
}
|
||||
|
||||
export function extractTodos(value: unknown): TodoItem[] {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
const parsed = z.object({
|
||||
todos: z.array(z.object({
|
||||
content: z.string(),
|
||||
status: z.enum(["pending", "in_progress", "completed"]),
|
||||
activeForm: z.string().optional(),
|
||||
})),
|
||||
}).safeParse(value);
|
||||
|
||||
if (!parsed.success) {
|
||||
return [];
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (!Array.isArray(obj.todos)) {
|
||||
return [];
|
||||
}
|
||||
return obj.todos.filter(
|
||||
(t): t is TodoItem =>
|
||||
typeof t === "object" &&
|
||||
t !== null &&
|
||||
typeof (t as Record<string, unknown>).content === "string" &&
|
||||
typeof (t as Record<string, unknown>).status === "string"
|
||||
);
|
||||
|
||||
return parsed.data.todos;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user