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:
264
apps/web/src/lib/parse-agent-output.test.ts
Normal file
264
apps/web/src/lib/parse-agent-output.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user