diff --git a/src/client/components/AgentProgressView.test.tsx b/src/client/components/AgentProgressView.test.tsx
new file mode 100644
index 0000000..91d67ac
--- /dev/null
+++ b/src/client/components/AgentProgressView.test.tsx
@@ -0,0 +1,254 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi } from "vitest";
+import React from "react";
+import { render, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { AgentProgressView } from "./AgentProgressView";
+import type { ParsedMessage } from "../lib/types";
+
+// Mock markdown renderer to avoid pulling in marked/hljs in jsdom
+vi.mock("../lib/markdown", () => ({
+ renderMarkdown: (text: string) => `
${text}
`,
+}));
+
+/** Build a ParsedMessage whose content is a JSON agent_progress payload */
+function makeAgentProgressEvent(
+ messageOverrides: Record = {},
+ dataOverrides: Record = {},
+ msgOverrides: Partial = {}
+): ParsedMessage {
+ const data = {
+ message: {
+ type: "assistant",
+ message: {
+ role: "assistant",
+ content: [
+ {
+ type: "tool_use",
+ id: "toolu_abc",
+ name: "Read",
+ input: { file_path: "/src/foo.ts" },
+ },
+ ],
+ },
+ timestamp: "2026-01-30T16:22:21.000Z",
+ ...messageOverrides,
+ },
+ 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("AgentProgressView", () => {
+ it("renders prompt banner with truncated prompt text", () => {
+ const events = [makeAgentProgressEvent()];
+ const { getByTestId } = render();
+
+ const prompt = getByTestId("agent-prompt");
+ expect(prompt.textContent).toContain("Explore the codebase");
+ });
+
+ it("renders agent ID and turn count in header", () => {
+ const events = [makeAgentProgressEvent()];
+ const { getByTestId } = render();
+
+ const view = getByTestId("agent-progress-view");
+ expect(view.textContent).toContain("a6945d4");
+ expect(view.textContent).toContain("1 turn");
+ });
+
+ it("renders summary rows with timestamps and tool names", () => {
+ const events = [makeAgentProgressEvent()];
+ const { getAllByTestId } = render();
+
+ const rows = getAllByTestId("agent-event-row");
+ expect(rows.length).toBe(1);
+ // Should contain tool name "Read" and the file path
+ expect(rows[0].textContent).toContain("Read");
+ expect(rows[0].textContent).toContain("src/foo.ts");
+ });
+
+ it("summary row for Read shows file path", () => {
+ const events = [
+ makeAgentProgressEvent({
+ message: {
+ role: "assistant",
+ content: [
+ {
+ type: "tool_use",
+ id: "t1",
+ name: "Read",
+ input: { file_path: "/src/components/App.tsx" },
+ },
+ ],
+ },
+ }),
+ ];
+ const { getAllByTestId } = render();
+ const rows = getAllByTestId("agent-event-row");
+ expect(rows[0].textContent).toContain("src/components/App.tsx");
+ });
+
+ it("summary row for text response shows line count", () => {
+ const events = [
+ makeAgentProgressEvent({
+ type: "assistant",
+ message: {
+ role: "assistant",
+ content: [
+ { type: "text", text: "Line 1\nLine 2\nLine 3" },
+ ],
+ },
+ }),
+ ];
+ const { getAllByTestId } = render();
+ const rows = getAllByTestId("agent-event-row");
+ expect(rows[0].textContent).toContain("3 lines");
+ });
+
+ it("clicking a summary row expands drill-down panel", () => {
+ const events = [makeAgentProgressEvent()];
+ const { getAllByTestId, queryByTestId } = render(
+
+ );
+
+ // Initially no drill-down
+ expect(queryByTestId("agent-drilldown")).toBeNull();
+
+ // Click to expand
+ fireEvent.click(getAllByTestId("agent-event-row")[0]);
+ expect(queryByTestId("agent-drilldown")).toBeInTheDocument();
+ });
+
+ it("drill-down shows pretty-printed tool input JSON", () => {
+ const events = [makeAgentProgressEvent()];
+ const { getAllByTestId, getByTestId } = render(
+
+ );
+
+ fireEvent.click(getAllByTestId("agent-event-row")[0]);
+ const drilldown = getByTestId("agent-drilldown");
+ expect(drilldown.textContent).toContain("file_path");
+ expect(drilldown.textContent).toContain("/src/foo.ts");
+ });
+
+ it("drill-down shows full tool result content", () => {
+ const events = [
+ makeAgentProgressEvent({
+ type: "user",
+ message: {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: "toolu_abc",
+ content: "Full file contents here",
+ },
+ ],
+ },
+ }),
+ ];
+ const { getAllByTestId, getByTestId } = render(
+
+ );
+
+ fireEvent.click(getAllByTestId("agent-event-row")[0]);
+ const drilldown = getByTestId("agent-drilldown");
+ expect(drilldown.textContent).toContain("Full file contents here");
+ });
+
+ it("clicking expanded row collapses it", () => {
+ const events = [makeAgentProgressEvent()];
+ const { getAllByTestId, queryByTestId } = render(
+
+ );
+
+ // Expand
+ fireEvent.click(getAllByTestId("agent-event-row")[0]);
+ expect(queryByTestId("agent-drilldown")).toBeInTheDocument();
+
+ // Collapse
+ fireEvent.click(getAllByTestId("agent-event-row")[0]);
+ expect(queryByTestId("agent-drilldown")).toBeNull();
+ });
+
+ it("only one drill-down open at a time (accordion behavior)", () => {
+ const events = [
+ makeAgentProgressEvent(
+ {
+ message: {
+ role: "assistant",
+ content: [
+ { type: "tool_use", id: "t1", name: "Read", input: { file_path: "/a.ts" } },
+ ],
+ },
+ },
+ {},
+ { uuid: "ev-1" }
+ ),
+ makeAgentProgressEvent(
+ {
+ message: {
+ role: "assistant",
+ content: [
+ { type: "tool_use", id: "t2", name: "Grep", input: { pattern: "foo" } },
+ ],
+ },
+ },
+ {},
+ { uuid: "ev-2" }
+ ),
+ ];
+ const { getAllByTestId } = render(
+
+ );
+
+ const rows = getAllByTestId("agent-event-row");
+
+ // Expand first
+ fireEvent.click(rows[0]);
+ let drilldowns = getAllByTestId("agent-drilldown");
+ expect(drilldowns.length).toBe(1);
+
+ // Click second - should close first and open second
+ fireEvent.click(rows[1]);
+ drilldowns = getAllByTestId("agent-drilldown");
+ expect(drilldowns.length).toBe(1);
+ // Second drill-down should show Grep pattern
+ expect(drilldowns[0].textContent).toContain("foo");
+ });
+
+ it("handles events with missing timestamps gracefully", () => {
+ const events = [
+ makeAgentProgressEvent(
+ {
+ message: {
+ role: "assistant",
+ content: [
+ { type: "tool_use", id: "t1", name: "Read", input: { file_path: "/a.ts" } },
+ ],
+ },
+ timestamp: undefined,
+ },
+ {},
+ { timestamp: undefined }
+ ),
+ ];
+ // Should render without crashing
+ const { getByTestId } = render();
+ expect(getByTestId("agent-progress-view")).toBeInTheDocument();
+ });
+});
diff --git a/src/client/components/ProgressBadge.test.tsx b/src/client/components/ProgressBadge.test.tsx
new file mode 100644
index 0000000..a33d971
--- /dev/null
+++ b/src/client/components/ProgressBadge.test.tsx
@@ -0,0 +1,240 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi } from "vitest";
+import React from "react";
+import { render, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { ProgressBadge } from "./ProgressBadge";
+import type { ParsedMessage } from "../lib/types";
+
+// Mock the markdown module to avoid pulling in marked/hljs in jsdom
+vi.mock("../lib/markdown", () => ({
+ renderMarkdown: (text: string) => `${text}
`,
+}));
+
+function makeEvent(
+ overrides: Partial = {}
+): ParsedMessage {
+ return {
+ uuid: crypto.randomUUID(),
+ category: "hook_progress",
+ content: "Running pre-commit hook",
+ rawIndex: 0,
+ timestamp: "2025-01-15T10:30:00Z",
+ progressSubtype: "hook",
+ ...overrides,
+ };
+}
+
+describe("ProgressBadge", () => {
+ describe("collapsed state", () => {
+ it("shows pill counts but hides event content", () => {
+ const events = [
+ makeEvent({ progressSubtype: "hook" }),
+ makeEvent({ progressSubtype: "hook" }),
+ makeEvent({ progressSubtype: "bash" }),
+ ];
+ const { container, queryByText } = render(
+
+ );
+
+ // Pill counts visible
+ expect(container.textContent).toContain("hook: 2");
+ expect(container.textContent).toContain("bash: 1");
+
+ // Event content should NOT be visible when collapsed
+ expect(queryByText("Running pre-commit hook")).toBeNull();
+ });
+ });
+
+ describe("expanded state", () => {
+ it("shows all event content when clicked", () => {
+ const events = [
+ makeEvent({ content: "First event" }),
+ makeEvent({ content: "Second event" }),
+ ];
+ const { container } = render();
+
+ // Click to expand
+ fireEvent.click(container.querySelector("button")!);
+
+ // Content should be visible (rendered as markdown via mock)
+ expect(container.innerHTML).toContain("First event");
+ expect(container.innerHTML).toContain("Second event");
+ });
+
+ it("renders content through markdown into prose-message-progress container", () => {
+ const events = [
+ makeEvent({ content: "**bold text**" }),
+ ];
+ const { container } = render();
+
+ // Expand
+ fireEvent.click(container.querySelector("button")!);
+
+ // Our mock wraps in , so the prose container should have rendered HTML
+ const proseEl = container.querySelector(".prose-message-progress");
+ expect(proseEl).toBeInTheDocument();
+ // Content is from local JSONL session files owned by the user,
+ // same trust model as MessageBubble's markdown rendering
+ expect(proseEl?.innerHTML).toContain("
**bold text**
");
+ });
+
+ it("does not have max-h-48 or overflow-y-auto on expanded container", () => {
+ const events = [makeEvent()];
+ const { container } = render();
+
+ fireEvent.click(container.querySelector("button")!);
+
+ const expandedDiv = container.querySelector("[data-testid='progress-expanded']");
+ expect(expandedDiv).toBeInTheDocument();
+ expect(expandedDiv?.className).not.toContain("max-h-48");
+ expect(expandedDiv?.className).not.toContain("overflow-y-auto");
+ });
+
+ it("does not have truncate class on content", () => {
+ const events = [makeEvent()];
+ const { container } = render();
+
+ fireEvent.click(container.querySelector("button")!);
+
+ // Generic (non-agent) expanded view should not truncate
+ const proseElements = container.querySelectorAll(".prose-message-progress");
+ for (const el of proseElements) {
+ expect(el.className).not.toContain("truncate");
+ }
+ });
+
+ it("displays timestamps and subtype badges per event", () => {
+ const events = [
+ makeEvent({
+ timestamp: "2025-01-15T10:30:00Z",
+ progressSubtype: "bash",
+ content: "npm test",
+ }),
+ ];
+ const { container } = render();
+
+ fireEvent.click(container.querySelector("button")!);
+
+ // Subtype badge visible
+ expect(container.textContent).toContain("bash");
+ // Timestamp visible (formatted by toLocaleTimeString)
+ const expandedDiv = container.querySelector("[data-testid='progress-expanded']");
+ expect(expandedDiv).toBeInTheDocument();
+ // The timestamp text should exist somewhere in the expanded area
+ expect(expandedDiv?.textContent).toMatch(/\d{1,2}:\d{2}:\d{2}/);
+ });
+ });
+
+ describe("edge cases", () => {
+ it("handles empty events array", () => {
+ const { container } = render();
+ // Should render without crashing, no pills
+ expect(container.querySelector("button")).toBeInTheDocument();
+ });
+
+ it("handles missing timestamps", () => {
+ const events = [makeEvent({ timestamp: undefined })];
+ const { container } = render();
+
+ fireEvent.click(container.querySelector("button")!);
+
+ expect(container.textContent).toContain("--:--:--");
+ });
+
+ it("defaults undefined subtype to hook", () => {
+ const events = [makeEvent({ progressSubtype: undefined })];
+ const { container } = render();
+
+ // In pill counts
+ expect(container.textContent).toContain("hook: 1");
+ });
+ });
+
+ describe("agent subtype delegation", () => {
+ function makeAgentEvent(
+ overrides: Partial = {}
+ ): ParsedMessage {
+ const data = {
+ message: {
+ type: "assistant",
+ message: {
+ role: "assistant",
+ content: [
+ {
+ type: "tool_use",
+ id: "toolu_abc",
+ name: "Read",
+ input: { file_path: "/src/foo.ts" },
+ },
+ ],
+ },
+ timestamp: "2026-01-30T16:22:21.000Z",
+ },
+ type: "agent_progress",
+ prompt: "Explore the codebase",
+ agentId: "a6945d4",
+ };
+
+ return {
+ uuid: crypto.randomUUID(),
+ category: "hook_progress",
+ content: JSON.stringify(data),
+ rawIndex: 0,
+ timestamp: "2026-01-30T16:22:21.000Z",
+ progressSubtype: "agent",
+ ...overrides,
+ };
+ }
+
+ it("renders AgentProgressView when all events are agent subtype", () => {
+ const events = [makeAgentEvent(), makeAgentEvent()];
+ const { container } = render();
+
+ // Expand
+ fireEvent.click(container.querySelector("button")!);
+
+ // Should render AgentProgressView
+ expect(
+ container.querySelector("[data-testid='agent-progress-view']")
+ ).toBeInTheDocument();
+ // Should NOT render generic prose-message-progress
+ expect(
+ container.querySelector(".prose-message-progress")
+ ).toBeNull();
+ });
+
+ it("renders generic list when events are mixed subtypes", () => {
+ const events = [
+ makeAgentEvent(),
+ makeEvent({ progressSubtype: "hook" }),
+ ];
+ const { container } = render();
+
+ // Expand
+ fireEvent.click(container.querySelector("button")!);
+
+ // Should NOT render AgentProgressView
+ expect(
+ container.querySelector("[data-testid='agent-progress-view']")
+ ).toBeNull();
+ // Should render generic view
+ expect(
+ container.querySelector(".prose-message-progress")
+ ).toBeInTheDocument();
+ });
+
+ it("pills and collapsed state unchanged regardless of subtype", () => {
+ const events = [makeAgentEvent(), makeAgentEvent(), makeAgentEvent()];
+ const { container } = render();
+
+ // Pills show agent count
+ expect(container.textContent).toContain("agent: 3");
+
+ // No expanded content initially
+ expect(
+ container.querySelector("[data-testid='progress-expanded']")
+ ).toBeNull();
+ });
+ });
+});
diff --git a/tests/fixtures/sample-session.jsonl b/tests/fixtures/sample-session.jsonl
index f683819..24daa03 100644
--- a/tests/fixtures/sample-session.jsonl
+++ b/tests/fixtures/sample-session.jsonl
@@ -1,14 +1,18 @@
{"type":"file-history-snapshot","messageId":"snap-1","snapshot":{"messageId":"snap-1","trackedFileBackups":[],"timestamp":"2025-10-15T10:30:00Z"},"isSnapshotUpdate":false}
-{"type":"progress","data":{"type":"hook","hookEvent":"PreToolUse","hookName":"security_check","command":"check"},"uuid":"prog-1","timestamp":"2025-10-15T10:30:01Z"}
+{"type":"progress","data":{"type":"hook_progress","hookEvent":"SessionStart","hookName":"init_hook","command":"setup"},"uuid":"prog-0","timestamp":"2025-10-15T10:30:00Z"}
+{"type":"progress","data":{"type":"hook_progress","hookEvent":"PreToolUse","hookName":"security_check","command":"check"},"uuid":"prog-1","timestamp":"2025-10-15T10:30:01Z"}
{"type":"user","message":{"role":"user","content":"Can you help me fix the login bug? Users are getting a 401 error."},"uuid":"msg-1","timestamp":"2025-10-15T10:30:05Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user is reporting a 401 error on login. I should look at the authentication middleware and the login endpoint to understand what's happening."},{"type":"text","text":"I'll investigate the login bug. Let me start by looking at the authentication code."}]},"uuid":"msg-2","timestamp":"2025-10-15T10:30:10Z"}
-{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/src/auth/login.ts"}}]},"uuid":"msg-3","timestamp":"2025-10-15T10:30:15Z"}
-{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"export async function login(email: string, password: string) {\n const user = await db.users.findByEmail(email);\n if (!user) throw new UnauthorizedError();\n const valid = await bcrypt.compare(password, user.passwordHash);\n if (!valid) throw new UnauthorizedError();\n return generateToken(user);\n}"}]},"uuid":"msg-4","timestamp":"2025-10-15T10:30:16Z"}
+{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_read1","name":"Read","input":{"file_path":"/src/auth/login.ts"}}]},"uuid":"msg-3","timestamp":"2025-10-15T10:30:15Z"}
+{"type":"progress","data":{"type":"hook_progress","hookEvent":"PreToolUse","hookName":"read_hook","status":"callback"},"parentToolUseID":"toolu_read1","uuid":"prog-read-hook","timestamp":"2025-10-15T10:30:15Z"}
+{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_read1","content":"export async function login(email: string, password: string) {\n const user = await db.users.findByEmail(email);\n if (!user) throw new UnauthorizedError();\n const valid = await bcrypt.compare(password, user.passwordHash);\n if (!valid) throw new UnauthorizedError();\n return generateToken(user);\n}"}]},"uuid":"msg-4","timestamp":"2025-10-15T10:30:16Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I found the issue. The `login` function looks correct, but let me check the middleware that validates tokens.\n\n```typescript\n// The fix is to update the token validation\nexport function validateToken(token: string) {\n return jwt.verify(token, process.env.JWT_SECRET!);\n}\n```\n\nThe problem was that the JWT_SECRET environment variable wasn't being loaded properly."}]},"uuid":"msg-5","timestamp":"2025-10-15T10:30:20Z"}
{"type":"user","message":{"role":"user","content":"Remember to check environment variables"},"uuid":"msg-6","timestamp":"2025-10-15T10:30:21Z"}
{"type":"user","message":{"role":"user","content":"That makes sense! Can you fix it?"},"uuid":"msg-7","timestamp":"2025-10-15T10:31:00Z"}
-{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/src/auth/middleware.ts","old_string":"jwt.verify(token, process.env.JWT_SECRET!)","new_string":"jwt.verify(token, getJwtSecret())"}}]},"uuid":"msg-8","timestamp":"2025-10-15T10:31:05Z"}
-{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_02","content":"File edited successfully."}]},"uuid":"msg-9","timestamp":"2025-10-15T10:31:06Z"}
+{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_edit1","name":"Edit","input":{"file_path":"/src/auth/middleware.ts","old_string":"jwt.verify(token, process.env.JWT_SECRET!)","new_string":"jwt.verify(token, getJwtSecret())"}}]},"uuid":"msg-8","timestamp":"2025-10-15T10:31:05Z"}
+{"type":"progress","data":{"type":"bash_progress","status":"running","toolName":"Bash"},"parentToolUseID":"toolu_edit1","uuid":"prog-edit-bash","timestamp":"2025-10-15T10:31:05Z"}
+{"type":"progress","data":{"type":"mcp_progress","serverName":"morph-mcp","toolName":"edit_file","status":"completed"},"parentToolUseID":"toolu_edit1","uuid":"prog-edit-mcp","timestamp":"2025-10-15T10:31:06Z"}
+{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_edit1","content":"File edited successfully."}]},"uuid":"msg-9","timestamp":"2025-10-15T10:31:06Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I've fixed the issue by using a `getJwtSecret()` helper that properly loads and caches the secret. The login should work now."}]},"uuid":"msg-10","timestamp":"2025-10-15T10:31:10Z"}
{"type":"progress","data":{"type":"tool","status":"running","toolName":"Bash","serverName":"local"},"uuid":"prog-2","timestamp":"2025-10-15T10:31:15Z"}
{"type":"summary","summary":"Fixed authentication bug by updating JWT secret loading in middleware.","leafUuid":"msg-10"}
diff --git a/tests/unit/agent-progress-parser.test.ts b/tests/unit/agent-progress-parser.test.ts
new file mode 100644
index 0000000..5defc6b
--- /dev/null
+++ b/tests/unit/agent-progress-parser.test.ts
@@ -0,0 +1,511 @@
+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 = {},
+ msgOverrides: Partial = {}
+): 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");
+ }
+ });
+ });
+});
diff --git a/tests/unit/filters.test.ts b/tests/unit/filters.test.ts
index 0b5ce97..2b0b48f 100644
--- a/tests/unit/filters.test.ts
+++ b/tests/unit/filters.test.ts
@@ -112,6 +112,19 @@ describe("filters", () => {
expect(filtered.every((m) => m.category === "tool_call" || m.category === "tool_result")).toBe(true);
});
+ it("hook_progress included when category is enabled", () => {
+ const enabled = new Set(ALL_CATEGORIES);
+ const filtered = filterMessages(messages, enabled);
+ expect(filtered.find((m) => m.category === "hook_progress")).toBeDefined();
+ });
+
+ it("hook_progress excluded when category is disabled", () => {
+ const enabled = new Set(ALL_CATEGORIES);
+ enabled.delete("hook_progress");
+ const filtered = filterMessages(messages, enabled);
+ expect(filtered.find((m) => m.category === "hook_progress")).toBeUndefined();
+ });
+
it("category counts are computed correctly", () => {
const counts: Record = {};
for (const cat of ALL_CATEGORIES) {
diff --git a/tests/unit/html-exporter.test.ts b/tests/unit/html-exporter.test.ts
index 74d1f38..cf0af62 100644
--- a/tests/unit/html-exporter.test.ts
+++ b/tests/unit/html-exporter.test.ts
@@ -16,13 +16,15 @@ function makeMessage(
function makeExportRequest(
messages: ParsedMessage[],
visible?: string[],
- redacted?: string[]
+ redacted?: string[],
+ toolProgress?: Record
): ExportRequest {
return {
session: {
id: "test-session",
project: "test-project",
messages,
+ toolProgress,
},
visibleMessageUuids: visible || messages.map((m) => m.uuid),
redactedMessageUuids: redacted || [],
@@ -118,4 +120,149 @@ describe("html-exporter", () => {
// Verify singular — should NOT contain "1 messages"
expect(html).not.toMatch(/\b1 messages\b/);
});
+
+ // ─── Collapsible messages ───
+
+ it("thinking messages render collapsed by default", async () => {
+ const msg = makeMessage({
+ uuid: "think-1",
+ category: "thinking",
+ content: "Line one\nLine two\nLine three\nLine four\nLine five",
+ });
+ const html = await generateExportHtml(makeExportRequest([msg]));
+ expect(html).toContain('data-collapsed="true"');
+ });
+
+ it("tool_call messages render collapsed with tool name preview", async () => {
+ const msg = makeMessage({
+ uuid: "tc-1",
+ category: "tool_call",
+ content: "",
+ toolName: "Read",
+ toolInput: '{"path": "/foo/bar.ts"}',
+ toolUseId: "tu-1",
+ });
+ const html = await generateExportHtml(makeExportRequest([msg]));
+ expect(html).toContain('data-collapsed="true"');
+ expect(html).toContain("collapsed-preview");
+ expect(html).toContain("Read");
+ });
+
+ it("tool_result messages render collapsed with content preview", async () => {
+ const msg = makeMessage({
+ uuid: "tr-1",
+ category: "tool_result",
+ content: "This is the first line of a tool result that should be truncated in the preview display when collapsed",
+ });
+ const html = await generateExportHtml(makeExportRequest([msg]));
+ expect(html).toContain('data-collapsed="true"');
+ expect(html).toContain("collapsed-preview");
+ });
+
+ it("collapsible messages include toggle button", async () => {
+ const msg = makeMessage({
+ uuid: "think-2",
+ category: "thinking",
+ content: "Some thoughts",
+ });
+ const html = await generateExportHtml(makeExportRequest([msg]));
+ expect(html).toContain("collapsible-toggle");
+ });
+
+ it("non-collapsible messages do not have collapse attributes on their message div", async () => {
+ const msg = makeMessage({
+ uuid: "user-1",
+ category: "user_message",
+ content: "Hello there",
+ });
+ const html = await generateExportHtml(makeExportRequest([msg]));
+ // Extract the message div — it should NOT have data-collapsed (CSS will have it as a selector)
+ const messageDiv = html.match(/]*>/);
+ expect(messageDiv).not.toBeNull();
+ expect(messageDiv![0]).not.toContain("data-collapsed");
+ expect(messageDiv![0]).not.toContain("collapsible-toggle");
+ });
+
+ it("export includes toggle JavaScript", async () => {
+ const msg = makeMessage({ uuid: "msg-js", content: "Hello" });
+ const html = await generateExportHtml(makeExportRequest([msg]));
+ expect(html).toContain("