)}
@@ -203,13 +235,34 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
)}
{message.type === 'tool_result' && (
-
-
- Result
-
-
- {message.content}
-
+
setExpandedResults(prev => {
+ const next = new Set(prev);
+ if (next.has(index)) next.delete(index); else next.add(index);
+ return next;
+ })}
+ >
+ {expandedResults.has(index) ? (
+ <>
+
+
+ Result
+
+
+ {message.content}
+
+ >
+ ) : (
+ <>
+
+
+ {message.meta?.toolName === "Task"
+ ? `${(message.meta.toolInput as any)?.subagent_type ?? "Subagent"} result`
+ : message.content.substring(0, 80)}
+
+ >
+ )}
)}
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