/**
* Unit tests for L0 Conversation Recorder (A 同学自测).
* L0-01 ~ L0-05 + extraction edge cases.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import {
recordConversation,
readConversationRecords,
readConversationMessages,
readConversationMessagesGroupedBySessionId,
} from "./l0-recorder.js";
import type { L0MessageRecord } from "./l0-recorder.js";
let testDir: string;
const mkDir = async () => {
const d = path.join(os.tmpdir(), `l0-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
await fs.mkdir(d, { recursive: true });
return d;
};
const rmDir = async (d: string) => { try { await fs.rm(d, { recursive: true, force: true }); } catch {} };
const logger = () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() });
const msg = (role: string, content: string, ts?: number) => ({ role, content, timestamp: ts ?? Date.now() });
const today = () => {
const n = new Date();
return `${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,"0")}-${String(n.getDate()).padStart(2,"0")}`;
};
beforeEach(async () => { testDir = await mkDir(); });
afterEach(async () => { await rmDir(testDir); });
// ── recordConversation ──
describe("recordConversation", () => {
it("L0-01: writes valid JSONL lines with correct fields", async () => {
const ts = Date.now();
const result = await recordConversation({
sessionKey: "sess", sessionId: "sid",
rawMessages: [msg("user","Hello, how are you?",ts), msg("assistant","I am fine, thank you!",ts+1)],
baseDir: testDir,
});
expect(result).toHaveLength(2);
const dir = path.join(testDir,"conversations");
const files = await fs.readdir(dir);
expect(files).toHaveLength(1);
const lines = (await fs.readFile(path.join(dir,files[0]),"utf-8")).split("\n").filter(Boolean);
expect(lines).toHaveLength(2);
for (const l of lines) {
const p = JSON.parse(l) as L0MessageRecord;
expect(p.sessionKey).toBe("sess");
expect(p.sessionId).toBe("sid");
expect(typeof p.id).toBe("string");
expect(typeof p.timestamp).toBe("number");
}
});
it("L0-01: appends to same file on same day", async () => {
const ts = Date.now();
await recordConversation({ sessionKey:"s", rawMessages:[msg("user","First msg here",ts)], baseDir:testDir });
await recordConversation({ sessionKey:"s", rawMessages:[msg("user","Second msg here",ts+100)], baseDir:testDir });
const dir = path.join(testDir,"conversations");
const files = await fs.readdir(dir);
expect(files).toHaveLength(1);
const lines = (await fs.readFile(path.join(dir,files[0]),"utf-8")).split("\n").filter(Boolean);
expect(lines).toHaveLength(2);
});
it("L0-02: file named YYYY-MM-DD.jsonl matching today", async () => {
await recordConversation({ sessionKey:"s", rawMessages:[msg("user","Test msg content",Date.now())], baseDir:testDir });
const files = await fs.readdir(path.join(testDir,"conversations"));
expect(files[0]).toBe(`${today()}.jsonl`);
});
it("L0-02: sessionKey in line data, not filename", async () => {
await recordConversation({ sessionKey:"special-key", rawMessages:[msg("user","Test content msg",Date.now())], baseDir:testDir });
const files = await fs.readdir(path.join(testDir,"conversations"));
expect(files[0]).not.toContain("special-key");
const p = JSON.parse((await fs.readFile(path.join(testDir,"conversations",files[0]),"utf-8")).trim());
expect(p.sessionKey).toBe("special-key");
});
it("L0-04: afterTimestamp filters old messages", async () => {
const base = 1000000;
const r = await recordConversation({
sessionKey:"s", baseDir:testDir, afterTimestamp: base+50,
rawMessages: [
msg("user","Old msg skip here",base), msg("assistant","Old reply skip",base+1),
msg("user","New msg keep here",base+100), msg("assistant","New reply keep",base+101),
],
});
expect(r).toHaveLength(2);
expect(r[0].content).toBe("New msg keep here");
});
it("L0-04: afterTimestamp=0 records all", async () => {
const r = await recordConversation({
sessionKey:"s", baseDir:testDir, afterTimestamp:0,
rawMessages: [msg("user","Message one here",100), msg("assistant","Reply one here",200)],
});
expect(r).toHaveLength(2);
});
it("L0-04: returns empty when all older than cursor", async () => {
const r = await recordConversation({
sessionKey:"s", baseDir:testDir, afterTimestamp:999999,
rawMessages: [msg("user","Very old message",100)],
});
expect(r).toHaveLength(0);
});
it("L0-05: auto-creates nested directory", async () => {
const nested = path.join(testDir,"deep","nested");
await recordConversation({ sessionKey:"s", rawMessages:[msg("user","Deep dir test msg",Date.now())], baseDir:nested });
const stat = await fs.stat(path.join(nested,"conversations"));
expect(stat.isDirectory()).toBe(true);
});
it("ignores non-user/assistant roles", async () => {
const r = await recordConversation({
sessionKey:"s", baseDir:testDir,
rawMessages: [msg("system","System text"), msg("user","User text here"), msg("tool","Tool out"), msg("assistant","Asst reply here")],
});
expect(r).toHaveLength(2);
expect(r[0].role).toBe("user");
expect(r[1].role).toBe("assistant");
});
it("handles multi-part content array", async () => {
const r = await recordConversation({
sessionKey:"s", baseDir:testDir,
rawMessages: [{ role:"user", content:[{type:"text",text:"Hello "},{type:"text",text:"world!"}], timestamp:Date.now() }],
});
expect(r).toHaveLength(1);
expect(r[0].content).toBe("Hello \nworld!");
});
it("skips null/undefined/non-object messages", async () => {
const r = await recordConversation({
sessionKey:"s", baseDir:testDir,
rawMessages: [null, undefined, "str", 42, msg("user","Valid msg content here")] as unknown[],
});
expect(r).toHaveLength(1);
});
it("L0 now captures short messages (strict filtering deferred to L1)", async () => {
const r = await recordConversation({
sessionKey:"s", baseDir:testDir,
rawMessages: [msg("user","hi"), msg("user","好的"), msg("user","This is a normal message for testing")],
});
// L0 is permissive — all 3 messages pass (short messages are no longer filtered at L0 stage)
expect(r).toHaveLength(3);
});
it("returns empty for empty rawMessages", async () => {
expect(await recordConversation({ sessionKey:"s", rawMessages:[], baseDir:testDir })).toHaveLength(0);
});
it("replaces polluted user message with originalUserText", async () => {
const ts = Date.now();
const r = await recordConversation({
sessionKey:"s", baseDir:testDir,
rawMessages: [msg("user","x polluted text here",ts), msg("assistant","Response text here",ts+1)],
originalUserText: "Clean original text content",
originalUserMessageCount: 0,
});
expect(r[0].content).toBe("Clean original text content");
});
it("warns when originalUserText without messageCount", async () => {
const log = logger();
await recordConversation({
sessionKey:"s", baseDir:testDir, logger:log,
rawMessages: [msg("user","User msg that may be polluted",Date.now())],
originalUserText: "Clean text",
});
expect(log.warn).toHaveBeenCalled();
});
it("strips injected memory tags via sanitize", async () => {
const r = await recordConversation({
sessionKey:"s", baseDir:testDir,
rawMessages: [msg("user","old Actual text here for test",Date.now())],
});
expect(r[0].content).not.toContain("relevant-memories");
expect(r[0].content).toContain("Actual text here for test");
});
it("returns messages even when file write fails", async () => {
const log = logger();
const dir = path.join(testDir,"conversations");
await fs.mkdir(dir, { recursive: true });
await fs.mkdir(path.join(dir, `${today()}.jsonl`), { recursive: true });
const r = await recordConversation({
sessionKey:"s", baseDir:testDir, logger:log,
rawMessages: [msg("user","Msg returned despite error",Date.now())],
});
expect(r).toHaveLength(1);
expect(log.error).toHaveBeenCalled();
});
});
// ── readConversationRecords ──
describe("readConversationRecords", () => {
it("returns empty when dir does not exist", async () => {
expect(await readConversationRecords("s", testDir)).toEqual([]);
});
it("returns empty when dir is empty", async () => {
await fs.mkdir(path.join(testDir,"conversations"), { recursive: true });
expect(await readConversationRecords("s", testDir)).toEqual([]);
});
it("filters by sessionKey", async () => {
const ts = Date.now();
await recordConversation({ sessionKey:"A", rawMessages:[msg("user","Msg from session A",ts)], baseDir:testDir });
await recordConversation({ sessionKey:"B", rawMessages:[msg("user","Msg from session B",ts+1)], baseDir:testDir });
const rA = await readConversationRecords("A", testDir);
expect(rA).toHaveLength(1);
expect(rA[0].sessionKey).toBe("A");
expect(await readConversationRecords("C", testDir)).toEqual([]);
});
it("skips malformed lines", async () => {
const log = logger();
const dir = path.join(testDir,"conversations");
await fs.mkdir(dir, { recursive: true });
const valid = { sessionKey:"s", sessionId:"", recordedAt:new Date().toISOString(), id:"m1", role:"user", content:"Valid content msg", timestamp:Date.now() };
await fs.writeFile(path.join(dir,`${today()}.jsonl`), [JSON.stringify(valid), "bad json", JSON.stringify({...valid,id:"m2",content:"Second valid msg"})].join("\n")+"\n");
const r = await readConversationRecords("s", testDir, log);
expect(r).toHaveLength(2);
expect(log.warn).toHaveBeenCalled();
});
it("sorts by recordedAt", async () => {
const dir = path.join(testDir,"conversations");
await fs.mkdir(dir, { recursive: true });
const rows = [
{ sessionKey:"s", sessionId:"", recordedAt:"2026-03-17T12:00:00Z", id:"m3", role:"user", content:"Third msg", timestamp:3000 },
{ sessionKey:"s", sessionId:"", recordedAt:"2026-03-17T10:00:00Z", id:"m1", role:"user", content:"First msg", timestamp:1000 },
];
await fs.writeFile(path.join(dir,`${today()}.jsonl`), rows.map(r=>JSON.stringify(r)).join("\n")+"\n");
const result = await readConversationRecords("s", testDir);
expect(result[0].messages[0].content).toBe("First msg");
expect(result[1].messages[0].content).toBe("Third msg");
});
it("ignores non-date-pattern filenames", async () => {
const dir = path.join(testDir,"conversations");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir,"random.jsonl"), JSON.stringify({ sessionKey:"s", role:"user", content:"Ignored content", timestamp:1 })+"\n");
expect(await readConversationRecords("s", testDir)).toEqual([]);
});
});
// ── readConversationMessages ──
describe("readConversationMessages", () => {
it("returns all messages for session", async () => {
const ts = Date.now();
await recordConversation({ sessionKey:"s", rawMessages:[msg("user","Hello user one",ts), msg("assistant","Hello asst one",ts+1)], baseDir:testDir });
expect(await readConversationMessages("s", testDir)).toHaveLength(2);
});
it("filters by afterTimestamp", async () => {
const dir = path.join(testDir,"conversations");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir,`${today()}.jsonl`), [
JSON.stringify({ sessionKey:"s", sessionId:"", recordedAt:"2026-03-17T10:00:00Z", id:"m1", role:"user", content:"Old msg here", timestamp:100 }),
JSON.stringify({ sessionKey:"s", sessionId:"", recordedAt:"2026-03-17T11:00:00Z", id:"m2", role:"user", content:"New msg here", timestamp:200 }),
].join("\n")+"\n");
const msgs = await readConversationMessages("s", testDir, 150);
expect(msgs).toHaveLength(1);
expect(msgs[0].content).toBe("New msg here");
});
it("returns empty for non-existent dir", async () => {
expect(await readConversationMessages("s", testDir)).toEqual([]);
});
it("limit truncates to newest N messages, preserving chronological order", async () => {
const dir = path.join(testDir, "conversations");
await fs.mkdir(dir, { recursive: true });
// Write 10 messages with timestamps 1..10
const lines = Array.from({ length: 10 }, (_, i) =>
JSON.stringify({ sessionKey: "s", sessionId: "", recordedAt: `2026-03-17T10:0${i}:00Z`, id: `m${i + 1}`, role: "user", content: `Message number ${i + 1}`, timestamp: (i + 1) * 100 }),
);
await fs.writeFile(path.join(dir, `${today()}.jsonl`), lines.join("\n") + "\n");
const msgs = await readConversationMessages("s", testDir, undefined, undefined, 3);
expect(msgs).toHaveLength(3);
// Should be the 3 newest: timestamps 800, 900, 1000 in chronological order
expect(msgs[0].content).toBe("Message number 8");
expect(msgs[1].content).toBe("Message number 9");
expect(msgs[2].content).toBe("Message number 10");
// Verify chronological order (old → new)
expect(msgs[0].timestamp).toBeLessThan(msgs[1].timestamp);
expect(msgs[1].timestamp).toBeLessThan(msgs[2].timestamp);
});
it("limit larger than total messages returns all", async () => {
const dir = path.join(testDir, "conversations");
await fs.mkdir(dir, { recursive: true });
const lines = [
JSON.stringify({ sessionKey: "s", sessionId: "", recordedAt: "2026-03-17T10:00:00Z", id: "m1", role: "user", content: "Only message here", timestamp: 100 }),
];
await fs.writeFile(path.join(dir, `${today()}.jsonl`), lines.join("\n") + "\n");
const msgs = await readConversationMessages("s", testDir, undefined, undefined, 50);
expect(msgs).toHaveLength(1);
expect(msgs[0].content).toBe("Only message here");
});
it("limit undefined preserves backward-compat (returns all)", async () => {
const dir = path.join(testDir, "conversations");
await fs.mkdir(dir, { recursive: true });
const lines = Array.from({ length: 5 }, (_, i) =>
JSON.stringify({ sessionKey: "s", sessionId: "", recordedAt: `2026-03-17T10:0${i}:00Z`, id: `m${i + 1}`, role: "user", content: `Msg ${i + 1}`, timestamp: (i + 1) * 100 }),
);
await fs.writeFile(path.join(dir, `${today()}.jsonl`), lines.join("\n") + "\n");
const msgs = await readConversationMessages("s", testDir);
expect(msgs).toHaveLength(5);
});
it("limit combined with afterTimestamp truncates filtered results", async () => {
const dir = path.join(testDir, "conversations");
await fs.mkdir(dir, { recursive: true });
// 10 messages: timestamps 100..1000
const lines = Array.from({ length: 10 }, (_, i) =>
JSON.stringify({ sessionKey: "s", sessionId: "", recordedAt: `2026-03-17T10:0${i}:00Z`, id: `m${i + 1}`, role: "user", content: `Message ${i + 1}`, timestamp: (i + 1) * 100 }),
);
await fs.writeFile(path.join(dir, `${today()}.jsonl`), lines.join("\n") + "\n");
// afterTimestamp=500 → keep ts 600,700,800,900,1000 (5 msgs), then limit=2 → 900,1000
const msgs = await readConversationMessages("s", testDir, 500, undefined, 2);
expect(msgs).toHaveLength(2);
expect(msgs[0].content).toBe("Message 9");
expect(msgs[1].content).toBe("Message 10");
});
});
// ── readConversationMessagesGroupedBySessionId ──
describe("readConversationMessagesGroupedBySessionId", () => {
it("groups by sessionId, sorted by earliest timestamp", async () => {
const dir = path.join(testDir,"conversations");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir,`${today()}.jsonl`), [
JSON.stringify({ sessionKey:"sk", sessionId:"A", recordedAt:"2026-03-17T10:00:00Z", id:"m1", role:"user", content:"Session A msg one", timestamp:100 }),
JSON.stringify({ sessionKey:"sk", sessionId:"B", recordedAt:"2026-03-17T10:01:00Z", id:"m2", role:"user", content:"Session B msg one", timestamp:200 }),
JSON.stringify({ sessionKey:"sk", sessionId:"A", recordedAt:"2026-03-17T10:02:00Z", id:"m3", role:"assistant", content:"Session A reply one", timestamp:300 }),
].join("\n")+"\n");
const g = await readConversationMessagesGroupedBySessionId("sk", testDir);
expect(g).toHaveLength(2);
expect(g[0].sessionId).toBe("A");
expect(g[0].messages).toHaveLength(2);
expect(g[1].sessionId).toBe("B");
expect(g[1].messages).toHaveLength(1);
});
it("filters by afterTimestamp before grouping", async () => {
const dir = path.join(testDir,"conversations");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir,`${today()}.jsonl`), [
JSON.stringify({ sessionKey:"sk", sessionId:"A", recordedAt:"2026-03-17T10:00:00Z", id:"m1", role:"user", content:"Old A message", timestamp:100 }),
JSON.stringify({ sessionKey:"sk", sessionId:"A", recordedAt:"2026-03-17T10:05:00Z", id:"m2", role:"user", content:"New A message", timestamp:500 }),
].join("\n")+"\n");
const g = await readConversationMessagesGroupedBySessionId("sk", testDir, 200);
expect(g).toHaveLength(1);
expect(g[0].messages).toHaveLength(1);
expect(g[0].messages[0].content).toBe("New A message");
});
it("returns empty for non-existent dir", async () => {
expect(await readConversationMessagesGroupedBySessionId("s", testDir)).toEqual([]);
});
it("limit truncates to newest N messages across groups", async () => {
const dir = path.join(testDir, "conversations");
await fs.mkdir(dir, { recursive: true });
// 3 groups: A(ts100,300), B(ts200,400), C(ts500)
// Total 5 messages. limit=3 → keep ts 300,400,500 (newest 3)
const lines = [
JSON.stringify({ sessionKey: "sk", sessionId: "A", recordedAt: "2026-03-17T10:00:00Z", id: "m1", role: "user", content: "A old msg", timestamp: 100 }),
JSON.stringify({ sessionKey: "sk", sessionId: "B", recordedAt: "2026-03-17T10:01:00Z", id: "m2", role: "user", content: "B old msg", timestamp: 200 }),
JSON.stringify({ sessionKey: "sk", sessionId: "A", recordedAt: "2026-03-17T10:02:00Z", id: "m3", role: "assistant", content: "A new msg", timestamp: 300 }),
JSON.stringify({ sessionKey: "sk", sessionId: "B", recordedAt: "2026-03-17T10:03:00Z", id: "m4", role: "assistant", content: "B new msg", timestamp: 400 }),
JSON.stringify({ sessionKey: "sk", sessionId: "C", recordedAt: "2026-03-17T10:04:00Z", id: "m5", role: "user", content: "C only msg", timestamp: 500 }),
];
await fs.writeFile(path.join(dir, `${today()}.jsonl`), lines.join("\n") + "\n");
const g = await readConversationMessagesGroupedBySessionId("sk", testDir, undefined, undefined, 3);
// Should keep ts 300(A), 400(B), 500(C) → 3 groups each with 1 msg
const totalMsgs = g.reduce((sum, group) => sum + group.messages.length, 0);
expect(totalMsgs).toBe(3);
// Verify groups sorted by earliest msg timestamp
expect(g[0].sessionId).toBe("A");
expect(g[0].messages[0].content).toBe("A new msg");
expect(g[1].sessionId).toBe("B");
expect(g[1].messages[0].content).toBe("B new msg");
expect(g[2].sessionId).toBe("C");
expect(g[2].messages[0].content).toBe("C only msg");
});
it("limit drops groups that become empty after truncation", async () => {
const dir = path.join(testDir, "conversations");
await fs.mkdir(dir, { recursive: true });
// Group A: ts 100, 200 (old). Group B: ts 300, 400 (new).
// limit=2 → keep ts 300, 400 → only group B remains
const lines = [
JSON.stringify({ sessionKey: "sk", sessionId: "A", recordedAt: "2026-03-17T10:00:00Z", id: "m1", role: "user", content: "A msg one", timestamp: 100 }),
JSON.stringify({ sessionKey: "sk", sessionId: "A", recordedAt: "2026-03-17T10:01:00Z", id: "m2", role: "assistant", content: "A msg two", timestamp: 200 }),
JSON.stringify({ sessionKey: "sk", sessionId: "B", recordedAt: "2026-03-17T10:02:00Z", id: "m3", role: "user", content: "B msg one", timestamp: 300 }),
JSON.stringify({ sessionKey: "sk", sessionId: "B", recordedAt: "2026-03-17T10:03:00Z", id: "m4", role: "assistant", content: "B msg two", timestamp: 400 }),
];
await fs.writeFile(path.join(dir, `${today()}.jsonl`), lines.join("\n") + "\n");
const g = await readConversationMessagesGroupedBySessionId("sk", testDir, undefined, undefined, 2);
expect(g).toHaveLength(1);
expect(g[0].sessionId).toBe("B");
expect(g[0].messages).toHaveLength(2);
// Verify chronological order within group
expect(g[0].messages[0].timestamp).toBeLessThan(g[0].messages[1].timestamp);
});
it("limit preserves chronological order within and across groups", async () => {
const dir = path.join(testDir, "conversations");
await fs.mkdir(dir, { recursive: true });
// Interleaved: A(100), B(200), A(300), B(400), A(500), B(600)
// limit=4 → keep ts 300(A), 400(B), 500(A), 600(B)
const lines = [
JSON.stringify({ sessionKey: "sk", sessionId: "A", recordedAt: "2026-03-17T10:00:00Z", id: "m1", role: "user", content: "A first", timestamp: 100 }),
JSON.stringify({ sessionKey: "sk", sessionId: "B", recordedAt: "2026-03-17T10:01:00Z", id: "m2", role: "user", content: "B first", timestamp: 200 }),
JSON.stringify({ sessionKey: "sk", sessionId: "A", recordedAt: "2026-03-17T10:02:00Z", id: "m3", role: "assistant", content: "A second", timestamp: 300 }),
JSON.stringify({ sessionKey: "sk", sessionId: "B", recordedAt: "2026-03-17T10:03:00Z", id: "m4", role: "assistant", content: "B second", timestamp: 400 }),
JSON.stringify({ sessionKey: "sk", sessionId: "A", recordedAt: "2026-03-17T10:04:00Z", id: "m5", role: "user", content: "A third", timestamp: 500 }),
JSON.stringify({ sessionKey: "sk", sessionId: "B", recordedAt: "2026-03-17T10:05:00Z", id: "m6", role: "user", content: "B third", timestamp: 600 }),
];
await fs.writeFile(path.join(dir, `${today()}.jsonl`), lines.join("\n") + "\n");
const g = await readConversationMessagesGroupedBySessionId("sk", testDir, undefined, undefined, 4);
// Groups: A(300,500), B(400,600)
expect(g).toHaveLength(2);
// Group A first (earliest msg ts=300 < B's 400)
expect(g[0].sessionId).toBe("A");
expect(g[0].messages).toHaveLength(2);
expect(g[0].messages[0].timestamp).toBe(300);
expect(g[0].messages[1].timestamp).toBe(500);
// Group B
expect(g[1].sessionId).toBe("B");
expect(g[1].messages).toHaveLength(2);
expect(g[1].messages[0].timestamp).toBe(400);
expect(g[1].messages[1].timestamp).toBe(600);
});
it("limit undefined returns all messages (backward compat)", async () => {
const dir = path.join(testDir, "conversations");
await fs.mkdir(dir, { recursive: true });
const lines = Array.from({ length: 20 }, (_, i) =>
JSON.stringify({ sessionKey: "sk", sessionId: "X", recordedAt: `2026-03-17T10:${String(i).padStart(2, "0")}:00Z`, id: `m${i + 1}`, role: "user", content: `Msg ${i + 1}`, timestamp: (i + 1) * 100 }),
);
await fs.writeFile(path.join(dir, `${today()}.jsonl`), lines.join("\n") + "\n");
const g = await readConversationMessagesGroupedBySessionId("sk", testDir);
expect(g).toHaveLength(1);
expect(g[0].messages).toHaveLength(20);
});
it("limit combined with afterTimestamp works correctly", async () => {
const dir = path.join(testDir, "conversations");
await fs.mkdir(dir, { recursive: true });
// 6 messages: ts 100..600. afterTimestamp=300 → keep 400,500,600. limit=2 → 500,600
const lines = Array.from({ length: 6 }, (_, i) =>
JSON.stringify({ sessionKey: "sk", sessionId: "X", recordedAt: `2026-03-17T10:0${i}:00Z`, id: `m${i + 1}`, role: "user", content: `Msg ${i + 1}`, timestamp: (i + 1) * 100 }),
);
await fs.writeFile(path.join(dir, `${today()}.jsonl`), lines.join("\n") + "\n");
const g = await readConversationMessagesGroupedBySessionId("sk", testDir, 300, undefined, 2);
expect(g).toHaveLength(1);
expect(g[0].messages).toHaveLength(2);
expect(g[0].messages[0].content).toBe("Msg 5");
expect(g[0].messages[1].content).toBe("Msg 6");
});
});
// ── Edge cases: recordConversation ──
describe("recordConversation edge cases", () => {
it("handles empty string content (filters out)", async () => {
const r = await recordConversation({
sessionKey: "s", baseDir: testDir,
rawMessages: [{ role: "user", content: "", timestamp: Date.now() }],
});
expect(r).toHaveLength(0);
});
it("handles whitespace-only content (filters out)", async () => {
const r = await recordConversation({
sessionKey: "s", baseDir: testDir,
rawMessages: [{ role: "user", content: " \n ", timestamp: Date.now() }],
});
expect(r).toHaveLength(0);
});
it("assigns Date.now() when message has no timestamp", async () => {
const before = Date.now();
const r = await recordConversation({
sessionKey: "s", baseDir: testDir,
rawMessages: [{ role: "user", content: "A message without a timestamp field here" }],
});
if (r.length > 0) {
expect(r[0].timestamp).toBeGreaterThanOrEqual(before);
}
});
it("preserves existing message id if present", async () => {
const r = await recordConversation({
sessionKey: "s", baseDir: testDir,
rawMessages: [{ role: "user", content: "Message with a custom id value here", id: "custom-id-1", timestamp: Date.now() }],
});
expect(r).toHaveLength(1);
expect(r[0].id).toBe("custom-id-1");
});
it("generates id when message id is missing", async () => {
const r = await recordConversation({
sessionKey: "s", baseDir: testDir,
rawMessages: [{ role: "user", content: "Message without any id field value", timestamp: Date.now() }],
});
expect(r).toHaveLength(1);
expect(r[0].id).toMatch(/^msg_/);
});
it("originalUserMessageCount out of range → skip replacement, sanitize fallback", async () => {
const log = logger();
const r = await recordConversation({
sessionKey: "s", baseDir: testDir, logger: log,
rawMessages: [msg("user", "injected Some real text here", Date.now())],
originalUserText: "Clean text original",
originalUserMessageCount: 99, // out of range
});
// Should NOT replace (out of range), but sanitize should remove tags
expect(r).toHaveLength(1);
expect(r[0].content).not.toContain("relevant-memories");
});
it("originalUserMessageCount negative → skip replacement", async () => {
const log = logger();
await recordConversation({
sessionKey: "s", baseDir: testDir, logger: log,
rawMessages: [msg("user", "Some longer message content for testing purposes", Date.now())],
originalUserText: "Clean text",
originalUserMessageCount: -1,
});
expect(log.warn).toHaveBeenCalled();
});
it("target raw message has no timestamp → skip replacement with warning", async () => {
const log = logger();
await recordConversation({
sessionKey: "s", baseDir: testDir, logger: log,
rawMessages: [{ role: "user", content: "Message content without timestamp field here" }],
originalUserText: "Clean prompt text here",
originalUserMessageCount: 0,
});
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("no valid timestamp"));
});
it("target timestamp not found in extracted → skip replacement with warning", async () => {
const log = logger();
const ts = Date.now();
// rawMessages[0] has ts=100, but extracted will filter it (afterTimestamp=200)
await recordConversation({
sessionKey: "s", baseDir: testDir, logger: log, afterTimestamp: 200,
rawMessages: [
{ role: "user", content: "Old message that was filtered out", timestamp: 100 },
{ role: "user", content: "New message that passes the cursor filter", timestamp: 300 },
],
originalUserText: "Replacement text for testing",
originalUserMessageCount: 0, // points to old msg (ts=100), which is filtered
});
// Should warn about not finding target in extracted batch
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("not found in extracted batch"));
});
it("sessionId defaults to empty string when not provided", async () => {
await recordConversation({
sessionKey: "s", baseDir: testDir,
rawMessages: [msg("user", "Message to test sessionId default value", Date.now())],
});
const dir = path.join(testDir, "conversations");
const files = await fs.readdir(dir);
const line = JSON.parse((await fs.readFile(path.join(dir, files[0]), "utf-8")).trim());
expect(line.sessionId).toBe("");
});
it("content array with non-text parts is ignored", async () => {
const r = await recordConversation({
sessionKey: "s", baseDir: testDir,
rawMessages: [{ role: "user", content: [{ type: "image", url: "http://example.com/img.png" }, { type: "text", text: "Some text content here for test" }], timestamp: Date.now() }],
});
expect(r).toHaveLength(1);
expect(r[0].content).toBe("Some text content here for test");
});
});
// ── Edge cases: readConversationRecords ──
describe("readConversationRecords edge cases", () => {
it("handles file read error gracefully", async () => {
const log = logger();
const dir = path.join(testDir, "conversations");
await fs.mkdir(dir, { recursive: true });
// Create a directory with the same name as a date file (causes read error)
await fs.mkdir(path.join(dir, "2026-03-17.jsonl"), { recursive: true });
// readdir should include it, but readFile will fail
// However, the function uses .isFile() filter so directories are skipped
const result = await readConversationRecords("s", testDir, log);
expect(result).toEqual([]);
});
it("generates id for records missing id field", async () => {
const dir = path.join(testDir, "conversations");
await fs.mkdir(dir, { recursive: true });
const record = { sessionKey: "s", sessionId: "", recordedAt: new Date().toISOString(), role: "user", content: "No id field in this record" };
await fs.writeFile(path.join(dir, `${today()}.jsonl`), JSON.stringify(record) + "\n");
const result = await readConversationRecords("s", testDir);
expect(result).toHaveLength(1);
expect(result[0].messages[0].id).toMatch(/^msg_/);
});
});