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:
254
src/client/components/AgentProgressView.test.tsx
Normal file
254
src/client/components/AgentProgressView.test.tsx
Normal file
@@ -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) => `<p>${text}</p>`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Build a ParsedMessage whose content is a JSON agent_progress payload */
|
||||||
|
function makeAgentProgressEvent(
|
||||||
|
messageOverrides: Record<string, unknown> = {},
|
||||||
|
dataOverrides: Record<string, unknown> = {},
|
||||||
|
msgOverrides: Partial<ParsedMessage> = {}
|
||||||
|
): 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(<AgentProgressView events={events} />);
|
||||||
|
|
||||||
|
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(<AgentProgressView events={events} />);
|
||||||
|
|
||||||
|
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(<AgentProgressView events={events} />);
|
||||||
|
|
||||||
|
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(<AgentProgressView events={events} />);
|
||||||
|
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(<AgentProgressView events={events} />);
|
||||||
|
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(
|
||||||
|
<AgentProgressView events={events} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<AgentProgressView events={events} />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<AgentProgressView events={events} />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<AgentProgressView events={events} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<AgentProgressView events={events} />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(<AgentProgressView events={events} />);
|
||||||
|
expect(getByTestId("agent-progress-view")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
240
src/client/components/ProgressBadge.test.tsx
Normal file
240
src/client/components/ProgressBadge.test.tsx
Normal file
@@ -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) => `<p>${text}</p>`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeEvent(
|
||||||
|
overrides: Partial<ParsedMessage> = {}
|
||||||
|
): 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(
|
||||||
|
<ProgressBadge events={events} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
// 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(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
// Expand
|
||||||
|
fireEvent.click(container.querySelector("button")!);
|
||||||
|
|
||||||
|
// Our mock wraps in <p>, 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("<p>**bold text**</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not have max-h-48 or overflow-y-auto on expanded container", () => {
|
||||||
|
const events = [makeEvent()];
|
||||||
|
const { container } = render(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
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(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
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(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
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(<ProgressBadge events={[]} />);
|
||||||
|
// Should render without crashing, no pills
|
||||||
|
expect(container.querySelector("button")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing timestamps", () => {
|
||||||
|
const events = [makeEvent({ timestamp: undefined })];
|
||||||
|
const { container } = render(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
fireEvent.click(container.querySelector("button")!);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("--:--:--");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults undefined subtype to hook", () => {
|
||||||
|
const events = [makeEvent({ progressSubtype: undefined })];
|
||||||
|
const { container } = render(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
// In pill counts
|
||||||
|
expect(container.textContent).toContain("hook: 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("agent subtype delegation", () => {
|
||||||
|
function makeAgentEvent(
|
||||||
|
overrides: Partial<ParsedMessage> = {}
|
||||||
|
): 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(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
// 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(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
// 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(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
// Pills show agent count
|
||||||
|
expect(container.textContent).toContain("agent: 3");
|
||||||
|
|
||||||
|
// No expanded content initially
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='progress-expanded']")
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
14
tests/fixtures/sample-session.jsonl
vendored
14
tests/fixtures/sample-session.jsonl
vendored
@@ -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":"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":"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":"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":"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":"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":"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":"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":"<system-reminder>Remember to check environment variables</system-reminder>"},"uuid":"msg-6","timestamp":"2025-10-15T10:30:21Z"}
|
{"type":"user","message":{"role":"user","content":"<system-reminder>Remember to check environment variables</system-reminder>"},"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":"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":"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":"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":"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":"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":"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"}
|
{"type":"summary","summary":"Fixed authentication bug by updating JWT secret loading in middleware.","leafUuid":"msg-10"}
|
||||||
|
|||||||
511
tests/unit/agent-progress-parser.test.ts
Normal file
511
tests/unit/agent-progress-parser.test.ts
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -112,6 +112,19 @@ describe("filters", () => {
|
|||||||
expect(filtered.every((m) => m.category === "tool_call" || m.category === "tool_result")).toBe(true);
|
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", () => {
|
it("category counts are computed correctly", () => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const cat of ALL_CATEGORIES) {
|
for (const cat of ALL_CATEGORIES) {
|
||||||
|
|||||||
@@ -16,13 +16,15 @@ function makeMessage(
|
|||||||
function makeExportRequest(
|
function makeExportRequest(
|
||||||
messages: ParsedMessage[],
|
messages: ParsedMessage[],
|
||||||
visible?: string[],
|
visible?: string[],
|
||||||
redacted?: string[]
|
redacted?: string[],
|
||||||
|
toolProgress?: Record<string, ParsedMessage[]>
|
||||||
): ExportRequest {
|
): ExportRequest {
|
||||||
return {
|
return {
|
||||||
session: {
|
session: {
|
||||||
id: "test-session",
|
id: "test-session",
|
||||||
project: "test-project",
|
project: "test-project",
|
||||||
messages,
|
messages,
|
||||||
|
toolProgress,
|
||||||
},
|
},
|
||||||
visibleMessageUuids: visible || messages.map((m) => m.uuid),
|
visibleMessageUuids: visible || messages.map((m) => m.uuid),
|
||||||
redactedMessageUuids: redacted || [],
|
redactedMessageUuids: redacted || [],
|
||||||
@@ -118,4 +120,149 @@ describe("html-exporter", () => {
|
|||||||
// Verify singular — should NOT contain "1 messages"
|
// Verify singular — should NOT contain "1 messages"
|
||||||
expect(html).not.toMatch(/\b1 messages\b/);
|
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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
98
tests/unit/progress-grouper.test.ts
Normal file
98
tests/unit/progress-grouper.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -216,4 +216,107 @@ describe("session-parser", () => {
|
|||||||
expect(msgs[0].category).toBe("user_message");
|
expect(msgs[0].category).toBe("user_message");
|
||||||
expect(msgs[1].category).toBe("assistant_text");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user