Introduce extractSessionMetadata() in a new session-metadata.ts module that extracts only what the list view needs from JSONL files: - messageCount: Uses shared countMessagesForLine() for exact parity - firstPrompt: First non-system-reminder user message, truncated to 200 chars - summary: Last type="summary" line's summary field - firstTimestamp/lastTimestamp: For duration computation Design goals: - Parser parity: Uses forEachJsonlLine() and countMessagesForLine() from session-parser.ts, ensuring list counts always match detail-view counts - No string building: Avoids JSON.stringify and markdown processing - 2-3x faster than full parse: Only captures metadata, skips content - Graceful degradation: Handles malformed lines identically to full parser This is the Tier 3 data source for JSONL-first session discovery. When neither the sessions-index.json nor the persistent cache has valid data, this function extracts fresh metadata from the file. Test coverage includes: - Output matches parseSessionContent().length on sample fixtures - Duration extraction from JSONL timestamps - firstPrompt extraction skips system-reminder content - Empty files return zero counts and empty strings Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
193 lines
6.0 KiB
TypeScript
193 lines
6.0 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { extractSessionMetadata } from "../../src/server/services/session-metadata.js";
|
|
import { parseSessionContent } from "../../src/server/services/session-parser.js";
|
|
import fs from "fs/promises";
|
|
import path from "path";
|
|
|
|
describe("session-metadata", () => {
|
|
it("messageCount 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 meta = extractSessionMetadata(content);
|
|
const parsed = parseSessionContent(content);
|
|
|
|
expect(meta.messageCount).toBe(parsed.length);
|
|
});
|
|
|
|
it("messageCount 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 meta = extractSessionMetadata(content);
|
|
const parsed = parseSessionContent(content);
|
|
|
|
expect(meta.messageCount).toBe(parsed.length);
|
|
});
|
|
|
|
it("firstPrompt skips system-reminder messages", () => {
|
|
const content = [
|
|
JSON.stringify({
|
|
type: "user",
|
|
message: { role: "user", content: "<system-reminder>hook output</system-reminder>" },
|
|
uuid: "u-sr",
|
|
timestamp: "2025-01-01T00:00:00Z",
|
|
}),
|
|
JSON.stringify({
|
|
type: "user",
|
|
message: { role: "user", content: "What is the project structure?" },
|
|
uuid: "u-real",
|
|
timestamp: "2025-01-01T00:00:01Z",
|
|
}),
|
|
].join("\n");
|
|
|
|
const meta = extractSessionMetadata(content);
|
|
expect(meta.firstPrompt).toBe("What is the project structure?");
|
|
});
|
|
|
|
it("firstPrompt truncated to 200 chars", () => {
|
|
const longMessage = "a".repeat(300);
|
|
const content = JSON.stringify({
|
|
type: "user",
|
|
message: { role: "user", content: longMessage },
|
|
uuid: "u-long",
|
|
timestamp: "2025-01-01T00:00:00Z",
|
|
});
|
|
|
|
const meta = extractSessionMetadata(content);
|
|
expect(meta.firstPrompt).toHaveLength(200);
|
|
expect(meta.firstPrompt).toBe("a".repeat(200));
|
|
});
|
|
|
|
it("summary captures the LAST summary line", () => {
|
|
const content = [
|
|
JSON.stringify({ type: "summary", summary: "First summary", uuid: "s-1" }),
|
|
JSON.stringify({
|
|
type: "user",
|
|
message: { role: "user", content: "Hello" },
|
|
uuid: "u-1",
|
|
}),
|
|
JSON.stringify({ type: "summary", summary: "Last summary", uuid: "s-2" }),
|
|
].join("\n");
|
|
|
|
const meta = extractSessionMetadata(content);
|
|
expect(meta.summary).toBe("Last summary");
|
|
});
|
|
|
|
it("timestamps captured from first and last lines with timestamps", () => {
|
|
const content = [
|
|
JSON.stringify({
|
|
type: "user",
|
|
message: { role: "user", content: "Hello" },
|
|
uuid: "u-1",
|
|
timestamp: "2025-01-01T10:00:00Z",
|
|
}),
|
|
JSON.stringify({
|
|
type: "assistant",
|
|
message: { role: "assistant", content: "Hi" },
|
|
uuid: "a-1",
|
|
timestamp: "2025-01-01T10:05:00Z",
|
|
}),
|
|
JSON.stringify({
|
|
type: "summary",
|
|
summary: "Session done",
|
|
uuid: "s-1",
|
|
}),
|
|
].join("\n");
|
|
|
|
const meta = extractSessionMetadata(content);
|
|
expect(meta.firstTimestamp).toBe("2025-01-01T10:00:00Z");
|
|
expect(meta.lastTimestamp).toBe("2025-01-01T10:05:00Z");
|
|
});
|
|
|
|
it("empty content returns zero counts and empty strings", () => {
|
|
const meta = extractSessionMetadata("");
|
|
expect(meta.messageCount).toBe(0);
|
|
expect(meta.firstPrompt).toBe("");
|
|
expect(meta.summary).toBe("");
|
|
expect(meta.firstTimestamp).toBe("");
|
|
expect(meta.lastTimestamp).toBe("");
|
|
expect(meta.parseErrors).toBe(0);
|
|
});
|
|
|
|
it("JSONL with no user messages returns empty firstPrompt", () => {
|
|
const content = [
|
|
JSON.stringify({ type: "summary", summary: "No user", uuid: "s-1" }),
|
|
JSON.stringify({ type: "progress", data: { type: "hook" }, uuid: "p-1" }),
|
|
].join("\n");
|
|
|
|
const meta = extractSessionMetadata(content);
|
|
expect(meta.firstPrompt).toBe("");
|
|
});
|
|
|
|
it("JSONL with all system-reminder users returns empty firstPrompt", () => {
|
|
const content = [
|
|
JSON.stringify({
|
|
type: "user",
|
|
message: { role: "user", content: "<system-reminder>r1</system-reminder>" },
|
|
uuid: "u-1",
|
|
}),
|
|
JSON.stringify({
|
|
type: "user",
|
|
message: { role: "user", content: "<system-reminder>r2</system-reminder>" },
|
|
uuid: "u-2",
|
|
}),
|
|
].join("\n");
|
|
|
|
const meta = extractSessionMetadata(content);
|
|
expect(meta.firstPrompt).toBe("");
|
|
});
|
|
|
|
it("single-line JSONL: firstTimestamp equals lastTimestamp", () => {
|
|
const content = JSON.stringify({
|
|
type: "user",
|
|
message: { role: "user", content: "solo" },
|
|
uuid: "u-solo",
|
|
timestamp: "2025-06-15T12:00:00Z",
|
|
});
|
|
|
|
const meta = extractSessionMetadata(content);
|
|
expect(meta.firstTimestamp).toBe("2025-06-15T12:00:00Z");
|
|
expect(meta.lastTimestamp).toBe("2025-06-15T12:00:00Z");
|
|
});
|
|
|
|
it("reports parseErrors from malformed lines", () => {
|
|
const content = [
|
|
"broken json",
|
|
JSON.stringify({
|
|
type: "user",
|
|
message: { role: "user", content: "ok" },
|
|
uuid: "u-1",
|
|
}),
|
|
"{truncated",
|
|
].join("\n");
|
|
|
|
const meta = extractSessionMetadata(content);
|
|
expect(meta.parseErrors).toBe(2);
|
|
expect(meta.messageCount).toBe(1);
|
|
});
|
|
|
|
it("skips array user content for firstPrompt (only captures string content)", () => {
|
|
const content = [
|
|
JSON.stringify({
|
|
type: "user",
|
|
message: {
|
|
role: "user",
|
|
content: [
|
|
{ type: "tool_result", tool_use_id: "t1", content: "result" },
|
|
],
|
|
},
|
|
uuid: "u-arr",
|
|
}),
|
|
JSON.stringify({
|
|
type: "user",
|
|
message: { role: "user", content: "Second prompt as string" },
|
|
uuid: "u-str",
|
|
}),
|
|
].join("\n");
|
|
|
|
const meta = extractSessionMetadata(content);
|
|
expect(meta.firstPrompt).toBe("Second prompt as string");
|
|
});
|
|
});
|