import { describe, it, expect } from "vitest"; import { parseSessionContent, forEachJsonlLine, classifyLine, countMessagesForLine, } from "../../src/server/services/session-parser.js"; import type { RawLine } from "../../src/server/services/session-parser.js"; import fs from "fs/promises"; import path from "path"; describe("session-parser", () => { it("parses user messages with string content", () => { const line = JSON.stringify({ type: "user", message: { role: "user", content: "Hello world" }, uuid: "u-1", timestamp: "2025-10-15T10:00:00Z", }); const msgs = parseSessionContent(line); expect(msgs).toHaveLength(1); expect(msgs[0].category).toBe("user_message"); expect(msgs[0].content).toBe("Hello world"); expect(msgs[0].uuid).toBe("u-1"); expect(msgs[0].timestamp).toBe("2025-10-15T10:00:00Z"); }); it("parses user messages with tool_result array content", () => { const line = JSON.stringify({ type: "user", message: { role: "user", content: [ { type: "tool_result", tool_use_id: "toolu_01", content: "File contents here" }, ], }, uuid: "u-2", }); const msgs = parseSessionContent(line); expect(msgs).toHaveLength(1); expect(msgs[0].category).toBe("tool_result"); expect(msgs[0].content).toBe("File contents here"); }); it("parses assistant text blocks", () => { const line = JSON.stringify({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "Here is my response" }], }, uuid: "a-1", }); const msgs = parseSessionContent(line); expect(msgs).toHaveLength(1); expect(msgs[0].category).toBe("assistant_text"); expect(msgs[0].content).toBe("Here is my response"); }); it("parses thinking blocks", () => { const line = JSON.stringify({ type: "assistant", message: { role: "assistant", content: [{ type: "thinking", thinking: "Let me think about this..." }], }, uuid: "a-2", }); const msgs = parseSessionContent(line); expect(msgs).toHaveLength(1); expect(msgs[0].category).toBe("thinking"); expect(msgs[0].content).toBe("Let me think about this..."); }); it("parses tool_use blocks", () => { const line = JSON.stringify({ type: "assistant", message: { role: "assistant", content: [ { type: "tool_use", name: "Read", input: { file_path: "/src/index.ts" } }, ], }, uuid: "a-3", }); const msgs = parseSessionContent(line); expect(msgs).toHaveLength(1); expect(msgs[0].category).toBe("tool_call"); expect(msgs[0].toolName).toBe("Read"); expect(msgs[0].toolInput).toContain("/src/index.ts"); }); it("parses progress messages from data field", () => { const line = JSON.stringify({ type: "progress", data: { type: "hook", hookEvent: "PreToolUse", hookName: "security_check" }, uuid: "p-1", timestamp: "2025-10-15T10:00:00Z", }); const msgs = parseSessionContent(line); expect(msgs).toHaveLength(1); expect(msgs[0].category).toBe("hook_progress"); expect(msgs[0].content).toContain("PreToolUse"); expect(msgs[0].content).toContain("security_check"); }); it("parses file-history-snapshot messages", () => { const line = JSON.stringify({ type: "file-history-snapshot", messageId: "snap-1", snapshot: { messageId: "snap-1", trackedFileBackups: [], timestamp: "2025-10-15T10:00:00Z" }, }); const msgs = parseSessionContent(line); expect(msgs).toHaveLength(1); expect(msgs[0].category).toBe("file_snapshot"); }); it("parses summary messages from summary field", () => { const line = JSON.stringify({ type: "summary", summary: "Session summary here", leafUuid: "msg-10", }); const msgs = parseSessionContent(line); expect(msgs).toHaveLength(1); expect(msgs[0].category).toBe("summary"); expect(msgs[0].content).toBe("Session summary here"); }); it("skips system metadata lines (turn_duration)", () => { const line = JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 50000, uuid: "sys-1", }); const msgs = parseSessionContent(line); expect(msgs).toHaveLength(0); }); it("skips queue-operation lines", () => { const line = JSON.stringify({ type: "queue-operation", uuid: "qo-1", }); const msgs = parseSessionContent(line); expect(msgs).toHaveLength(0); }); it("detects system-reminder content in user messages", () => { const line = JSON.stringify({ type: "user", message: { role: "user", content: "Some reminder", }, uuid: "u-sr", }); const msgs = parseSessionContent(line); expect(msgs).toHaveLength(1); expect(msgs[0].category).toBe("system_message"); }); it("skips malformed JSONL lines without crashing", () => { const content = [ "not valid json", JSON.stringify({ type: "user", message: { role: "user", content: "Valid message" }, uuid: "u-valid", }), "{broken}", ].join("\n"); const msgs = parseSessionContent(content); expect(msgs).toHaveLength(1); expect(msgs[0].content).toBe("Valid message"); }); it("returns empty array for empty files", () => { const msgs = parseSessionContent(""); expect(msgs).toEqual([]); }); it("uses uuid from the JSONL line, not random", () => { const line = JSON.stringify({ type: "user", message: { role: "user", content: "Test" }, uuid: "my-specific-uuid-123", }); const msgs = parseSessionContent(line); expect(msgs[0].uuid).toBe("my-specific-uuid-123"); }); it("parses the full sample session fixture", async () => { const fixturePath = path.join( __dirname, "../fixtures/sample-session.jsonl" ); const content = await fs.readFile(fixturePath, "utf-8"); const msgs = parseSessionContent(content); const categories = new Set(msgs.map((m) => m.category)); expect(categories.has("user_message")).toBe(true); expect(categories.has("assistant_text")).toBe(true); expect(categories.has("thinking")).toBe(true); expect(categories.has("tool_call")).toBe(true); expect(categories.has("tool_result")).toBe(true); expect(categories.has("system_message")).toBe(true); expect(categories.has("hook_progress")).toBe(true); expect(categories.has("summary")).toBe(true); expect(categories.has("file_snapshot")).toBe(true); }); it("handles edge-cases fixture (corrupt lines)", async () => { const fixturePath = path.join( __dirname, "../fixtures/edge-cases.jsonl" ); const content = await fs.readFile(fixturePath, "utf-8"); const msgs = parseSessionContent(content); expect(msgs).toHaveLength(2); 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"); }); describe("forEachJsonlLine", () => { it("skips malformed JSON lines and reports parseErrors count", () => { const content = [ "not valid json", JSON.stringify({ type: "user", message: { role: "user", content: "Hello" } }), "{broken}", ].join("\n"); const lines: RawLine[] = []; const result = forEachJsonlLine(content, (parsed) => { lines.push(parsed); }); expect(lines).toHaveLength(1); expect(result.parseErrors).toBe(2); }); it("skips empty and whitespace-only lines without incrementing parseErrors", () => { const content = [ "", " ", JSON.stringify({ type: "summary", summary: "test" }), "\t", "", ].join("\n"); const lines: RawLine[] = []; const result = forEachJsonlLine(content, (parsed) => { lines.push(parsed); }); expect(lines).toHaveLength(1); expect(result.parseErrors).toBe(0); }); it("returns parseErrors 0 for empty content", () => { const lines: RawLine[] = []; const result = forEachJsonlLine("", (parsed) => { lines.push(parsed); }); expect(lines).toHaveLength(0); expect(result.parseErrors).toBe(0); }); it("processes content without trailing newline", () => { const content = JSON.stringify({ type: "summary", summary: "no trailing newline" }); const lines: RawLine[] = []; forEachJsonlLine(content, (parsed) => { lines.push(parsed); }); expect(lines).toHaveLength(1); expect(lines[0].summary).toBe("no trailing newline"); }); it("passes correct lineIndex to callback", () => { const content = [ JSON.stringify({ type: "user", message: { role: "user", content: "first" } }), "", JSON.stringify({ type: "summary", summary: "third" }), ].join("\n"); const indices: number[] = []; forEachJsonlLine(content, (_parsed, lineIndex) => { indices.push(lineIndex); }); expect(indices).toEqual([0, 2]); }); }); describe("classifyLine", () => { it("returns correct classification for each type", () => { expect(classifyLine({ type: "progress" })).toBe("progress"); expect(classifyLine({ type: "file-history-snapshot" })).toBe("file-history-snapshot"); expect(classifyLine({ type: "summary" })).toBe("summary"); expect(classifyLine({ type: "system" })).toBe("system"); expect(classifyLine({ type: "queue-operation" })).toBe("queue-operation"); expect(classifyLine({ type: "user", message: { role: "user" } })).toBe("user"); expect(classifyLine({ type: "assistant", message: { role: "assistant" } })).toBe("assistant"); expect(classifyLine({})).toBe("unknown"); }); it("classifies by message.role when type is missing", () => { expect(classifyLine({ message: { role: "user" } })).toBe("user"); expect(classifyLine({ message: { role: "assistant" } })).toBe("assistant"); }); it("returns unknown for missing type and no role", () => { expect(classifyLine({ message: {} })).toBe("unknown"); expect(classifyLine({ uuid: "orphan" })).toBe("unknown"); }); }); describe("countMessagesForLine", () => { it("returns 1 for user string message", () => { const line: RawLine = { type: "user", message: { role: "user", content: "Hello" }, }; expect(countMessagesForLine(line)).toBe(1); }); it("matches extractMessages length for user array with tool_result and text", () => { const line: RawLine = { type: "user", message: { role: "user", content: [ { type: "tool_result", tool_use_id: "t1", content: "result" }, { type: "text", text: "description" }, ], }, uuid: "u-arr", }; const msgs = parseSessionContent(JSON.stringify(line)); expect(countMessagesForLine(line)).toBe(msgs.length); expect(countMessagesForLine(line)).toBe(2); }); it("matches extractMessages length for assistant array with thinking/text/tool_use", () => { const line: RawLine = { type: "assistant", message: { role: "assistant", content: [ { type: "thinking", thinking: "hmm" }, { type: "text", text: "response" }, { type: "tool_use", name: "Read", input: { file_path: "/x" } }, ], }, uuid: "a-arr", }; const msgs = parseSessionContent(JSON.stringify(line)); expect(countMessagesForLine(line)).toBe(msgs.length); expect(countMessagesForLine(line)).toBe(3); }); it("returns 1 for progress/file-history-snapshot/summary", () => { expect(countMessagesForLine({ type: "progress", data: { type: "hook" } })).toBe(1); expect(countMessagesForLine({ type: "file-history-snapshot", snapshot: {} })).toBe(1); expect(countMessagesForLine({ type: "summary", summary: "test" })).toBe(1); }); it("returns 0 for system/queue-operation", () => { expect(countMessagesForLine({ type: "system", subtype: "turn_duration" })).toBe(0); expect(countMessagesForLine({ type: "queue-operation" })).toBe(0); }); it("returns 0 for unknown type", () => { expect(countMessagesForLine({})).toBe(0); expect(countMessagesForLine({ type: "something-new" })).toBe(0); }); it("returns 0 for user message with empty content array", () => { const line: RawLine = { type: "user", message: { role: "user", content: [] }, }; expect(countMessagesForLine(line)).toBe(0); }); it("returns 0 for user message with undefined content", () => { const line: RawLine = { type: "user", message: { role: "user" }, }; expect(countMessagesForLine(line)).toBe(0); }); it("only counts known block types in assistant arrays", () => { const line: RawLine = { type: "assistant", message: { role: "assistant", content: [ { type: "thinking", thinking: "hmm" }, { type: "unknown_block" }, { type: "text", text: "hi" }, ], }, }; expect(countMessagesForLine(line)).toBe(2); }); it("returns 1 for assistant string content", () => { const line: RawLine = { type: "assistant", message: { role: "assistant", content: "direct string" }, }; expect(countMessagesForLine(line)).toBe(1); }); it("counts user text with system-reminder as 1 (reclassified but still counted)", () => { const line: RawLine = { type: "user", message: { role: "user", content: "Some reminder" }, uuid: "u-sr-parity", }; const msgs = parseSessionContent(JSON.stringify(line)); expect(countMessagesForLine(line)).toBe(msgs.length); expect(countMessagesForLine(line)).toBe(1); }); it("handles truncated JSON (crash mid-write)", () => { const content = [ JSON.stringify({ type: "user", message: { role: "user", content: "ok" }, uuid: "u-ok" }), '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"trun', ].join("\n"); const lines: RawLine[] = []; const result = forEachJsonlLine(content, (parsed) => { lines.push(parsed); }); expect(lines).toHaveLength(1); expect(result.parseErrors).toBe(1); }); }); describe("parser parity: fixture integration", () => { it("countMessagesForLine sum matches parseSessionContent on sample-session.jsonl", async () => { const fixturePath = path.join(__dirname, "../fixtures/sample-session.jsonl"); const content = await fs.readFile(fixturePath, "utf-8"); const parsedMessages = parseSessionContent(content); let countSum = 0; forEachJsonlLine(content, (parsed) => { countSum += countMessagesForLine(parsed); }); expect(countSum).toBe(parsedMessages.length); }); it("countMessagesForLine sum matches parseSessionContent on edge-cases.jsonl", async () => { const fixturePath = path.join(__dirname, "../fixtures/edge-cases.jsonl"); const content = await fs.readFile(fixturePath, "utf-8"); const parsedMessages = parseSessionContent(content); let countSum = 0; forEachJsonlLine(content, (parsed) => { countSum += countMessagesForLine(parsed); }); expect(countSum).toBe(parsedMessages.length); }); }); });