Test fixture updates: - Add toolUseId fields (toolu_read1, toolu_edit1) to tool_use blocks - Add parentToolUseID-linked progress events for read and edit tools - Add orphaned SessionStart progress event (no parent) - Update tool_result references to match new toolUseId values - Add bash_progress and mcp_progress subtypes for subtype derivation session-parser tests (7 new): - toolUseId extraction from tool_use blocks with and without id field - parentToolUseId and progressSubtype extraction from hook_progress - Subtype derivation for bash_progress, mcp_progress, agent_progress - Fallback to "hook" for unknown data types - Undefined parentToolUseId when field is absent progress-grouper tests (7 new): - Partition parented progress into toolProgress map - Remove parented progress from filtered messages array - Keep orphaned progress (no parentToolUseId) in main stream - Keep progress with invalid parentToolUseId (no matching tool_call) - Empty input handling - Sort each group by rawIndex - Multiple tool_call parents tracked independently agent-progress-parser tests (full suite): - Parse user text events with prompt/agentId metadata extraction - Parse tool_use blocks into AgentToolCall events - Parse tool_result blocks with content extraction - Parse text content as text_response with line counting - Handle multiple content blocks in single turn - Post-pass tool_result→tool_call linking (sourceTool, language) - Empty input and malformed JSON → raw_content fallback - stripLineNumbers for cat-n prefixed output - summarizeToolCall for Read, Grep, Glob, Bash, Task, WarpGrep, etc. ProgressBadge component tests: - Collapsed state shows pill counts, hides content - Expanded state shows all event content via markdown - Subtype counting accuracy - Agent-only events route to AgentProgressView AgentProgressView component tests: - Prompt banner rendering with truncation - Agent ID and turn count display - Summary rows with timestamps and tool names - Click-to-expand drill-down content html-exporter tests (8 new): - Collapsible rendering for thinking, tool_call, tool_result - Toggle button and JavaScript inclusion - Non-collapsible messages lack collapse attributes - Diff content detection and highlighting - Progress badge rendering with toolProgress data filters tests (2 new): - hook_progress included/excluded by category toggle Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
512 lines
16 KiB
TypeScript
512 lines
16 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
parseAgentEvents,
|
|
summarizeToolCall,
|
|
stripLineNumbers,
|
|
} from "../../src/client/lib/agent-progress-parser";
|
|
import type { ParsedMessage } from "../../src/shared/types";
|
|
|
|
/** Build a fake ParsedMessage whose content is a JSON agent_progress payload */
|
|
function makeAgentEvent(
|
|
dataOverrides: Record<string, unknown> = {},
|
|
msgOverrides: Partial<ParsedMessage> = {}
|
|
): ParsedMessage {
|
|
const data = {
|
|
message: {
|
|
type: "assistant",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "tool_use",
|
|
id: "toolu_abc123",
|
|
name: "Read",
|
|
input: { file_path: "/src/foo.ts" },
|
|
},
|
|
],
|
|
},
|
|
timestamp: "2026-01-30T16:22:21.000Z",
|
|
},
|
|
normalizedMessages: [],
|
|
type: "agent_progress",
|
|
prompt: "Explore the codebase",
|
|
agentId: "a6945d4",
|
|
...dataOverrides,
|
|
};
|
|
|
|
return {
|
|
uuid: crypto.randomUUID(),
|
|
category: "hook_progress",
|
|
content: JSON.stringify(data),
|
|
rawIndex: 0,
|
|
timestamp: "2026-01-30T16:22:21.000Z",
|
|
progressSubtype: "agent",
|
|
...msgOverrides,
|
|
};
|
|
}
|
|
|
|
describe("agent-progress-parser", () => {
|
|
describe("parseAgentEvents", () => {
|
|
it("parses a user text event and extracts the prompt", () => {
|
|
const event = makeAgentEvent({
|
|
message: {
|
|
type: "user",
|
|
message: {
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Find the auth implementation" },
|
|
],
|
|
},
|
|
timestamp: "2026-01-30T16:22:00.000Z",
|
|
},
|
|
});
|
|
|
|
const result = parseAgentEvents([event]);
|
|
expect(result.prompt).toBe("Explore the codebase");
|
|
expect(result.agentId).toBe("a6945d4");
|
|
expect(result.events.length).toBe(1);
|
|
expect(result.events[0].kind).toBe("user_text");
|
|
if (result.events[0].kind === "user_text") {
|
|
expect(result.events[0].text).toBe("Find the auth implementation");
|
|
}
|
|
});
|
|
|
|
it("parses assistant turn with tool_use blocks and extracts tool info", () => {
|
|
const event = makeAgentEvent(); // default has Read tool_use
|
|
|
|
const result = parseAgentEvents([event]);
|
|
expect(result.events.length).toBe(1);
|
|
expect(result.events[0].kind).toBe("tool_call");
|
|
if (result.events[0].kind === "tool_call") {
|
|
expect(result.events[0].toolName).toBe("Read");
|
|
expect(result.events[0].toolUseId).toBe("toolu_abc123");
|
|
expect(result.events[0].input).toEqual({ file_path: "/src/foo.ts" });
|
|
}
|
|
});
|
|
|
|
it("parses user turn with tool_result and extracts result content", () => {
|
|
const event = makeAgentEvent({
|
|
message: {
|
|
type: "user",
|
|
message: {
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "tool_result",
|
|
tool_use_id: "toolu_abc123",
|
|
content: "File contents here...",
|
|
},
|
|
],
|
|
},
|
|
timestamp: "2026-01-30T16:22:22.000Z",
|
|
},
|
|
});
|
|
|
|
const result = parseAgentEvents([event]);
|
|
expect(result.events[0].kind).toBe("tool_result");
|
|
if (result.events[0].kind === "tool_result") {
|
|
expect(result.events[0].toolUseId).toBe("toolu_abc123");
|
|
expect(result.events[0].content).toBe("File contents here...");
|
|
}
|
|
});
|
|
|
|
it("parses assistant turn with text content as text_response", () => {
|
|
const event = makeAgentEvent({
|
|
message: {
|
|
type: "assistant",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "text", text: "Here is my analysis of the codebase.\nLine 2\nLine 3\nLine 4" },
|
|
],
|
|
},
|
|
timestamp: "2026-01-30T16:22:30.000Z",
|
|
},
|
|
});
|
|
|
|
const result = parseAgentEvents([event]);
|
|
expect(result.events[0].kind).toBe("text_response");
|
|
if (result.events[0].kind === "text_response") {
|
|
expect(result.events[0].text).toContain("Here is my analysis");
|
|
expect(result.events[0].lineCount).toBe(4);
|
|
}
|
|
});
|
|
|
|
it("extracts agentId and prompt from events", () => {
|
|
const events = [makeAgentEvent()];
|
|
const result = parseAgentEvents(events);
|
|
expect(result.agentId).toBe("a6945d4");
|
|
expect(result.prompt).toBe("Explore the codebase");
|
|
});
|
|
|
|
it("calculates time range from first/last event timestamps", () => {
|
|
const events = [
|
|
makeAgentEvent(
|
|
{
|
|
message: {
|
|
type: "user",
|
|
message: { role: "user", content: [{ type: "text", text: "Go" }] },
|
|
timestamp: "2026-01-30T16:22:00.000Z",
|
|
},
|
|
},
|
|
{ timestamp: "2026-01-30T16:22:00.000Z" }
|
|
),
|
|
makeAgentEvent(
|
|
{
|
|
message: {
|
|
type: "assistant",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "tool_use", id: "t1", name: "Read", input: { file_path: "a.ts" } },
|
|
],
|
|
},
|
|
timestamp: "2026-01-30T16:22:30.000Z",
|
|
},
|
|
},
|
|
{ timestamp: "2026-01-30T16:22:30.000Z" }
|
|
),
|
|
];
|
|
|
|
const result = parseAgentEvents(events);
|
|
expect(result.firstTimestamp).toBe("2026-01-30T16:22:00.000Z");
|
|
expect(result.lastTimestamp).toBe("2026-01-30T16:22:30.000Z");
|
|
});
|
|
|
|
it("counts turns correctly (tool_call events = turns)", () => {
|
|
const events = [
|
|
// User text
|
|
makeAgentEvent({
|
|
message: {
|
|
type: "user",
|
|
message: { role: "user", content: [{ type: "text", text: "Go" }] },
|
|
timestamp: "2026-01-30T16:22:00.000Z",
|
|
},
|
|
}),
|
|
// Tool call 1
|
|
makeAgentEvent({
|
|
message: {
|
|
type: "assistant",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "tool_use", id: "t1", name: "Read", input: { file_path: "a.ts" } },
|
|
],
|
|
},
|
|
timestamp: "2026-01-30T16:22:01.000Z",
|
|
},
|
|
}),
|
|
// Tool result 1
|
|
makeAgentEvent({
|
|
message: {
|
|
type: "user",
|
|
message: {
|
|
role: "user",
|
|
content: [{ type: "tool_result", tool_use_id: "t1", content: "..." }],
|
|
},
|
|
timestamp: "2026-01-30T16:22:02.000Z",
|
|
},
|
|
}),
|
|
// Tool call 2
|
|
makeAgentEvent({
|
|
message: {
|
|
type: "assistant",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "tool_use", id: "t2", name: "Grep", input: { pattern: "foo" } },
|
|
],
|
|
},
|
|
timestamp: "2026-01-30T16:22:03.000Z",
|
|
},
|
|
}),
|
|
// Tool result 2
|
|
makeAgentEvent({
|
|
message: {
|
|
type: "user",
|
|
message: {
|
|
role: "user",
|
|
content: [{ type: "tool_result", tool_use_id: "t2", content: "..." }],
|
|
},
|
|
timestamp: "2026-01-30T16:22:04.000Z",
|
|
},
|
|
}),
|
|
// Final text
|
|
makeAgentEvent({
|
|
message: {
|
|
type: "assistant",
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Done" }],
|
|
},
|
|
timestamp: "2026-01-30T16:22:05.000Z",
|
|
},
|
|
}),
|
|
];
|
|
|
|
const result = parseAgentEvents(events);
|
|
expect(result.turnCount).toBe(2); // 2 tool_call events
|
|
});
|
|
|
|
it("handles malformed JSON content gracefully", () => {
|
|
const event: ParsedMessage = {
|
|
uuid: "bad-json",
|
|
category: "hook_progress",
|
|
content: "this is not valid json {{{",
|
|
rawIndex: 0,
|
|
timestamp: "2026-01-30T16:22:00.000Z",
|
|
progressSubtype: "agent",
|
|
};
|
|
|
|
const result = parseAgentEvents([event]);
|
|
expect(result.events.length).toBe(1);
|
|
expect(result.events[0].kind).toBe("raw_content");
|
|
if (result.events[0].kind === "raw_content") {
|
|
expect(result.events[0].content).toBe("this is not valid json {{{");
|
|
}
|
|
});
|
|
|
|
it("handles empty events array", () => {
|
|
const result = parseAgentEvents([]);
|
|
expect(result.events).toEqual([]);
|
|
expect(result.agentId).toBeUndefined();
|
|
expect(result.prompt).toBeUndefined();
|
|
expect(result.turnCount).toBe(0);
|
|
});
|
|
|
|
it("handles tool_result with array content blocks", () => {
|
|
const event = makeAgentEvent({
|
|
message: {
|
|
type: "user",
|
|
message: {
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "tool_result",
|
|
tool_use_id: "toolu_xyz",
|
|
content: [
|
|
{ type: "text", text: "Part 1\n" },
|
|
{ type: "text", text: "Part 2\n" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
timestamp: "2026-01-30T16:22:22.000Z",
|
|
},
|
|
});
|
|
|
|
const result = parseAgentEvents([event]);
|
|
expect(result.events[0].kind).toBe("tool_result");
|
|
if (result.events[0].kind === "tool_result") {
|
|
expect(result.events[0].content).toBe("Part 1\nPart 2\n");
|
|
}
|
|
});
|
|
|
|
it("handles multiple tool_use blocks in a single assistant message", () => {
|
|
const event = makeAgentEvent({
|
|
message: {
|
|
type: "assistant",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "tool_use", id: "t1", name: "Read", input: { file_path: "a.ts" } },
|
|
{ type: "tool_use", id: "t2", name: "Read", input: { file_path: "b.ts" } },
|
|
],
|
|
},
|
|
timestamp: "2026-01-30T16:22:01.000Z",
|
|
},
|
|
});
|
|
|
|
const result = parseAgentEvents([event]);
|
|
const toolCalls = result.events.filter((e) => e.kind === "tool_call");
|
|
expect(toolCalls.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("summarizeToolCall", () => {
|
|
it("summarizes Read tool with file_path", () => {
|
|
const summary = summarizeToolCall("Read", { file_path: "/src/components/App.tsx" });
|
|
expect(summary).toBe("src/components/App.tsx");
|
|
});
|
|
|
|
it("summarizes Grep tool with pattern + path", () => {
|
|
const summary = summarizeToolCall("Grep", {
|
|
pattern: "useChat",
|
|
path: "/src/hooks/",
|
|
});
|
|
expect(summary).toContain("useChat");
|
|
expect(summary).toContain("src/hooks/");
|
|
});
|
|
|
|
it("summarizes Bash tool with truncated command", () => {
|
|
const summary = summarizeToolCall("Bash", {
|
|
command: "npm run test -- --watch --verbose --coverage --reporter=json",
|
|
});
|
|
expect(summary.length).toBeLessThanOrEqual(80);
|
|
expect(summary).toContain("npm run test");
|
|
});
|
|
|
|
it("summarizes Task tool with description", () => {
|
|
const summary = summarizeToolCall("Task", {
|
|
description: "Find all auth handlers",
|
|
subagent_type: "Explore",
|
|
});
|
|
expect(summary).toContain("Find all auth handlers");
|
|
});
|
|
|
|
it("summarizes Glob tool with pattern", () => {
|
|
const summary = summarizeToolCall("Glob", {
|
|
pattern: "**/*.test.ts",
|
|
path: "/src/",
|
|
});
|
|
expect(summary).toContain("**/*.test.ts");
|
|
});
|
|
|
|
it("summarizes Edit tool with file_path", () => {
|
|
const summary = summarizeToolCall("Edit", {
|
|
file_path: "/src/lib/utils.ts",
|
|
old_string: "foo",
|
|
new_string: "bar",
|
|
});
|
|
expect(summary).toContain("src/lib/utils.ts");
|
|
});
|
|
|
|
it("summarizes Write tool with file_path", () => {
|
|
const summary = summarizeToolCall("Write", {
|
|
file_path: "/src/new-file.ts",
|
|
content: "export const x = 1;",
|
|
});
|
|
expect(summary).toContain("src/new-file.ts");
|
|
});
|
|
|
|
it("summarizes unknown tool with generic label", () => {
|
|
const summary = summarizeToolCall("CustomTool", { foo: "bar" });
|
|
expect(summary).toBe("CustomTool");
|
|
});
|
|
|
|
it("summarizes mcp__morph-mcp__warpgrep_codebase_search with search_string", () => {
|
|
const summary = summarizeToolCall("mcp__morph-mcp__warpgrep_codebase_search", {
|
|
search_string: "Find authentication middleware",
|
|
repo_path: "/data/projects/app",
|
|
});
|
|
expect(summary).toContain("Find authentication middleware");
|
|
});
|
|
|
|
it("summarizes mcp__morph-mcp__edit_file with path", () => {
|
|
const summary = summarizeToolCall("mcp__morph-mcp__edit_file", {
|
|
path: "/src/lib/utils.ts",
|
|
code_edit: "// changes",
|
|
instruction: "Fix bug",
|
|
});
|
|
expect(summary).toContain("src/lib/utils.ts");
|
|
});
|
|
|
|
it("strips leading slash from file paths", () => {
|
|
const summary = summarizeToolCall("Read", { file_path: "/src/foo.ts" });
|
|
expect(summary).toBe("src/foo.ts");
|
|
});
|
|
});
|
|
|
|
describe("stripLineNumbers", () => {
|
|
it("strips cat-n style line numbers with arrow prefix", () => {
|
|
const input = " 1\u2192import React from 'react';\n 2\u2192\n 3\u2192export default function App() {";
|
|
const result = stripLineNumbers(input);
|
|
expect(result).toBe("import React from 'react';\n\nexport default function App() {");
|
|
});
|
|
|
|
it("returns text unchanged if no line numbers detected", () => {
|
|
const input = "just plain text\nno line numbers here";
|
|
expect(stripLineNumbers(input)).toBe(input);
|
|
});
|
|
|
|
it("returns text unchanged for short single-line content", () => {
|
|
const input = "single line";
|
|
expect(stripLineNumbers(input)).toBe(input);
|
|
});
|
|
|
|
it("handles double-digit line numbers", () => {
|
|
const lines = Array.from({ length: 15 }, (_, i) =>
|
|
` ${i + 1}\u2192line ${i + 1}`
|
|
).join("\n");
|
|
const result = stripLineNumbers(lines);
|
|
expect(result).toContain("line 1");
|
|
expect(result).not.toMatch(/^\s*\d+\u2192/m);
|
|
});
|
|
});
|
|
|
|
describe("tool_result language linking", () => {
|
|
it("links tool_result to preceding Read call and sets language", () => {
|
|
const events = [
|
|
makeAgentEvent({
|
|
message: {
|
|
type: "assistant",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "tool_use", id: "t1", name: "Read", input: { file_path: "/src/app.tsx" } },
|
|
],
|
|
},
|
|
timestamp: "2026-01-30T16:22:01.000Z",
|
|
},
|
|
}),
|
|
makeAgentEvent({
|
|
message: {
|
|
type: "user",
|
|
message: {
|
|
role: "user",
|
|
content: [
|
|
{ type: "tool_result", tool_use_id: "t1", content: "import React..." },
|
|
],
|
|
},
|
|
timestamp: "2026-01-30T16:22:02.000Z",
|
|
},
|
|
}),
|
|
];
|
|
|
|
const result = parseAgentEvents(events);
|
|
const toolResult = result.events.find((e) => e.kind === "tool_result");
|
|
expect(toolResult).toBeDefined();
|
|
if (toolResult?.kind === "tool_result") {
|
|
expect(toolResult.language).toBe("tsx");
|
|
expect(toolResult.sourceTool).toBe("Read");
|
|
}
|
|
});
|
|
|
|
it("sets no language for Grep results", () => {
|
|
const events = [
|
|
makeAgentEvent({
|
|
message: {
|
|
type: "assistant",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "tool_use", id: "t1", name: "Grep", input: { pattern: "foo" } },
|
|
],
|
|
},
|
|
timestamp: "2026-01-30T16:22:01.000Z",
|
|
},
|
|
}),
|
|
makeAgentEvent({
|
|
message: {
|
|
type: "user",
|
|
message: {
|
|
role: "user",
|
|
content: [
|
|
{ type: "tool_result", tool_use_id: "t1", content: "src/a.ts:10:foo" },
|
|
],
|
|
},
|
|
timestamp: "2026-01-30T16:22:02.000Z",
|
|
},
|
|
}),
|
|
];
|
|
|
|
const result = parseAgentEvents(events);
|
|
const toolResult = result.events.find((e) => e.kind === "tool_result");
|
|
if (toolResult?.kind === "tool_result") {
|
|
expect(toolResult.language).toBeUndefined();
|
|
expect(toolResult.sourceTool).toBe("Grep");
|
|
}
|
|
});
|
|
});
|
|
});
|