/** * KHAELOR * File: tests/session/store.test.ts * Description: Session log tests — serialized appends, seq monotonicity, replay, torn-line recovery, pairing safety. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { KhaelorError } from "../../src/shared/index.js"; import { EVENT_SCHEMA_VERSION } from "../../src/session/events.js"; import type { DurableEventInput } from "../../src/session/events.js"; import { SessionLog, canonicalizeJsonValue } from "../../src/session/store.js"; import { sampleDurableInputs } from "./fixtures.js"; const PROJECT_HASH = "testproject"; let dir: string; beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "khaelor-store-")); }); afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); function renameInput(title: string): DurableEventInput { return { type: "session.renamed", payload: { title } }; } async function createLog(): Promise { return SessionLog.create({ projectHash: PROJECT_HASH, sessionsDir: dir }); } describe("SessionLog.create / append", () => { it("creates one file per session under //.jsonl", async () => { const log = await createLog(); expect(log.filePath).toBe(join(dir, PROJECT_HASH, `${log.sessionId}.jsonl`)); expect(existsSync(log.filePath)).toBe(true); await log.close(); }); it("refuses to clobber an existing session file", async () => { const log = await createLog(); await expect( SessionLog.create({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId: log.sessionId }), ).rejects.toThrow(); await log.close(); }); it("assigns gapless monotonic seq starting at 1 and the schema version field", async () => { const log = await createLog(); const events = [1, 2, 3, 4, 5].map((i) => log.append(renameInput(`t${i}`))); expect(events.map((e) => e.seq)).toEqual([1, 2, 3, 4, 5]); for (const e of events) { expect(e.v).toBe(EVENT_SCHEMA_VERSION); expect(e.sessionId).toBe(log.sessionId); } await log.flush(); const lines = (await readFile(log.filePath, "utf8")).split("\n").filter(Boolean); expect(lines).toHaveLength(5); lines.forEach((line, i) => { const parsed = JSON.parse(line); expect(parsed.seq).toBe(i + 1); expect(parsed.v).toBe(EVENT_SCHEMA_VERSION); }); await log.close(); }); it("writes one complete JSON line per event (appends serialized in order)", async () => { const log = await createLog(); const inputs = sampleDurableInputs(); const appended = inputs.map((input) => log.append(input)); await log.flush(); const raw = await readFile(log.filePath, "utf8"); expect(raw.endsWith("\n")).toBe(true); const lines = raw.split("\n").filter(Boolean); expect(lines).toHaveLength(inputs.length); lines.forEach((line, i) => { expect(JSON.parse(line)).toEqual(JSON.parse(JSON.stringify(appended[i]))); }); await log.close(); }); it("canonicalizes tool.requested input (sorted keys) for byte-stable replay", async () => { const log = await createLog(); const event = log.append({ type: "tool.requested", payload: { requestId: "r1", blockIndex: 0, toolUseId: "t1", toolName: "grep", input: { zebra: 1, alpha: { nested_b: 2, nested_a: [{ y: 1, x: 2 }] } }, }, }); await log.flush(); const payload = event.payload as { input: unknown }; expect(JSON.stringify(payload.input)).toBe( '{"alpha":{"nested_a":[{"x":2,"y":1}],"nested_b":2},"zebra":1}', ); await log.close(); }); it("rejects non-durable event types", async () => { const log = await createLog(); expect(() => log.append({ type: "model.text-delta", payload: {} } as never)).toThrow( KhaelorError, ); await log.close(); }); it("append after close throws", async () => { const log = await createLog(); await log.close(); expect(() => log.append(renameInput("late"))).toThrow(/closed/); }); }); describe("SessionLog.open (replay)", () => { it("replays all events and continues seq assignment", async () => { const log = await createLog(); const inputs = sampleDurableInputs().slice(0, 10); inputs.forEach((input) => log.append(input)); await log.close(); const reopened = await SessionLog.open({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId: log.sessionId, }); expect(reopened.recovery).toBeNull(); expect(reopened.replayedEvents).toHaveLength(10); expect(reopened.replayedEvents.map((e) => e.seq)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); expect(reopened.replayedEvents.map((e) => e.type)).toEqual(inputs.map((i) => i.type)); const next = reopened.append(renameInput("after resume")); expect(next.seq).toBe(11); await reopened.close(); }); it("replayed events deep-equal the appended envelopes (round-trip through disk)", async () => { const log = await createLog(); const appended = sampleDurableInputs().map((input) => log.append(input)); await log.close(); const reopened = await SessionLog.open({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId: log.sessionId, }); expect(reopened.replayedEvents).toEqual(JSON.parse(JSON.stringify(appended))); await reopened.close(); }); it("tolerates unknown event types and unknown optional fields (version tolerance)", async () => { const log = await createLog(); log.append(renameInput("known")); await log.close(); const unknownLine = JSON.stringify({ v: 1, id: "01UNKNOWN00000000000000000", sessionId: log.sessionId, seq: 2, ts: 123, type: "future.event-type", payload: { anything: true }, someNewEnvelopeField: "tolerated", }) + "\n"; await writeFile(log.filePath, (await readFile(log.filePath, "utf8")) + unknownLine); const reopened = await SessionLog.open({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId: log.sessionId, }); expect(reopened.replayedEvents).toHaveLength(2); expect(reopened.replayedEvents[1]!.type).toBe("future.event-type" as never); await reopened.close(); }); it("fails with a typed error for a missing file", async () => { await expect( SessionLog.open({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId: "01MISSING" }), ).rejects.toMatchObject({ code: "session-log-io" }); }); }); describe("torn-last-line recovery (EVENT_MODEL.md §5.3)", () => { async function buildLog(): Promise<{ sessionId: string; filePath: string; full: Buffer }> { const log = await createLog(); log.append(renameInput("one")); log.append(renameInput("two")); log.append(renameInput("three")); await log.close(); const full = await readFile(log.filePath); return { sessionId: log.sessionId, filePath: log.filePath, full }; } it("truncating the file at EVERY byte offset of the final line still opens cleanly", async () => { const { sessionId, filePath, full } = await buildLog(); const text = full.toString("utf8"); const lineStarts = [0]; for (let i = 0; i < text.length; i++) { if (text[i] === "\n" && i < text.length - 1) lineStarts.push(i + 1); } const lastLineStart = Buffer.byteLength(text.slice(0, lineStarts[lineStarts.length - 1]!)); for (let cut = lastLineStart + 1; cut < full.length; cut++) { await writeFile(filePath, full.subarray(0, cut)); const reopened = await SessionLog.open({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId, }); expect(reopened.recovery).not.toBeNull(); expect(reopened.recovery!.truncatedTo).toBe(lastLineStart); expect(reopened.replayedEvents).toHaveLength(2); expect(reopened.seqCursor).toBe(3); // The torn bytes were preserved for diagnostics. const torn = await readFile(reopened.recovery!.tornFile); expect(torn.equals(full.subarray(lastLineStart, cut))).toBe(true); // The log itself was truncated to the last valid line. const repaired = await readFile(filePath); expect(repaired.equals(full.subarray(0, lastLineStart))).toBe(true); await reopened.close(); // Restore .torn cleanliness for next iteration (file gets overwritten anyway). } }); it("recovered log accepts new appends continuing the seq", async () => { const { sessionId, filePath, full } = await buildLog(); await writeFile(filePath, full.subarray(0, full.length - 5)); // tear the last line const reopened = await SessionLog.open({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId, }); const event = reopened.append(renameInput("recovered")); expect(event.seq).toBe(3); await reopened.close(); const lines = (await readFile(filePath, "utf8")).split("\n").filter(Boolean); expect(lines).toHaveLength(3); expect(JSON.parse(lines[2]!).payload.title).toBe("recovered"); }); it("an invalid final complete line (with newline) is also recovered as torn", async () => { const { sessionId, filePath, full } = await buildLog(); await writeFile(filePath, Buffer.concat([full, Buffer.from("{not json}\n")])); const reopened = await SessionLog.open({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId, }); expect(reopened.recovery).not.toBeNull(); expect(reopened.replayedEvents).toHaveLength(3); await reopened.close(); }); it("corruption before the final line refuses to open (no silent repair)", async () => { const { sessionId, filePath, full } = await buildLog(); const text = full.toString("utf8"); const corrupted = text.replace('"title":"two"', '"title":!!BAD!'); // breaks line 2 JSON await writeFile(filePath, corrupted); await expect( SessionLog.open({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId }), ).rejects.toMatchObject({ code: "session-log-corrupted" }); }); it("a seq gap mid-file is corruption", async () => { const { sessionId, filePath, full } = await buildLog(); const lines = full.toString("utf8").split("\n").filter(Boolean); const second = JSON.parse(lines[1]!); second.seq = 7; const rewritten = [lines[0], JSON.stringify(second), lines[2]].join("\n") + "\n"; await writeFile(filePath, rewritten); await expect( SessionLog.open({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId }), ).rejects.toMatchObject({ code: "session-log-corrupted" }); }); }); describe("pairing-safe compaction cuts (EVENT_MODEL.md §6.5.3)", () => { function compaction(fromSeq: number, toSeq: number): DurableEventInput { return { type: "context.compacted", payload: { checkpointYaml: "objective: test\n", cut: { fromSeq, toSeq }, trigger: "user-command", tokensBefore: 1000, summaryModel: "claude-haiku-4-5", }, }; } it("accepts a cut where every tool_use is closed within the cut", async () => { const log = await createLog(); log.append({ type: "user.message-created", payload: { text: "go", mentions: [] } }); // 1 log.append({ type: "tool.requested", payload: { requestId: "r1", blockIndex: 0, toolUseId: "t1", toolName: "read", input: {} }, }); // 2 log.append({ type: "tool.completed", payload: { toolUseId: "t1", modelText: "ok", durationMs: 1, ui: { kind: "read", summary: "Read" } }, }); // 3 expect(() => log.append(compaction(1, 3))).not.toThrow(); await log.close(); }); it("refuses a cut that would orphan an open tool_use", async () => { const log = await createLog(); log.append({ type: "tool.requested", payload: { requestId: "r1", blockIndex: 0, toolUseId: "t1", toolName: "bash", input: {} }, }); // 1 — never closed expect(() => log.append(compaction(1, 1))).toThrow(/orphan/); await log.close(); }); it("refuses a cut that consumes a tool_result while keeping its tool_use", async () => { const log = await createLog(); log.append({ type: "tool.requested", payload: { requestId: "r1", blockIndex: 0, toolUseId: "t1", toolName: "bash", input: {} }, }); // 1 log.append({ type: "tool.completed", payload: { toolUseId: "t1", modelText: "ok", durationMs: 1, ui: { kind: "exec", summary: "Run" } }, }); // 2 expect(() => log.append(compaction(2, 2))).toThrow(/pairing|consume/i); await log.close(); }); }); describe("canonicalizeJsonValue", () => { it("sorts object keys recursively and preserves arrays and scalars", () => { const input = { b: [3, { z: 1, a: 2 }], a: null, c: "s" }; expect(JSON.stringify(canonicalizeJsonValue(input))).toBe('{"a":null,"b":[3,{"a":2,"z":1}],"c":"s"}'); expect(canonicalizeJsonValue(5)).toBe(5); expect(canonicalizeJsonValue(null)).toBe(null); expect(canonicalizeJsonValue("x")).toBe("x"); }); });