From ee6b0da976e9d8fc4f86104947601e6829ee1cbe Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:02:50 +0100 Subject: [PATCH] feat: Add tool correlation, toolInput metadata, and unknown-type fallbacks to parser - Extend ParsedMessage.meta with toolInput to expose raw tool arguments - Add toolUseRegistry to correlate tool_result blocks back to originating tool_use - Set toolInput on tool_call messages and populate meta.toolName/toolInput on tool_result - Fix tool_result with is_error:true now correctly produces type "error" - Add catch-all for unknown top-level event types (emits system message) - Add catch-all for unknown assistant content block types (emits system message) - Add unit tests covering all 8 scenarios including regression cases Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/lib/parse-agent-output.test.ts | 264 ++++++++++++++++++++ apps/web/src/lib/parse-agent-output.ts | 29 ++- 2 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/parse-agent-output.test.ts diff --git a/apps/web/src/lib/parse-agent-output.test.ts b/apps/web/src/lib/parse-agent-output.test.ts new file mode 100644 index 0000000..52c7fae --- /dev/null +++ b/apps/web/src/lib/parse-agent-output.test.ts @@ -0,0 +1,264 @@ +import { parseAgentOutput } from "./parse-agent-output"; + +function chunk(events: object[]): string { + return events.map((e) => JSON.stringify(e)).join("\n"); +} + +describe("parseAgentOutput", () => { + // 1. toolInput is set on tool_call messages + it("sets meta.toolInput on tool_call messages", () => { + const input = chunk([ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "tu1", + name: "Read", + input: { file_path: "/foo.ts" }, + }, + ], + }, + }, + ]); + const messages = parseAgentOutput(input); + const toolCall = messages.find((m) => m.type === "tool_call"); + expect(toolCall).toBeDefined(); + expect(toolCall!.meta?.toolInput).toEqual({ file_path: "/foo.ts" }); + }); + + // 2. tool_result with tool_use_id gets meta.toolName and meta.toolInput from registry + it("correlates tool_result to its tool_use via registry", () => { + const input = chunk([ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "tu1", + name: "Read", + input: { file_path: "/foo.ts" }, + }, + ], + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tu1", + content: "file contents", + }, + ], + }, + }, + ]); + const messages = parseAgentOutput(input); + const toolResult = messages.find((m) => m.type === "tool_result"); + expect(toolResult).toBeDefined(); + expect(toolResult!.meta?.toolName).toBe("Read"); + expect(toolResult!.meta?.toolInput).toEqual({ file_path: "/foo.ts" }); + }); + + // 3. tool_result with no matching registry entry has no meta.toolName + it("tool_result with unknown tool_use_id has no meta.toolName", () => { + const input = chunk([ + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "unknown-id", + content: "output", + }, + ], + }, + }, + ]); + const messages = parseAgentOutput(input); + const toolResult = messages.find((m) => m.type === "tool_result"); + expect(toolResult).toBeDefined(); + expect(toolResult!.meta?.toolName).toBeUndefined(); + }); + + // 4. tool_result with is_error: true produces type: "error" and meta.isError: true + it("tool_result with is_error: true produces error message", () => { + const input = chunk([ + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tu1", + is_error: true, + content: "something went wrong", + }, + ], + }, + }, + ]); + const messages = parseAgentOutput(input); + const errorMsg = messages.find((m) => m.content === "something went wrong"); + expect(errorMsg).toBeDefined(); + expect(errorMsg!.type).toBe("error"); + expect(errorMsg!.meta?.isError).toBe(true); + }); + + // 5. tool_result from a Task tool_use gets correct meta.toolName and meta.toolInput + it("tool_result from Task tool_use has correct meta", () => { + const taskInput = { + subagent_type: "Explore", + description: "find files", + prompt: "search for *.ts", + }; + const input = chunk([ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "tu2", + name: "Task", + input: taskInput, + }, + ], + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tu2", + content: "found 10 files", + }, + ], + }, + }, + ]); + const messages = parseAgentOutput(input); + const toolResult = messages.find((m) => m.type === "tool_result"); + expect(toolResult).toBeDefined(); + expect(toolResult!.meta?.toolName).toBe("Task"); + expect(toolResult!.meta?.toolInput).toEqual(taskInput); + }); + + // 6. Unknown top-level event type produces a system message + it("unknown top-level event type produces system message", () => { + const input = chunk([{ type: "future_event_type", data: {} }]); + const messages = parseAgentOutput(input); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("system"); + expect(messages[0].content).toBe("[unknown event: future_event_type]"); + }); + + // 7. Unknown assistant content block type produces a system message + it("unknown assistant content block type produces system message", () => { + const input = chunk([ + { + type: "assistant", + message: { + content: [{ type: "image", data: "base64..." }], + }, + }, + ]); + const messages = parseAgentOutput(input); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("system"); + expect(messages[0].content).toBe("[unsupported content block: image]"); + }); + + // 8. Previously passing behavior unchanged + describe("previously passing behavior", () => { + it("system event with session_id produces system message", () => { + const input = chunk([{ type: "system", session_id: "sess-123" }]); + const messages = parseAgentOutput(input); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("system"); + expect(messages[0].content).toBe("Session started: sess-123"); + }); + + it("assistant text block produces text message", () => { + const input = chunk([ + { + type: "assistant", + message: { + content: [{ type: "text", text: "Hello, world!" }], + }, + }, + ]); + const messages = parseAgentOutput(input); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("text"); + expect(messages[0].content).toBe("Hello, world!"); + }); + + it("assistant tool_use block produces tool_call message with meta.toolName", () => { + const input = chunk([ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "tu1", + name: "Bash", + input: { command: "ls -la", description: "list files" }, + }, + ], + }, + }, + ]); + const messages = parseAgentOutput(input); + const toolCall = messages.find((m) => m.type === "tool_call"); + expect(toolCall).toBeDefined(); + expect(toolCall!.meta?.toolName).toBe("Bash"); + }); + + it("result event with is_error: false produces session_end", () => { + const input = chunk([ + { + type: "result", + is_error: false, + total_cost_usd: 0.01, + duration_ms: 5000, + }, + ]); + const messages = parseAgentOutput(input); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("session_end"); + expect(messages[0].content).toBe("Session completed"); + }); + + it("result event with is_error: true produces session_end with meta.isError", () => { + const input = chunk([ + { + type: "result", + is_error: true, + total_cost_usd: 0.01, + duration_ms: 5000, + }, + ]); + const messages = parseAgentOutput(input); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("session_end"); + expect(messages[0].meta?.isError).toBe(true); + }); + + it("non-JSON line produces error message with raw line as content", () => { + const rawLine = "This is not JSON at all"; + const messages = parseAgentOutput(rawLine); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("error"); + expect(messages[0].content).toBe(rawLine); + }); + }); +}); diff --git a/apps/web/src/lib/parse-agent-output.ts b/apps/web/src/lib/parse-agent-output.ts index ca6065c..7f950bb 100644 --- a/apps/web/src/lib/parse-agent-output.ts +++ b/apps/web/src/lib/parse-agent-output.ts @@ -10,6 +10,7 @@ export interface ParsedMessage { timestamp?: Date; meta?: { toolName?: string; + toolInput?: unknown; isError?: boolean; cost?: number; duration?: number; @@ -80,6 +81,7 @@ export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessag : raw.map((c) => ({ content: c.content, timestamp: new Date(c.createdAt) })); const parsedMessages: ParsedMessage[] = []; + const toolUseRegistry = new Map(); for (const chunk of chunks) { const lines = chunk.content.split("\n").filter(Boolean); @@ -113,7 +115,14 @@ export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessag type: "tool_call", content: formatToolCall(block), timestamp: chunk.timestamp, - meta: { toolName: block.name }, + meta: { toolName: block.name, toolInput: block.input }, + }); + toolUseRegistry.set(block.id, { name: block.name, input: block.input }); + } else { + parsedMessages.push({ + type: "system", + content: `[unsupported content block: ${block.type}]`, + timestamp: chunk.timestamp, }); } } @@ -149,10 +158,20 @@ export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessag output.length > 1000 ? output.substring(0, 1000) + "\n... (truncated)" : output; + const isError = block.is_error === true; + const originatingCall = block.tool_use_id + ? toolUseRegistry.get(block.tool_use_id) + : undefined; parsedMessages.push({ - type: "tool_result", + type: isError ? "error" : "tool_result", content: displayOutput, timestamp: chunk.timestamp, + meta: { + ...(isError ? { isError: true } : {}), + ...(originatingCall + ? { toolName: originatingCall.name, toolInput: originatingCall.input } + : {}), + }, }); } } @@ -180,6 +199,12 @@ export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessag duration: event.duration_ms, }, }); + } else { + parsedMessages.push({ + type: "system", + content: `[unknown event: ${event.type ?? "(no type)"}]`, + timestamp: chunk.timestamp, + }); } } catch { // Not JSON, display as-is