Add comprehensive test suite for progress tracking system

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>
This commit is contained in:
2026-01-30 23:05:01 -05:00
parent d4de363227
commit 3fe8d7d3b5
8 changed files with 1376 additions and 6 deletions

View File

@@ -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<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");
}
});
});
});

View File

@@ -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<MessageCategory>(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<MessageCategory>(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<string, number> = {};
for (const cat of ALL_CATEGORIES) {

View File

@@ -16,13 +16,15 @@ function makeMessage(
function makeExportRequest(
messages: ParsedMessage[],
visible?: string[],
redacted?: string[]
redacted?: string[],
toolProgress?: Record<string, ParsedMessage[]>
): 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(/<div class="message"[^>]*>/);
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("<script>");
expect(html).toContain("collapsible-toggle");
});
it("tool_result with diff content gets diff highlighting", async () => {
const diffContent = [
"diff --git a/foo.ts b/foo.ts",
"--- a/foo.ts",
"+++ b/foo.ts",
"@@ -1,3 +1,3 @@",
" unchanged",
"-old line",
"+new line",
].join("\n");
const msg = makeMessage({
uuid: "tr-diff",
category: "tool_result",
content: diffContent,
});
const html = await generateExportHtml(makeExportRequest([msg]));
expect(html).toContain("diff-add");
expect(html).toContain("diff-del");
expect(html).toContain("diff-hunk");
});
it("tool_call with progress events renders progress badge", async () => {
const toolMsg = makeMessage({
uuid: "tc-prog",
category: "tool_call",
content: "",
toolName: "Bash",
toolUseId: "tu-prog",
});
const progressEvents: ParsedMessage[] = [
makeMessage({
uuid: "pe-1",
category: "hook_progress",
content: "Running...",
progressSubtype: "bash",
timestamp: "2025-01-01T12:00:00Z",
}),
makeMessage({
uuid: "pe-2",
category: "hook_progress",
content: "Done",
progressSubtype: "bash",
timestamp: "2025-01-01T12:00:01Z",
}),
];
const html = await generateExportHtml(
makeExportRequest([toolMsg], undefined, undefined, { "tu-prog": progressEvents })
);
expect(html).toContain("progress-badge");
expect(html).toContain("progress-drawer");
expect(html).toContain("bash");
expect(html).toContain("2");
});
it("tool_call without progress events has no badge", async () => {
const toolMsg = makeMessage({
uuid: "tc-no-prog",
category: "tool_call",
content: "",
toolName: "Read",
toolUseId: "tu-no-prog",
});
const html = await generateExportHtml(makeExportRequest([toolMsg]));
// The CSS will contain .progress-badge as a selector, but the message HTML should not
// have an actual progress-badge div element
expect(html).not.toContain('<div class="progress-badge">');
});
it("print CSS forces content visible", async () => {
const msg = makeMessage({ uuid: "msg-print", content: "Hello" });
const html = await generateExportHtml(makeExportRequest([msg]));
expect(html).toContain("@media print");
// Should override collapsed hidden state for print
expect(html).toMatch(/\.message-body\s*\{[^}]*display:\s*block/);
});
});

View File

@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import { groupProgress } from "../../src/server/services/progress-grouper.js";
import type { ParsedMessage } from "../../src/shared/types.js";
function makeMsg(
overrides: Partial<ParsedMessage> & { uuid: string; rawIndex: number }
): ParsedMessage {
return {
category: "assistant_text",
content: "test",
...overrides,
};
}
describe("progress-grouper", () => {
it("partitions parented progress into toolProgress map", () => {
const messages: ParsedMessage[] = [
makeMsg({ uuid: "tc-1", rawIndex: 0, category: "tool_call", toolUseId: "toolu_1" }),
makeMsg({ uuid: "hp-1", rawIndex: 1, category: "hook_progress", parentToolUseId: "toolu_1", progressSubtype: "hook" }),
makeMsg({ uuid: "hp-2", rawIndex: 2, category: "hook_progress", parentToolUseId: "toolu_1", progressSubtype: "bash" }),
makeMsg({ uuid: "txt-1", rawIndex: 3, category: "assistant_text" }),
];
const result = groupProgress(messages);
expect(result.toolProgress["toolu_1"]).toHaveLength(2);
expect(result.toolProgress["toolu_1"][0].uuid).toBe("hp-1");
expect(result.toolProgress["toolu_1"][1].uuid).toBe("hp-2");
});
it("removes parented progress from messages array", () => {
const messages: ParsedMessage[] = [
makeMsg({ uuid: "tc-1", rawIndex: 0, category: "tool_call", toolUseId: "toolu_1" }),
makeMsg({ uuid: "hp-1", rawIndex: 1, category: "hook_progress", parentToolUseId: "toolu_1" }),
makeMsg({ uuid: "txt-1", rawIndex: 2, category: "assistant_text" }),
];
const result = groupProgress(messages);
expect(result.messages).toHaveLength(2);
expect(result.messages.map((m) => m.uuid)).toEqual(["tc-1", "txt-1"]);
});
it("keeps orphaned progress (no parentToolUseId) in messages", () => {
const messages: ParsedMessage[] = [
makeMsg({ uuid: "hp-orphan", rawIndex: 0, category: "hook_progress", progressSubtype: "hook" }),
makeMsg({ uuid: "txt-1", rawIndex: 1, category: "assistant_text" }),
];
const result = groupProgress(messages);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].uuid).toBe("hp-orphan");
expect(Object.keys(result.toolProgress)).toHaveLength(0);
});
it("keeps progress with invalid parentToolUseId (no matching tool_call) in messages", () => {
const messages: ParsedMessage[] = [
makeMsg({ uuid: "hp-invalid", rawIndex: 0, category: "hook_progress", parentToolUseId: "toolu_nonexistent" }),
makeMsg({ uuid: "txt-1", rawIndex: 1, category: "assistant_text" }),
];
const result = groupProgress(messages);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].uuid).toBe("hp-invalid");
});
it("returns empty results for empty input", () => {
const result = groupProgress([]);
expect(result.messages).toEqual([]);
expect(result.toolProgress).toEqual({});
});
it("sorts each toolProgress group by rawIndex", () => {
const messages: ParsedMessage[] = [
makeMsg({ uuid: "tc-1", rawIndex: 0, category: "tool_call", toolUseId: "toolu_1" }),
makeMsg({ uuid: "hp-3", rawIndex: 5, category: "hook_progress", parentToolUseId: "toolu_1" }),
makeMsg({ uuid: "hp-1", rawIndex: 1, category: "hook_progress", parentToolUseId: "toolu_1" }),
makeMsg({ uuid: "hp-2", rawIndex: 3, category: "hook_progress", parentToolUseId: "toolu_1" }),
];
const result = groupProgress(messages);
const group = result.toolProgress["toolu_1"];
expect(group.map((m) => m.rawIndex)).toEqual([1, 3, 5]);
});
it("handles multiple tool_call parents independently", () => {
const messages: ParsedMessage[] = [
makeMsg({ uuid: "tc-1", rawIndex: 0, category: "tool_call", toolUseId: "toolu_1" }),
makeMsg({ uuid: "hp-1a", rawIndex: 1, category: "hook_progress", parentToolUseId: "toolu_1" }),
makeMsg({ uuid: "tc-2", rawIndex: 2, category: "tool_call", toolUseId: "toolu_2" }),
makeMsg({ uuid: "hp-2a", rawIndex: 3, category: "hook_progress", parentToolUseId: "toolu_2" }),
makeMsg({ uuid: "hp-2b", rawIndex: 4, category: "hook_progress", parentToolUseId: "toolu_2" }),
];
const result = groupProgress(messages);
expect(result.toolProgress["toolu_1"]).toHaveLength(1);
expect(result.toolProgress["toolu_2"]).toHaveLength(2);
expect(result.messages).toHaveLength(2); // only the 2 tool_calls
});
});

View File

@@ -216,4 +216,107 @@ describe("session-parser", () => {
expect(msgs[0].category).toBe("user_message");
expect(msgs[1].category).toBe("assistant_text");
});
it("extracts toolUseId from tool_use blocks with id field", () => {
const line = JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
{ type: "tool_use", id: "toolu_abc123", name: "Read", input: { file_path: "/src/index.ts" } },
],
},
uuid: "a-tu-1",
});
const msgs = parseSessionContent(line);
expect(msgs).toHaveLength(1);
expect(msgs[0].category).toBe("tool_call");
expect(msgs[0].toolUseId).toBe("toolu_abc123");
});
it("toolUseId is undefined when tool_use block has no id field", () => {
const line = JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
{ type: "tool_use", name: "Read", input: { file_path: "/src/index.ts" } },
],
},
uuid: "a-tu-2",
});
const msgs = parseSessionContent(line);
expect(msgs).toHaveLength(1);
expect(msgs[0].toolUseId).toBeUndefined();
});
it("extracts parentToolUseId and progressSubtype from hook_progress", () => {
const line = JSON.stringify({
type: "progress",
data: { type: "hook_progress", hookEvent: "PreToolUse", hookName: "check" },
parentToolUseID: "toolu_abc123",
uuid: "p-linked",
timestamp: "2025-10-15T10:00:00Z",
});
const msgs = parseSessionContent(line);
expect(msgs).toHaveLength(1);
expect(msgs[0].category).toBe("hook_progress");
expect(msgs[0].parentToolUseId).toBe("toolu_abc123");
expect(msgs[0].progressSubtype).toBe("hook");
});
it("derives progressSubtype 'bash' from bash_progress data type", () => {
const line = JSON.stringify({
type: "progress",
data: { type: "bash_progress", status: "running" },
parentToolUseID: "toolu_bash1",
uuid: "p-bash",
});
const msgs = parseSessionContent(line);
expect(msgs[0].progressSubtype).toBe("bash");
expect(msgs[0].parentToolUseId).toBe("toolu_bash1");
});
it("derives progressSubtype 'mcp' from mcp_progress data type", () => {
const line = JSON.stringify({
type: "progress",
data: { type: "mcp_progress", serverName: "morph-mcp" },
parentToolUseID: "toolu_mcp1",
uuid: "p-mcp",
});
const msgs = parseSessionContent(line);
expect(msgs[0].progressSubtype).toBe("mcp");
});
it("derives progressSubtype 'agent' from agent_progress data type", () => {
const line = JSON.stringify({
type: "progress",
data: { type: "agent_progress", status: "started" },
parentToolUseID: "toolu_agent1",
uuid: "p-agent",
});
const msgs = parseSessionContent(line);
expect(msgs[0].progressSubtype).toBe("agent");
});
it("parentToolUseId is undefined when progress has no parentToolUseID", () => {
const line = JSON.stringify({
type: "progress",
data: { type: "hook_progress", hookEvent: "SessionStart" },
uuid: "p-orphan",
});
const msgs = parseSessionContent(line);
expect(msgs[0].parentToolUseId).toBeUndefined();
expect(msgs[0].progressSubtype).toBe("hook");
});
it("progressSubtype defaults to 'hook' for unknown data types", () => {
const line = JSON.stringify({
type: "progress",
data: { type: "unknown_thing", status: "ok" },
uuid: "p-unknown",
});
const msgs = parseSessionContent(line);
expect(msgs[0].progressSubtype).toBe("hook");
});
});