- 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>
265 lines
7.8 KiB
TypeScript
265 lines
7.8 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|