From 3fe8d7d3b5dd4dc882ae63102223cff3215846ce Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 30 Jan 2026 23:05:01 -0500 Subject: [PATCH] Add comprehensive test suite for progress tracking system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/AgentProgressView.test.tsx | 254 +++++++++ src/client/components/ProgressBadge.test.tsx | 240 ++++++++ tests/fixtures/sample-session.jsonl | 14 +- tests/unit/agent-progress-parser.test.ts | 511 ++++++++++++++++++ tests/unit/filters.test.ts | 13 + tests/unit/html-exporter.test.ts | 149 ++++- tests/unit/progress-grouper.test.ts | 98 ++++ tests/unit/session-parser.test.ts | 103 ++++ 8 files changed, 1376 insertions(+), 6 deletions(-) create mode 100644 src/client/components/AgentProgressView.test.tsx create mode 100644 src/client/components/ProgressBadge.test.tsx create mode 100644 tests/unit/agent-progress-parser.test.ts create mode 100644 tests/unit/progress-grouper.test.ts 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("