diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 90b24d0fc..5f38e0b33 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -363,6 +363,7 @@ export function AgentStreamView({ return ( createMarkdownStyles(theme), [theme]); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 77c39b181..48af2d05a 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -420,7 +420,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ position: "absolute", top: 0, bottom: 0, - width: 100, }, })); @@ -1017,7 +1016,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ if (isLoading) { shimmer.value = -1; shimmer.value = withRepeat( - withTiming(1, { duration: 2400, easing: Easing.bezier(0.4, 0, 0.6, 1) }), + withTiming(1, { duration: 3200, easing: Easing.linear }), -1 ); } else { @@ -1026,7 +1025,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ } }, [isLoading]); - const shimmerBandWidth = 100; + const shimmerBandWidth = 14; const shimmerStyle = useAnimatedStyle(() => { const travel = badgeWidth + shimmerBandWidth; return { @@ -1098,7 +1097,11 @@ const ExpandableBadge = memo(function ExpandableBadge({ {isLoading && badgeWidth > 0 ? ( @@ -1109,10 +1112,13 @@ const ExpandableBadge = memo(function ExpandableBadge({ x2="1" y2="0" > - - - - + + + + + + + @@ -1149,6 +1155,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ interface ToolCallProps { toolName: string; + provider?: string; args: any; result?: any; error?: any; @@ -1176,6 +1183,7 @@ const TOOL_CALL_COMMIT_THRESHOLD_MS = 16; export const ToolCall = memo(function ToolCall({ toolName, + provider, args, result, error, @@ -1197,8 +1205,17 @@ export const ToolCall = memo(function ToolCall({ UnistylesRuntime.breakpoint === "sm"; const displayInfo = useMemo( - () => parseToolCallDisplay({ name: toolName, input: args, output: result, error, metadata, cwd }), - [toolName, args, result, error, metadata, cwd] + () => + parseToolCallDisplay({ + name: toolName, + provider, + input: args, + output: result, + error, + metadata, + cwd, + }), + [toolName, provider, args, result, error, metadata, cwd] ); const { kind, displayName, summary, detail, errorText } = displayInfo; const IconComponent = toolKindIcons[kind] || Wrench; diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 53f024566..7898cda4b 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -384,6 +384,54 @@ function testToolCallParsedPayloadHydration() { assert.ok(commandPass, 'Command payload should persist across hydration'); } +function testNullToolPayloadDoesNotEraseKnownInput() { + const timestampStart = new Date("2025-01-01T10:36:00Z"); + const timestampFinish = new Date("2025-01-01T10:36:05Z"); + const callId = "null-input-preserve"; + + const updates: Array<{ event: AgentStreamEventPayload; timestamp: Date }> = [ + { + event: { + type: "timeline", + provider: "claude", + item: { + type: "tool_call", + name: "shell", + status: "pending", + callId, + input: { command: "pwd" }, + }, + }, + timestamp: timestampStart, + }, + { + event: { + type: "timeline", + provider: "claude", + item: { + type: "tool_call", + name: "shell", + status: "completed", + callId, + input: null, + output: { type: "command", output: "/tmp" }, + }, + }, + timestamp: timestampFinish, + }, + ]; + + const state = hydrateStreamState(updates); + const commandEntry = state.find( + (item): item is AgentToolCallItem => + isAgentToolCallItem(item) && item.payload.data.callId === callId + ); + + assert.ok(commandEntry, "Tool call should exist"); + assert.strictEqual(commandEntry.payload.data.status, "completed"); + assert.deepStrictEqual(commandEntry.payload.data.input, { command: "pwd" }); +} + function buildClaudeToolUseBlock({ id, name, @@ -1116,6 +1164,7 @@ describe('stream timeline reducers', () => { it('infers failure from error payloads', testToolCallFailureInferenceFromError); it('reconciles late call IDs against pending entries', testToolCallLateCallIdReconciliation); it('persists parsed read/edit/command payloads after hydration', testToolCallParsedPayloadHydration); + it('preserves known input when later tool updates send null input', testNullToolPayloadDoesNotEraseKnownInput); it('hydrates Claude tool bodies with parsed content', testClaudeHydratedToolBodies); it('preserves whitespace in assistant chunk concatenation', testAssistantWhitespacePreservation); it('hydrates user messages and deduplicates optimistic/live entries', testUserMessageHydration); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 345e11ac1..dd6752268 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -431,15 +431,15 @@ function appendAgentToolCall( const next = [...state]; const existing = next[existingIndex] as AgentToolCallItem; const mergedInput = - payloadData.input !== undefined + hasValue(payloadData.input) ? payloadData.input : existing.payload.data.input; const mergedResult = - payloadData.result !== undefined + hasValue(payloadData.result) ? payloadData.result : existing.payload.data.result; const mergedError = - payloadData.error !== undefined + hasValue(payloadData.error) ? payloadData.error : existing.payload.data.error; const mergedStatus = mergeToolCallStatus( diff --git a/packages/app/src/utils/tool-call-parsers.test.ts b/packages/app/src/utils/tool-call-parsers.test.ts index cbe6dbe9e..a54143e89 100644 --- a/packages/app/src/utils/tool-call-parsers.test.ts +++ b/packages/app/src/utils/tool-call-parsers.test.ts @@ -64,6 +64,35 @@ describe("parseToolCallDisplay", () => { } }); + test("falls back to output command when shell input is missing", () => { + const output = { type: "command", command: "pwd", output: "/some/path" }; + + const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Bash", output }); + expect(info.summary).toBe("pwd"); + expect(info.detail.type).toBe("shell"); + if (info.detail.type === "shell") { + expect(info.detail.command).toBe("pwd"); + expect(info.detail.output).toBe("/some/path"); + } + }); + + test("falls back to read output path when input is missing", () => { + const info: ToolCallDisplayInfo = parseToolCallDisplay({ + name: "Read", + output: { + type: "file_read", + filePath: "/some/file.txt", + content: "hello", + }, + }); + expect(info.summary).toBe("/some/file.txt"); + expect(info.detail.type).toBe("read"); + if (info.detail.type === "read") { + expect(info.detail.filePath).toBe("/some/file.txt"); + expect(info.detail.content).toBe("hello"); + } + }); + test("strips shell + cd wrapper from command (Codex exec_command style)", () => { const input = { command: @@ -110,6 +139,14 @@ describe("parseToolCallDisplay", () => { expect(info.displayName).toBe("Read"); }); + test("uses stable label for Edit in frontend", () => { + const input = { file_path: "/some/file.txt", old_string: "a", new_string: "b" }; + const first = parseToolCallDisplay({ name: "Edit", input }); + const second = parseToolCallDisplay({ name: "Edit", input }); + expect(first.displayName).toBe("Edit"); + expect(second.displayName).toBe("Edit"); + }); + test("normalizes tool names - paseo_voice.speak to Speak", () => { const input = { text: "hello from namespaced speak" }; const info = parseToolCallDisplay({ name: "paseo_voice.speak", input }); @@ -128,6 +165,15 @@ describe("parseToolCallDisplay", () => { expect(info.displayName).toBe("MyCustomTool"); }); + test("does not let Task metadata override non-Task summary", () => { + const info = parseToolCallDisplay({ + name: "shell", + input: { command: "pwd" }, + metadata: { subAgentActivity: "Read" }, + }); + expect(info.summary).toBe("pwd"); + }); + test("parses non-command tool call into generic detail", () => { const input = { file_path: "/some/file.txt" }; const output = { content: "file contents here", lineCount: 42 }; @@ -141,12 +187,16 @@ describe("parseToolCallDisplay", () => { } }); - test("handles file_write output as generic", () => { + test("handles file_write output as edit", () => { const input = { file_path: "/some/file.txt", content: "new content" }; const output = { type: "file_write", filePath: "/some/file.txt" }; const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Write", input, output }); - expect(info.detail.type).toBe("generic"); + expect(info.detail.type).toBe("edit"); + expect(info.summary).toBe("/some/file.txt"); + if (info.detail.type === "edit") { + expect(info.detail.filePath).toBe("/some/file.txt"); + } }); test("handles undefined input and output gracefully", () => { @@ -366,14 +416,13 @@ describe("parseToolCallDisplay - read_file (Codex)", () => { } }); - test("Codex read_file falls through to generic when result is missing", () => { + test("Codex read_file stays read when result is missing", () => { const input = { path: "/some/file.txt", }; const info = parseToolCallDisplay({ name: "read_file", input }); - // Without result, it can't match the schema so falls through to generic - expect(info.detail.type).toBe("generic"); + expect(info.detail.type).toBe("read"); expect(info.displayName).toBe("Read"); }); }); diff --git a/packages/server/package.json b/packages/server/package.json index dc935208e..94c3ccb6c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -16,6 +16,7 @@ "generate:config-schema": "tsx scripts/generate-config-schema.ts", "speech:models": "tsx scripts/list-speech-models.ts", "speech:download": "tsx scripts/download-speech-models.ts", + "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", diff --git a/packages/server/scripts/transcribe-local-wav.ts b/packages/server/scripts/transcribe-local-wav.ts new file mode 100644 index 000000000..3c7ff94f6 --- /dev/null +++ b/packages/server/scripts/transcribe-local-wav.ts @@ -0,0 +1,180 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { STTManager } from "../src/server/agent/stt-manager.js"; +import { createRootLogger } from "../src/server/logger.js"; +import { resolvePaseoHome } from "../src/server/paseo-home.js"; +import { + DEFAULT_LOCAL_STT_MODEL, + DEFAULT_LOCAL_TTS_MODEL, + LocalSttModelIdSchema, + type LocalSttModelId, +} from "../src/server/speech/providers/local/models.js"; +import { initializeLocalSpeechServices } from "../src/server/speech/providers/local/runtime.js"; +import type { RequestedSpeechProviders } from "../src/server/speech/speech-types.js"; + +type CliOptions = { + wavPath: string; + outPath?: string; + model: LocalSttModelId; + modelsDir: string; + autoDownload: boolean; +}; + +function usage(): string { + return [ + "Usage: npm run speech:transcribe:local -- [--out ] [--model ] [--models-dir ] [--no-auto-download]", + "", + "Examples:", + " npm run speech:transcribe:local -- ./sample.wav", + " npm run speech:transcribe:local -- ./sample.wav --out ./tmp/sample.transcript.txt", + "", + "Env fallbacks:", + " PASEO_LOCAL_MODELS_DIR, PASEO_LOCAL_STT_MODEL", + ].join("\n"); +} + +function parseArgs(argv: string[]): CliOptions { + if (argv.includes("--help") || argv.includes("-h")) { + process.stdout.write(`${usage()}\n`); + process.exit(0); + } + + if (argv.length === 0) { + throw new Error(`Missing \n\n${usage()}`); + } + + const paseoHome = resolvePaseoHome(); + const defaultModelsDir = + process.env.PASEO_LOCAL_MODELS_DIR ?? path.join(paseoHome, "models", "local-speech"); + + const positional: string[] = []; + let outPath: string | undefined; + let model = LocalSttModelIdSchema.parse(process.env.PASEO_LOCAL_STT_MODEL ?? DEFAULT_LOCAL_STT_MODEL); + let modelsDir = defaultModelsDir; + let autoDownload = true; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (arg === "--out") { + const next = argv[i + 1]; + if (!next) { + throw new Error("--out requires a value"); + } + outPath = path.resolve(next); + i += 1; + continue; + } + + if (arg === "--model") { + const next = argv[i + 1]; + if (!next) { + throw new Error("--model requires a value"); + } + model = LocalSttModelIdSchema.parse(next); + i += 1; + continue; + } + + if (arg === "--models-dir") { + const next = argv[i + 1]; + if (!next) { + throw new Error("--models-dir requires a value"); + } + modelsDir = path.resolve(next); + i += 1; + continue; + } + + if (arg === "--no-auto-download") { + autoDownload = false; + continue; + } + + if (arg.startsWith("-")) { + throw new Error(`Unknown option: ${arg}`); + } + + positional.push(arg); + } + + if (positional.length === 0) { + throw new Error(`Missing \n\n${usage()}`); + } + + return { + wavPath: path.resolve(positional[0]), + ...(outPath ? { outPath } : {}), + model, + modelsDir, + autoDownload, + }; +} + +async function main(): Promise { + let options: CliOptions; + + try { + options = parseArgs(process.argv.slice(2)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exit(2); + return; + } + + const logger = createRootLogger({ level: "info", format: "pretty" }); + + const providers: RequestedSpeechProviders = { + dictationStt: { provider: "local", explicit: true }, + voiceStt: { provider: "local", explicit: true }, + // Not used here, but required by the shared runtime config shape. + voiceTts: { provider: "openai", explicit: false }, + }; + + const runtime = await initializeLocalSpeechServices({ + providers, + speechConfig: { + providers, + local: { + modelsDir: options.modelsDir, + autoDownload: options.autoDownload, + models: { + dictationStt: options.model, + voiceStt: options.model, + voiceTts: DEFAULT_LOCAL_TTS_MODEL, + }, + }, + }, + logger, + }); + + try { + if (!runtime.sttService) { + throw new Error( + "Local STT service is unavailable. Check model files or run `npm run speech:download -- --model " + + options.model + + "`." + ); + } + + const audio = await readFile(options.wavPath); + const manager = new STTManager("dev-local-wav-transcribe", logger, runtime.sttService); + const result = await manager.transcribe(audio, "audio/wav", { label: "dev-local-wav-transcribe" }); + + const transcript = result.text.trim(); + + if (options.outPath) { + await mkdir(path.dirname(options.outPath), { recursive: true }); + await writeFile(options.outPath, `${transcript}\n`, "utf8"); + logger.info({ outPath: options.outPath }, "Wrote transcript"); + } + + process.stdout.write(`${transcript}\n`); + } finally { + runtime.cleanup(); + } +} + +await main(); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 13730d5ef..1d28f7870 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -159,7 +159,11 @@ export function curateAgentActivity( case "tool_call": { flushBuffers(lines, buffers); const inputJson = formatToolInputJson(item.input); - const { displayName, summary } = parseToolCallDisplay({ name: item.name, input: item.input, metadata: item.metadata }); + const { displayName, summary } = parseToolCallDisplay({ + name: item.name, + input: item.input, + metadata: item.metadata, + }); if (isLikelyExternalToolName(item.name) && inputJson) { lines.push(`[${displayName}] ${inputJson}`); break; diff --git a/packages/server/src/utils/tool-call-parsers.test.ts b/packages/server/src/utils/tool-call-parsers.test.ts index e82a98068..d6325011e 100644 --- a/packages/server/src/utils/tool-call-parsers.test.ts +++ b/packages/server/src/utils/tool-call-parsers.test.ts @@ -98,6 +98,7 @@ describe("parseToolCallDisplay", () => { describe("summary (was extractPrincipalParam)", () => { test("extracts and strips shell wrapper from command string", () => { const result = parseToolCallDisplay({ + provider: "claude", name: "Bash", input: { command: "/bin/zsh -lc cd /Users/dev/project && npm run format" }, }); @@ -120,6 +121,14 @@ describe("parseToolCallDisplay", () => { expect(result.summary).toBe("npm run build"); }); + test("falls back to output command when shell input is missing", () => { + const result = parseToolCallDisplay({ + name: "Bash", + output: { type: "command", command: "pwd", output: "/tmp" }, + }); + expect(result.summary).toBe("pwd"); + }); + test("extracts file_path and strips cwd", () => { const result = parseToolCallDisplay({ name: "Read", @@ -129,6 +138,34 @@ describe("parseToolCallDisplay", () => { expect(result.summary).toBe("src/file.ts"); }); + test("falls back to read output path when input is missing", () => { + const result = parseToolCallDisplay({ + provider: "codex", + name: "Read", + output: { + type: "file_read", + filePath: "/Users/dev/project/src/file.ts", + content: "hello", + }, + cwd: "/Users/dev/project", + }); + expect(result.summary).toBe("src/file.ts"); + }); + + test("falls back to edit output path when input is missing", () => { + const result = parseToolCallDisplay({ + name: "Edit", + output: { + type: "file_edit", + filePath: "/Users/dev/project/src/file.ts", + oldContent: "a", + newContent: "b", + }, + cwd: "/Users/dev/project", + }); + expect(result.summary).toBe("src/file.ts"); + }); + test("extracts pattern without modification", () => { const result = parseToolCallDisplay({ name: "Grep", @@ -168,7 +205,7 @@ describe("parseToolCallDisplay", () => { }); describe("summary from metadata", () => { - test("subAgentActivity in metadata takes priority over input", () => { + test("subAgentActivity in metadata takes priority for Task", () => { const result = parseToolCallDisplay({ name: "Task", input: { description: "Explore codebase" }, @@ -185,6 +222,15 @@ describe("parseToolCallDisplay", () => { }); expect(result.summary).toBe("Bash"); }); + + test("non-Task tools keep their parsed summary even when metadata is present", () => { + const result = parseToolCallDisplay({ + name: "Bash", + input: { command: "pwd" }, + metadata: { subAgentActivity: "Read" }, + }); + expect(result.summary).toBe("pwd"); + }); }); describe("kind", () => { diff --git a/packages/server/src/utils/tool-call-parsers.ts b/packages/server/src/utils/tool-call-parsers.ts index 791438822..ffbfd3756 100644 --- a/packages/server/src/utils/tool-call-parsers.ts +++ b/packages/server/src/utils/tool-call-parsers.ts @@ -3,7 +3,14 @@ import { z } from "zod"; // ---- Tool Call Kind (icon category) ---- -export type ToolCallKind = "read" | "edit" | "execute" | "search" | "thinking" | "agent" | "tool"; +export type ToolCallKind = + | "read" + | "edit" + | "execute" + | "search" + | "thinking" + | "agent" + | "tool"; const TOOL_KIND_MAP: Record = { read: "read", @@ -24,9 +31,12 @@ const TOOL_KIND_MAP: Record = { const TOOL_NAME_MAP: Record = { shell: "Shell", - Bash: "Shell", + bash: "Shell", + read: "Read", read_file: "Read", apply_patch: "Edit", + edit: "Edit", + write: "Edit", paseo_worktree_setup: "Setup", thinking: "Thinking", }; @@ -48,7 +58,9 @@ export function normalizeToolDisplayName(toolName: string): string { } function resolveDisplayName(rawName: string): string { - return normalizeToolDisplayName(TOOL_NAME_MAP[rawName] ?? rawName); + const normalizedName = rawName.trim().toLowerCase(); + const entry = TOOL_NAME_MAP[normalizedName]; + return normalizeToolDisplayName(entry ?? rawName); } function resolveKind(rawName: string): ToolCallKind { @@ -56,7 +68,6 @@ function resolveKind(rawName: string): ToolCallKind { 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"; } @@ -81,11 +92,8 @@ export function stripCwdPrefix(filePath: string, cwd?: string): string { return filePath; } -// Strips shell wrapper prefixes like: -// - `/bin/zsh -lc cd /path && ` -// - `/bin/zsh -lc "cd /path && "` -// 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 SHELL_WRAPPER_PREFIX_PATTERN = + /^\/bin\/(?:zsh|bash|sh)\s+(?:-[a-zA-Z]+\s+)?/; const CD_AND_PATTERN = /^cd\s+(?:"[^"]+"|'[^']+'|\S+)\s+&&\s+/; export function stripShellWrapperPrefix(command: string): string { @@ -106,85 +114,39 @@ export function stripShellWrapperPrefix(command: string): string { return rest.replace(CD_AND_PATTERN, ""); } -// ---- 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 | 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; - } - - const { type, value } = parsed.data; - if (type === "path") { - return stripCwdPrefix(value, cwd); - } - if (type === "command") { - return stripShellWrapperPrefix(value); - } - return value; -} - -// ---- Detail Schemas ---- +// ---- Detail Types ---- export interface KeyValuePair { key: string; value: string; } +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[] }; + +type ParsedToolCallContent = { + detail: ToolCallDetail; + summary?: string; +}; + +// ---- Generic Detail Schema ---- + function stringifyValue(value: unknown): string { if (value === null) { return "null"; @@ -221,327 +183,980 @@ const KeyValuePairsSchema = z.record(z.unknown()).transform((data) => })) ); -// 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 +const GenericDetailSchema = z .object({ - input: ShellInputSchema, - output: z.unknown(), + input: z.unknown().optional(), + output: z.unknown().optional(), }) - .transform((data) => { - const commandRaw = Array.isArray(data.input.command) - ? data.input.command.join(" ") - : data.input.command; - const command = stripShellWrapperPrefix(commandRaw); + .transform( + (d): ToolCallDetail => ({ + type: "generic", + input: KeyValuePairsSchema.catch([] as KeyValuePair[]).parse(d.input), + output: KeyValuePairsSchema.catch([] as KeyValuePair[]).parse(d.output), + }) + ); - const resultParsed = ShellResultSchema.safeParse(data.output); - if (resultParsed.success) { - return { - type: "shell" as const, - command, - output: stripAnsi(resultParsed.data.output), - }; - } +// ---- Tool Call Shape -> { summary, detail } ---- - const errorParsed = ShellErrorResultSchema.safeParse(data.output); - if (errorParsed.success) { - return { - type: "shell" as const, - command, - output: stripAnsi(errorParsed.data.content), - }; - } +type ProviderCaseKey = "claude" | "codex" | "opencode" | "shared"; +type ToolCaseKey = + | "thinking" + | "bash" + | "shell" + | "read" + | "read_file" + | "edit" + | "write" + | "apply_patch" + | "task" + | "todowrite" + | "todo_write" + | "update_plan" + | "web_search" + | "generic"; - return { - type: "shell" as const, - command, - output: "", - }; - }); +type NormalizedToolCallCase = { + name: string; + provider?: string; + input?: unknown; + output?: unknown; + metadata?: Record; + cwd?: string; + providerCase: ProviderCaseKey; + toolCase: ToolCaseKey; + caseKey: `${ProviderCaseKey}:${ToolCaseKey}`; +}; -// 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 TOOL_CASE_NORMALIZATION_PATTERN = /[.\s-]+/g; + +function normalizeProviderCase(provider?: string): ProviderCaseKey { + const normalized = provider?.trim().toLowerCase(); + if (normalized === "claude") { + return "claude"; + } + if (normalized === "codex") { + return "codex"; + } + if ( + normalized === "opencode" || + normalized === "open_code" || + normalized === "open-code" + ) { + return "opencode"; + } + return "shared"; +} + +function normalizeToolCaseName(name: string): string { + return name + .trim() + .replace(TOOL_CASE_NORMALIZATION_PATTERN, "_") + .toLowerCase(); +} + +function resolveToolCase(normalizedName: string): ToolCaseKey { + const compact = normalizedName.replace(/_/g, ""); + if (normalizedName === "thinking" || compact === "thinking") { + return "thinking"; + } + if (normalizedName === "bash" || compact === "bash") { + return "bash"; + } + if (normalizedName === "shell" || compact === "shell") { + return "shell"; + } + if (normalizedName === "read" || compact === "read") { + return "read"; + } + if (normalizedName === "read_file" || compact === "readfile") { + return "read_file"; + } + if (normalizedName === "edit" || compact === "edit") { + return "edit"; + } + if (normalizedName === "write" || compact === "write") { + return "write"; + } + if (normalizedName === "apply_patch" || compact === "applypatch") { + return "apply_patch"; + } + if (normalizedName === "task" || compact === "task") { + return "task"; + } + if (normalizedName === "todowrite" || compact === "todowrite") { + return "todowrite"; + } + if (normalizedName === "todo_write") { + return "todo_write"; + } + if (normalizedName === "update_plan" || compact === "updateplan") { + return "update_plan"; + } + if (normalizedName === "web_search" || compact === "websearch") { + return "web_search"; + } + return "generic"; +} + +const CLAUDE_TOOL_CASES: ReadonlySet = new Set([ + "thinking", + "bash", + "shell", + "read", + "read_file", + "edit", + "write", + "apply_patch", + "task", + "todowrite", + "todo_write", + "update_plan", + "web_search", + "generic", ]); -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; +const CODEX_TOOL_CASES: ReadonlySet = new Set([ + "thinking", + "bash", + "shell", + "read", + "read_file", + "edit", + "write", + "apply_patch", + "task", + "todowrite", + "todo_write", + "update_plan", + "web_search", + "generic", +]); +const OPENCODE_TOOL_CASES: ReadonlySet = new Set([ + "thinking", + "bash", + "shell", + "read", + "read_file", + "edit", + "write", + "apply_patch", + "task", + "todowrite", + "todo_write", + "update_plan", + "web_search", + "generic", +]); + +const SHARED_TOOL_CASES: ReadonlySet = new Set([ + "thinking", + "bash", + "shell", + "read", + "read_file", + "edit", + "write", + "apply_patch", + "task", + "todowrite", + "todo_write", + "update_plan", + "web_search", + "generic", +]); + +const PROVIDER_TOOL_CASES: Record> = { + claude: CLAUDE_TOOL_CASES, + codex: CODEX_TOOL_CASES, + opencode: OPENCODE_TOOL_CASES, + shared: SHARED_TOOL_CASES, +}; + +function resolveProviderToolCase( + providerCase: ProviderCaseKey, + normalizedName: string +): ToolCaseKey { + const candidate = resolveToolCase(normalizedName); + const allowed = PROVIDER_TOOL_CASES[providerCase]; + return allowed.has(candidate) ? candidate : "generic"; +} + +const ToolCallContentInputSchema = z + .object({ + name: z.string().catch("unknown"), + provider: z.string().optional(), + input: z.unknown().optional(), + output: z.unknown().optional(), + metadata: z.record(z.unknown()).optional().catch(undefined), + cwd: z.string().optional(), + }) + .passthrough() + .transform((toolCall): NormalizedToolCallCase => { + const providerCase = normalizeProviderCase(toolCall.provider); + const normalizedName = normalizeToolCaseName(toolCall.name); + const toolCase = resolveProviderToolCase(providerCase, normalizedName); return { - type: "edit", - filePath, - oldString, - newString, + ...toolCall, + providerCase, + toolCase, + caseKey: `${providerCase}:${toolCase}`, }; }); -// Codex apply_patch -const ApplyPatchFileKindSchema = z +const ShellCommandSchema = z .union([ z.string(), - z.object({ - type: z.string().optional(), - move_path: z.string().nullable().optional(), - movePath: z.string().nullable().optional(), - }).passthrough(), + z.array(z.string()).nonempty().transform((parts) => parts.join(" ")), + ]) + .transform((command) => stripShellWrapperPrefix(command)); + +const ShellInputSchema = z.object({ command: ShellCommandSchema }).passthrough(); + +const ShellOutputCommandSchema = z + .object({ + type: z.literal("command"), + command: ShellCommandSchema.optional(), + }) + .passthrough(); + +const ShellOutputTextSchema = z + .union([ + z.string().transform((text) => stripAnsi(text)), + z + .object({ + type: z.literal("command"), + output: z.string().optional(), + }) + .passthrough() + .transform((d) => stripAnsi(d.output ?? "")), + z + .object({ + type: z.literal("tool_result"), + content: z.string(), + is_error: z.literal(true), + }) + .passthrough() + .transform((d) => stripAnsi(d.content)), + ]) + .catch(""); + +const ReadInputSchema = z.union([ + z + .object({ + file_path: z.string(), + offset: z.number().optional(), + limit: z.number().optional(), + }) + .passthrough() + .transform((d) => ({ + filePath: d.file_path, + offset: d.offset, + limit: d.limit, + })), + z + .object({ + filePath: z.string(), + offset: z.number().optional(), + limit: z.number().optional(), + }) + .passthrough() + .transform((d) => ({ + filePath: d.filePath, + offset: d.offset, + limit: d.limit, + })), + z + .object({ + path: z.string(), + offset: z.number().optional(), + limit: z.number().optional(), + }) + .passthrough() + .transform((d) => ({ + filePath: d.path, + offset: d.offset, + limit: d.limit, + })), +]); + +const ReadOutputSchema = z.union([ + z + .object({ + type: z.literal("file_read"), + filePath: z.string(), + content: z.string(), + }) + .passthrough() + .transform((d) => ({ + filePath: d.filePath, + content: d.content, + })), + z + .object({ + type: z.literal("read_file"), + path: z.string(), + content: z.string(), + }) + .passthrough() + .transform((d) => ({ + filePath: d.path, + content: d.content, + })), + z.string().transform((content) => ({ content })), +]); + +const EditInputSchema = z.union([ + z + .object({ + file_path: z.string(), + old_string: z.string(), + new_string: z.string(), + patch: z.string().optional(), + diff: z.string().optional(), + }) + .passthrough() + .transform((d) => ({ + filePath: d.file_path, + oldString: d.old_string, + newString: d.new_string, + unifiedDiff: d.patch ?? d.diff, + })), + z + .object({ + file_path: z.string(), + old_str: z.string(), + new_str: z.string(), + patch: z.string().optional(), + diff: z.string().optional(), + }) + .passthrough() + .transform((d) => ({ + filePath: d.file_path, + oldString: d.old_str, + newString: d.new_str, + unifiedDiff: d.patch ?? d.diff, + })), + z + .object({ + file_path: z.string(), + content: z.string(), + patch: z.string().optional(), + diff: z.string().optional(), + }) + .passthrough() + .transform((d) => ({ + filePath: d.file_path, + oldString: "", + newString: d.content, + unifiedDiff: d.patch ?? d.diff, + })), + z + .object({ + file_path: z.string(), + patch: z.string().optional(), + diff: z.string().optional(), + }) + .passthrough() + .transform((d) => ({ + filePath: d.file_path, + oldString: "", + newString: "", + unifiedDiff: d.patch ?? d.diff, + })), + z + .object({ + filePath: z.string(), + oldContent: z.string().optional(), + newContent: z.string().optional(), + diff: z.string().optional(), + patch: z.string().optional(), + }) + .passthrough() + .transform((d) => ({ + filePath: d.filePath, + oldString: d.oldContent ?? "", + newString: d.newContent ?? "", + unifiedDiff: d.diff ?? d.patch, + })), + z + .object({ + path: z.string(), + content: z.string().optional(), + diff: z.string().optional(), + patch: z.string().optional(), + }) + .passthrough() + .transform((d) => ({ + filePath: d.path, + oldString: "", + newString: d.content ?? "", + unifiedDiff: d.diff ?? d.patch, + })), +]); + +const EditOutputSchema = z.union([ + z + .object({ + type: z.literal("file_edit"), + filePath: z.string(), + diff: z.string().optional(), + oldContent: z.string().optional(), + newContent: z.string().optional(), + }) + .passthrough() + .transform((d) => ({ + filePath: d.filePath, + oldString: d.oldContent ?? "", + newString: d.newContent ?? "", + unifiedDiff: d.diff, + })), + z + .object({ + type: z.literal("file_write"), + filePath: z.string(), + oldContent: z.string().optional(), + newContent: z.string().optional(), + }) + .passthrough() + .transform((d) => ({ + filePath: d.filePath, + oldString: d.oldContent ?? "", + newString: d.newContent ?? "", + unifiedDiff: undefined, + })), + z + .object({ + files: z + .array( + z + .object({ + path: z.string(), + patch: z.string().optional(), + }) + .passthrough() + ) + .nonempty(), + }) + .passthrough() + .transform((d) => { + const firstFile = d.files[0]; + return { + filePath: firstFile.path, + oldString: "", + newString: "", + unifiedDiff: firstFile.patch, + }; + }), +]); + +const ApplyPatchMovePathSchema = z + .union([ + z.string().transform(() => undefined), + z + .object({ + movePath: z.string().nullable().optional(), + move_path: z.string().nullable().optional(), + }) + .passthrough() + .transform((d) => d.movePath ?? d.move_path ?? undefined), ]) .optional(); -const ApplyPatchMovePathSchema = z.object({ - movePath: z.string().optional(), - move_path: z.string().optional(), -}).passthrough(); +const ApplyPatchInputFileSchema = z + .object({ + path: z.string(), + kind: ApplyPatchMovePathSchema, + }) + .transform((d) => ({ + path: d.path, + movePath: d.kind, + })); -function getApplyPatchMovePath(kind: unknown): string | undefined { - const parsed = ApplyPatchMovePathSchema.safeParse(kind); +const ApplyPatchResultFileSchema = z + .object({ + path: z.string(), + patch: z.string().optional(), + kind: ApplyPatchMovePathSchema, + }) + .transform((d) => ({ + path: d.path, + patch: d.patch, + movePath: d.kind, + })); + +const ApplyPatchInputSchema = z + .object({ + files: z.array(ApplyPatchInputFileSchema).min(1), + }) + .passthrough(); + +const ApplyPatchOutputSchema = z + .object({ + files: z.array(ApplyPatchResultFileSchema).optional(), + message: z.string().optional(), + success: z.boolean().optional(), + }) + .passthrough(); + +const GenericPathSummaryInputSchema = z.union([ + z.object({ file_path: z.string() }).passthrough().transform((d) => d.file_path), + z.object({ filePath: z.string() }).passthrough().transform((d) => d.filePath), + z.object({ path: z.string() }).passthrough().transform((d) => d.path), +]); + +const GenericTextSummaryInputSchema = z.union([ + z.object({ description: z.string() }).passthrough().transform((d) => d.description), + z.object({ title: z.string() }).passthrough().transform((d) => d.title), + z.object({ name: z.string() }).passthrough().transform((d) => d.name), + z.object({ branch: z.string() }).passthrough().transform((d) => d.branch), + z.object({ pattern: z.string() }).passthrough().transform((d) => d.pattern), + z.object({ query: z.string() }).passthrough().transform((d) => d.query), + z.object({ url: z.string() }).passthrough().transform((d) => d.url), + z.object({ text: z.string() }).passthrough().transform((d) => d.text), +]); + +const GenericFilesSummaryInputSchema = z + .object({ + files: z.array(z.object({ path: z.string() })).nonempty(), + }) + .passthrough(); + +const GenericTodosSummaryInputSchema = z + .object({ + todos: z + .array( + z.object({ + content: z.string(), + status: z.enum(["pending", "in_progress", "completed"]), + activeForm: z.string().optional(), + }) + ) + .nonempty(), + }) + .passthrough(); + +const GenericPlanSummaryInputSchema = z + .object({ + plan: z + .array( + z.object({ + step: z.string(), + status: z.enum(["pending", "in_progress", "completed"]).catch("pending"), + }) + ) + .nonempty(), + }) + .passthrough(); + +const TaskSummaryInputSchema = z.union([ + z.object({ description: z.string() }).passthrough().transform((d) => d.description), + z.object({ title: z.string() }).passthrough().transform((d) => d.title), +]); + +function resolveTodoSummary(input: unknown): string | undefined { + const todos = GenericTodosSummaryInputSchema.safeParse(input); + if (todos.success) { + const inProgress = todos.data.todos.find( + (todo) => todo.status === "in_progress" + ); + return inProgress + ? inProgress.activeForm ?? inProgress.content + : `${todos.data.todos.length} tasks`; + } + + const plan = GenericPlanSummaryInputSchema.safeParse(input); + if (plan.success) { + const inProgress = plan.data.plan.find( + (entry) => entry.status === "in_progress" + ); + return inProgress ? inProgress.step : `${plan.data.plan.length} tasks`; + } + + return undefined; +} + +function resolveGenericSummary( + input: unknown, + cwd?: string +): string | undefined { + const path = GenericPathSummaryInputSchema.safeParse(input); + if (path.success) { + return stripCwdPrefix(path.data, cwd); + } + + const text = GenericTextSummaryInputSchema.safeParse(input); + if (text.success) { + return text.data; + } + + const files = GenericFilesSummaryInputSchema.safeParse(input); + if (files.success) { + return stripCwdPrefix(files.data.files[0].path, cwd); + } + + return resolveTodoSummary(input); +} + +function parseThinkingToolCall( + toolCall: NormalizedToolCallCase +): ParsedToolCallContent { + return { + detail: { + type: "thinking", + content: z.string().catch("").parse(toolCall.input), + }, + }; +} + +function parseShellToolCall( + toolCall: NormalizedToolCallCase +): ParsedToolCallContent { + const input = ShellInputSchema.safeParse(toolCall.input); + const outputCommand = ShellOutputCommandSchema.safeParse(toolCall.output); + const command = + input.success + ? input.data.command + : outputCommand.success + ? outputCommand.data.command ?? "" + : ""; + + return { + summary: command.length > 0 ? command : undefined, + detail: { + type: "shell", + command, + output: ShellOutputTextSchema.parse(toolCall.output), + }, + }; +} + +function parseGenericToolCall( + toolCall: NormalizedToolCallCase +): ParsedToolCallContent { + return { + summary: resolveGenericSummary(toolCall.input, toolCall.cwd), + detail: GenericDetailSchema.parse(toolCall), + }; +} + +function parseReadToolCall( + toolCall: NormalizedToolCallCase +): ParsedToolCallContent { + const input = ReadInputSchema.safeParse(toolCall.input); + const output = ReadOutputSchema.safeParse(toolCall.output); + const outputFilePath = + output.success && "filePath" in output.data + ? output.data.filePath + : undefined; + const filePath = + input.success + ? input.data.filePath + : outputFilePath; + + if (!filePath) { + return parseGenericToolCall(toolCall); + } + + return { + summary: stripCwdPrefix(filePath, toolCall.cwd), + detail: { + type: "read", + filePath, + content: output.success ? output.data.content : "", + offset: input.success ? input.data.offset : undefined, + limit: input.success ? input.data.limit : undefined, + }, + }; +} + +function parseEditToolCall( + toolCall: NormalizedToolCallCase +): ParsedToolCallContent { + const input = EditInputSchema.safeParse(toolCall.input); + const output = EditOutputSchema.safeParse(toolCall.output); + const filePath = + input.success + ? input.data.filePath + : output.success + ? output.data.filePath + : undefined; + + if (!filePath) { + return parseGenericToolCall(toolCall); + } + + const unifiedDiff = + (input.success ? input.data.unifiedDiff : undefined) ?? + (output.success ? output.data.unifiedDiff : undefined); + + return { + summary: stripCwdPrefix(filePath, toolCall.cwd), + detail: { + type: "edit", + filePath, + oldString: input.success + ? input.data.oldString + : output.success + ? output.data.oldString + : "", + newString: input.success + ? input.data.newString + : output.success + ? output.data.newString + : "", + ...(unifiedDiff ? { unifiedDiff } : {}), + }, + }; +} + +function parseApplyPatchToolCall( + toolCall: NormalizedToolCallCase +): ParsedToolCallContent { + const input = ApplyPatchInputSchema.safeParse(toolCall.input); + const output = ApplyPatchOutputSchema.safeParse(toolCall.output); + + if (input.success) { + const firstFile = input.data.files[0]; + const outputFiles = output.success ? output.data.files ?? [] : []; + const matchPaths = z.array(z.string()).parse( + [firstFile.path, firstFile.movePath].filter(Boolean) + ); + const matched = + outputFiles.find((file) => matchPaths.includes(file.path)) ?? + outputFiles[0]; + + return { + summary: stripCwdPrefix(firstFile.path, toolCall.cwd), + detail: { + type: "edit", + filePath: firstFile.movePath ?? firstFile.path, + oldString: "", + newString: "", + ...(matched?.patch ? { unifiedDiff: matched.patch } : {}), + }, + }; + } + + if (output.success && output.data.files && output.data.files.length > 0) { + const firstFile = output.data.files[0]; + return { + summary: stripCwdPrefix(firstFile.path, toolCall.cwd), + detail: { + type: "edit", + filePath: firstFile.path, + oldString: "", + newString: "", + ...(firstFile.patch ? { unifiedDiff: firstFile.patch } : {}), + }, + }; + } + + return parseGenericToolCall(toolCall); +} + +function parseTaskToolCall( + toolCall: NormalizedToolCallCase +): ParsedToolCallContent { + const parsedSummary = TaskSummaryInputSchema.safeParse(toolCall.input); + const summary = parsedSummary.success + ? parsedSummary.data + : resolveGenericSummary(toolCall.input, toolCall.cwd); + return { + summary, + detail: GenericDetailSchema.parse(toolCall), + }; +} + +function parseTodosToolCall( + toolCall: NormalizedToolCallCase +): ParsedToolCallContent { + return { + summary: + resolveTodoSummary(toolCall.input) ?? + resolveGenericSummary(toolCall.input, toolCall.cwd), + detail: GenericDetailSchema.parse(toolCall), + }; +} + +function createToolCaseSchema( + caseKey: `${ProviderCaseKey}:${ToolCaseKey}` +) { + return z + .object({ + caseKey: z.literal(caseKey), + }) + .passthrough(); +} + +function parseKnownToolCall( + toolCall: NormalizedToolCallCase +): ParsedToolCallContent { + switch (toolCall.toolCase) { + case "thinking": + return parseThinkingToolCall(toolCall); + case "bash": + case "shell": + return parseShellToolCall(toolCall); + case "read": + case "read_file": + return parseReadToolCall(toolCall); + case "edit": + case "write": + return parseEditToolCall(toolCall); + case "apply_patch": + return parseApplyPatchToolCall(toolCall); + case "task": + return parseTaskToolCall(toolCall); + case "todowrite": + case "todo_write": + case "update_plan": + return parseTodosToolCall(toolCall); + case "web_search": + case "generic": + return parseGenericToolCall(toolCall); + } +} + +const ClaudeToolCallContentSchema = z + .discriminatedUnion("caseKey", [ + createToolCaseSchema("claude:thinking"), + createToolCaseSchema("claude:bash"), + createToolCaseSchema("claude:shell"), + createToolCaseSchema("claude:read"), + createToolCaseSchema("claude:read_file"), + createToolCaseSchema("claude:edit"), + createToolCaseSchema("claude:write"), + createToolCaseSchema("claude:apply_patch"), + createToolCaseSchema("claude:task"), + createToolCaseSchema("claude:todowrite"), + createToolCaseSchema("claude:todo_write"), + createToolCaseSchema("claude:update_plan"), + createToolCaseSchema("claude:web_search"), + createToolCaseSchema("claude:generic"), + ]) + .transform((toolCall): ParsedToolCallContent => + parseKnownToolCall(toolCall as NormalizedToolCallCase) + ); + +const CodexToolCallContentSchema = z + .discriminatedUnion("caseKey", [ + createToolCaseSchema("codex:thinking"), + createToolCaseSchema("codex:bash"), + createToolCaseSchema("codex:shell"), + createToolCaseSchema("codex:read"), + createToolCaseSchema("codex:read_file"), + createToolCaseSchema("codex:edit"), + createToolCaseSchema("codex:write"), + createToolCaseSchema("codex:apply_patch"), + createToolCaseSchema("codex:task"), + createToolCaseSchema("codex:todowrite"), + createToolCaseSchema("codex:todo_write"), + createToolCaseSchema("codex:update_plan"), + createToolCaseSchema("codex:web_search"), + createToolCaseSchema("codex:generic"), + ]) + .transform((toolCall): ParsedToolCallContent => + parseKnownToolCall(toolCall as NormalizedToolCallCase) + ); + +const OpenCodeToolCallContentSchema = z + .discriminatedUnion("caseKey", [ + createToolCaseSchema("opencode:thinking"), + createToolCaseSchema("opencode:bash"), + createToolCaseSchema("opencode:shell"), + createToolCaseSchema("opencode:read"), + createToolCaseSchema("opencode:read_file"), + createToolCaseSchema("opencode:edit"), + createToolCaseSchema("opencode:write"), + createToolCaseSchema("opencode:apply_patch"), + createToolCaseSchema("opencode:task"), + createToolCaseSchema("opencode:todowrite"), + createToolCaseSchema("opencode:todo_write"), + createToolCaseSchema("opencode:update_plan"), + createToolCaseSchema("opencode:web_search"), + createToolCaseSchema("opencode:generic"), + ]) + .transform((toolCall): ParsedToolCallContent => + parseKnownToolCall(toolCall as NormalizedToolCallCase) + ); + +const SharedToolCallContentSchema = z + .discriminatedUnion("caseKey", [ + createToolCaseSchema("shared:thinking"), + createToolCaseSchema("shared:bash"), + createToolCaseSchema("shared:shell"), + createToolCaseSchema("shared:read"), + createToolCaseSchema("shared:read_file"), + createToolCaseSchema("shared:edit"), + createToolCaseSchema("shared:write"), + createToolCaseSchema("shared:apply_patch"), + createToolCaseSchema("shared:task"), + createToolCaseSchema("shared:todowrite"), + createToolCaseSchema("shared:todo_write"), + createToolCaseSchema("shared:update_plan"), + createToolCaseSchema("shared:web_search"), + createToolCaseSchema("shared:generic"), + ]) + .transform((toolCall): ParsedToolCallContent => + parseKnownToolCall(toolCall as NormalizedToolCallCase) + ); + +const ToolCallContentSchema = ToolCallContentInputSchema.pipe( + z.union([ + ClaudeToolCallContentSchema, + CodexToolCallContentSchema, + OpenCodeToolCallContentSchema, + SharedToolCallContentSchema, + ]) +); + +const MetadataSummarySchema = z + .object({ subAgentActivity: z.string() }) + .transform((d) => d.subAgentActivity); + +function resolveMetadataSummary( + toolName: string, + metadata: Record | undefined +): string | undefined { + if (toolName.trim().toLowerCase() !== "task") { + return undefined; + } + const parsed = MetadataSummarySchema.safeParse(metadata); 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([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; + const summary = parsed.data.trim(); + return summary.length > 0 ? summary : undefined; } // ---- 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); - } -} +const ErrorTextSchema = z.union([ + z.undefined().transform(() => undefined), + z.null().transform(() => undefined), + z.string(), + z + .object({ + type: z.literal("tool_result"), + content: z.string(), + }) + .passthrough() + .transform((d) => d.content), + z.unknown().transform((value) => { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + }), +]); // ---- Unified ToolCallDisplayInfo ---- export interface ToolCallInput { name: string; + provider?: string; input?: unknown; output?: unknown; error?: unknown; @@ -557,20 +1172,34 @@ export interface ToolCallDisplayInfo { 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); +export const ToolCallDisplaySchema = z + .object({ + name: z.string().catch("unknown"), + provider: z.string().optional(), + input: z.unknown().optional(), + output: z.unknown().optional(), + error: z.unknown().optional(), + metadata: z.record(z.unknown()).optional().catch(undefined), + cwd: z.string().optional().catch(undefined), + }) + .transform((toolCall): ToolCallDisplayInfo => { + const parsed = ToolCallContentSchema.parse(toolCall); + const metadataSummary = resolveMetadataSummary( + toolCall.name, + toolCall.metadata + ); - return { - displayName, - kind, - summary, - detail, - errorText, - }; + return { + displayName: resolveDisplayName(toolCall.name), + kind: resolveKind(toolCall.name), + summary: metadataSummary ?? parsed.summary, + detail: parsed.detail, + errorText: ErrorTextSchema.parse(toolCall.error), + }; + }); + +export function parseToolCallDisplay(toolCall: ToolCallInput): ToolCallDisplayInfo { + return ToolCallDisplaySchema.parse(toolCall); } // ---- TodoWrite Extraction ---- diff --git a/paseo@0.1.0 b/paseo@0.1.0 new file mode 100644 index 000000000..75f939caa --- /dev/null +++ b/paseo@0.1.0 @@ -0,0 +1,4 @@ + +> paseo@0.1.0 cli +> npx tsx packages/cli/src/index.js logs cli --filter tools +