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;
|
||||
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<string, { name: string; input: unknown }>();
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user