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 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 16:02:50 +01:00
parent 2bef0fa682
commit ee6b0da976
2 changed files with 291 additions and 2 deletions

View File

@@ -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);
});
});
});

View File

@@ -10,6 +10,7 @@ export interface ParsedMessage {
timestamp?: Date; timestamp?: Date;
meta?: { meta?: {
toolName?: string; toolName?: string;
toolInput?: unknown;
isError?: boolean; isError?: boolean;
cost?: number; cost?: number;
duration?: 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) })); : raw.map((c) => ({ content: c.content, timestamp: new Date(c.createdAt) }));
const parsedMessages: ParsedMessage[] = []; const parsedMessages: ParsedMessage[] = [];
const toolUseRegistry = new Map<string, { name: string; input: unknown }>();
for (const chunk of chunks) { for (const chunk of chunks) {
const lines = chunk.content.split("\n").filter(Boolean); const lines = chunk.content.split("\n").filter(Boolean);
@@ -113,7 +115,14 @@ export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessag
type: "tool_call", type: "tool_call",
content: formatToolCall(block), content: formatToolCall(block),
timestamp: chunk.timestamp, 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.length > 1000
? output.substring(0, 1000) + "\n... (truncated)" ? output.substring(0, 1000) + "\n... (truncated)"
: output; : output;
const isError = block.is_error === true;
const originatingCall = block.tool_use_id
? toolUseRegistry.get(block.tool_use_id)
: undefined;
parsedMessages.push({ parsedMessages.push({
type: "tool_result", type: isError ? "error" : "tool_result",
content: displayOutput, content: displayOutput,
timestamp: chunk.timestamp, 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, duration: event.duration_ms,
}, },
}); });
} else {
parsedMessages.push({
type: "system",
content: `[unknown event: ${event.type ?? "(no type)"}]`,
timestamp: chunk.timestamp,
});
} }
} catch { } catch {
// Not JSON, display as-is // Not JSON, display as-is