spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: tests/session/store.test.ts4 * Description: Session log tests — serialized appends, seq monotonicity, replay, torn-line recovery, pairing safety.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";11import { existsSync } from "node:fs";12import { tmpdir } from "node:os";13import { join } from "node:path";14import { afterEach, beforeEach, describe, expect, it } from "vitest";15import { KhaelorError } from "../../src/shared/index.js";16import { EVENT_SCHEMA_VERSION } from "../../src/session/events.js";17import type { DurableEventInput } from "../../src/session/events.js";18import { SessionLog, canonicalizeJsonValue } from "../../src/session/store.js";19import { sampleDurableInputs } from "./fixtures.js";2021const PROJECT_HASH = "testproject";2223let dir: string;2425beforeEach(async () => {26 dir = await mkdtemp(join(tmpdir(), "khaelor-store-"));27});2829afterEach(async () => {30 await rm(dir, { recursive: true, force: true });31});3233function renameInput(title: string): DurableEventInput {34 return { type: "session.renamed", payload: { title } };35}3637async function createLog(): Promise<SessionLog> {38 return SessionLog.create({ projectHash: PROJECT_HASH, sessionsDir: dir });39}4041describe("SessionLog.create / append", () => {42 it("creates one file per session under <sessionsDir>/<project-hash>/<session-id>.jsonl", async () => {43 const log = await createLog();44 expect(log.filePath).toBe(join(dir, PROJECT_HASH, `${log.sessionId}.jsonl`));45 expect(existsSync(log.filePath)).toBe(true);46 await log.close();47 });4849 it("refuses to clobber an existing session file", async () => {50 const log = await createLog();51 await expect(52 SessionLog.create({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId: log.sessionId }),53 ).rejects.toThrow();54 await log.close();55 });5657 it("assigns gapless monotonic seq starting at 1 and the schema version field", async () => {58 const log = await createLog();59 const events = [1, 2, 3, 4, 5].map((i) => log.append(renameInput(`t${i}`)));60 expect(events.map((e) => e.seq)).toEqual([1, 2, 3, 4, 5]);61 for (const e of events) {62 expect(e.v).toBe(EVENT_SCHEMA_VERSION);63 expect(e.sessionId).toBe(log.sessionId);64 }65 await log.flush();66 const lines = (await readFile(log.filePath, "utf8")).split("\n").filter(Boolean);67 expect(lines).toHaveLength(5);68 lines.forEach((line, i) => {69 const parsed = JSON.parse(line);70 expect(parsed.seq).toBe(i + 1);71 expect(parsed.v).toBe(EVENT_SCHEMA_VERSION);72 });73 await log.close();74 });7576 it("writes one complete JSON line per event (appends serialized in order)", async () => {77 const log = await createLog();78 const inputs = sampleDurableInputs();79 const appended = inputs.map((input) => log.append(input));80 await log.flush();81 const raw = await readFile(log.filePath, "utf8");82 expect(raw.endsWith("\n")).toBe(true);83 const lines = raw.split("\n").filter(Boolean);84 expect(lines).toHaveLength(inputs.length);85 lines.forEach((line, i) => {86 expect(JSON.parse(line)).toEqual(JSON.parse(JSON.stringify(appended[i])));87 });88 await log.close();89 });9091 it("canonicalizes tool.requested input (sorted keys) for byte-stable replay", async () => {92 const log = await createLog();93 const event = log.append({94 type: "tool.requested",95 payload: {96 requestId: "r1",97 blockIndex: 0,98 toolUseId: "t1",99 toolName: "grep",100 input: { zebra: 1, alpha: { nested_b: 2, nested_a: [{ y: 1, x: 2 }] } },101 },102 });103 await log.flush();104 const payload = event.payload as { input: unknown };105 expect(JSON.stringify(payload.input)).toBe(106 '{"alpha":{"nested_a":[{"x":2,"y":1}],"nested_b":2},"zebra":1}',107 );108 await log.close();109 });110111 it("rejects non-durable event types", async () => {112 const log = await createLog();113 expect(() => log.append({ type: "model.text-delta", payload: {} } as never)).toThrow(114 KhaelorError,115 );116 await log.close();117 });118119 it("append after close throws", async () => {120 const log = await createLog();121 await log.close();122 expect(() => log.append(renameInput("late"))).toThrow(/closed/);123 });124});125126describe("SessionLog.open (replay)", () => {127 it("replays all events and continues seq assignment", async () => {128 const log = await createLog();129 const inputs = sampleDurableInputs().slice(0, 10);130 inputs.forEach((input) => log.append(input));131 await log.close();132133 const reopened = await SessionLog.open({134 projectHash: PROJECT_HASH,135 sessionsDir: dir,136 sessionId: log.sessionId,137 });138 expect(reopened.recovery).toBeNull();139 expect(reopened.replayedEvents).toHaveLength(10);140 expect(reopened.replayedEvents.map((e) => e.seq)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);141 expect(reopened.replayedEvents.map((e) => e.type)).toEqual(inputs.map((i) => i.type));142 const next = reopened.append(renameInput("after resume"));143 expect(next.seq).toBe(11);144 await reopened.close();145 });146147 it("replayed events deep-equal the appended envelopes (round-trip through disk)", async () => {148 const log = await createLog();149 const appended = sampleDurableInputs().map((input) => log.append(input));150 await log.close();151 const reopened = await SessionLog.open({152 projectHash: PROJECT_HASH,153 sessionsDir: dir,154 sessionId: log.sessionId,155 });156 expect(reopened.replayedEvents).toEqual(JSON.parse(JSON.stringify(appended)));157 await reopened.close();158 });159160 it("tolerates unknown event types and unknown optional fields (version tolerance)", async () => {161 const log = await createLog();162 log.append(renameInput("known"));163 await log.close();164 const unknownLine =165 JSON.stringify({166 v: 1,167 id: "01UNKNOWN00000000000000000",168 sessionId: log.sessionId,169 seq: 2,170 ts: 123,171 type: "future.event-type",172 payload: { anything: true },173 someNewEnvelopeField: "tolerated",174 }) + "\n";175 await writeFile(log.filePath, (await readFile(log.filePath, "utf8")) + unknownLine);176 const reopened = await SessionLog.open({177 projectHash: PROJECT_HASH,178 sessionsDir: dir,179 sessionId: log.sessionId,180 });181 expect(reopened.replayedEvents).toHaveLength(2);182 expect(reopened.replayedEvents[1]!.type).toBe("future.event-type" as never);183 await reopened.close();184 });185186 it("fails with a typed error for a missing file", async () => {187 await expect(188 SessionLog.open({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId: "01MISSING" }),189 ).rejects.toMatchObject({ code: "session-log-io" });190 });191});192193describe("torn-last-line recovery (EVENT_MODEL.md §5.3)", () => {194 async function buildLog(): Promise<{ sessionId: string; filePath: string; full: Buffer }> {195 const log = await createLog();196 log.append(renameInput("one"));197 log.append(renameInput("two"));198 log.append(renameInput("three"));199 await log.close();200 const full = await readFile(log.filePath);201 return { sessionId: log.sessionId, filePath: log.filePath, full };202 }203204 it("truncating the file at EVERY byte offset of the final line still opens cleanly", async () => {205 const { sessionId, filePath, full } = await buildLog();206 const text = full.toString("utf8");207 const lineStarts = [0];208 for (let i = 0; i < text.length; i++) {209 if (text[i] === "\n" && i < text.length - 1) lineStarts.push(i + 1);210 }211 const lastLineStart = Buffer.byteLength(text.slice(0, lineStarts[lineStarts.length - 1]!));212213 for (let cut = lastLineStart + 1; cut < full.length; cut++) {214 await writeFile(filePath, full.subarray(0, cut));215 const reopened = await SessionLog.open({216 projectHash: PROJECT_HASH,217 sessionsDir: dir,218 sessionId,219 });220 expect(reopened.recovery).not.toBeNull();221 expect(reopened.recovery!.truncatedTo).toBe(lastLineStart);222 expect(reopened.replayedEvents).toHaveLength(2);223 expect(reopened.seqCursor).toBe(3);224 // The torn bytes were preserved for diagnostics.225 const torn = await readFile(reopened.recovery!.tornFile);226 expect(torn.equals(full.subarray(lastLineStart, cut))).toBe(true);227 // The log itself was truncated to the last valid line.228 const repaired = await readFile(filePath);229 expect(repaired.equals(full.subarray(0, lastLineStart))).toBe(true);230 await reopened.close();231 // Restore .torn cleanliness for next iteration (file gets overwritten anyway).232 }233 });234235 it("recovered log accepts new appends continuing the seq", async () => {236 const { sessionId, filePath, full } = await buildLog();237 await writeFile(filePath, full.subarray(0, full.length - 5)); // tear the last line238 const reopened = await SessionLog.open({239 projectHash: PROJECT_HASH,240 sessionsDir: dir,241 sessionId,242 });243 const event = reopened.append(renameInput("recovered"));244 expect(event.seq).toBe(3);245 await reopened.close();246 const lines = (await readFile(filePath, "utf8")).split("\n").filter(Boolean);247 expect(lines).toHaveLength(3);248 expect(JSON.parse(lines[2]!).payload.title).toBe("recovered");249 });250251 it("an invalid final complete line (with newline) is also recovered as torn", async () => {252 const { sessionId, filePath, full } = await buildLog();253 await writeFile(filePath, Buffer.concat([full, Buffer.from("{not json}\n")]));254 const reopened = await SessionLog.open({255 projectHash: PROJECT_HASH,256 sessionsDir: dir,257 sessionId,258 });259 expect(reopened.recovery).not.toBeNull();260 expect(reopened.replayedEvents).toHaveLength(3);261 await reopened.close();262 });263264 it("corruption before the final line refuses to open (no silent repair)", async () => {265 const { sessionId, filePath, full } = await buildLog();266 const text = full.toString("utf8");267 const corrupted = text.replace('"title":"two"', '"title":!!BAD!'); // breaks line 2 JSON268 await writeFile(filePath, corrupted);269 await expect(270 SessionLog.open({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId }),271 ).rejects.toMatchObject({ code: "session-log-corrupted" });272 });273274 it("a seq gap mid-file is corruption", async () => {275 const { sessionId, filePath, full } = await buildLog();276 const lines = full.toString("utf8").split("\n").filter(Boolean);277 const second = JSON.parse(lines[1]!);278 second.seq = 7;279 const rewritten = [lines[0], JSON.stringify(second), lines[2]].join("\n") + "\n";280 await writeFile(filePath, rewritten);281 await expect(282 SessionLog.open({ projectHash: PROJECT_HASH, sessionsDir: dir, sessionId }),283 ).rejects.toMatchObject({ code: "session-log-corrupted" });284 });285});286287describe("pairing-safe compaction cuts (EVENT_MODEL.md §6.5.3)", () => {288 function compaction(fromSeq: number, toSeq: number): DurableEventInput {289 return {290 type: "context.compacted",291 payload: {292 checkpointYaml: "objective: test\n",293 cut: { fromSeq, toSeq },294 trigger: "user-command",295 tokensBefore: 1000,296 summaryModel: "claude-haiku-4-5",297 },298 };299 }300301 it("accepts a cut where every tool_use is closed within the cut", async () => {302 const log = await createLog();303 log.append({ type: "user.message-created", payload: { text: "go", mentions: [] } }); // 1304 log.append({305 type: "tool.requested",306 payload: { requestId: "r1", blockIndex: 0, toolUseId: "t1", toolName: "read", input: {} },307 }); // 2308 log.append({309 type: "tool.completed",310 payload: { toolUseId: "t1", modelText: "ok", durationMs: 1, ui: { kind: "read", summary: "Read" } },311 }); // 3312 expect(() => log.append(compaction(1, 3))).not.toThrow();313 await log.close();314 });315316 it("refuses a cut that would orphan an open tool_use", async () => {317 const log = await createLog();318 log.append({319 type: "tool.requested",320 payload: { requestId: "r1", blockIndex: 0, toolUseId: "t1", toolName: "bash", input: {} },321 }); // 1 — never closed322 expect(() => log.append(compaction(1, 1))).toThrow(/orphan/);323 await log.close();324 });325326 it("refuses a cut that consumes a tool_result while keeping its tool_use", async () => {327 const log = await createLog();328 log.append({329 type: "tool.requested",330 payload: { requestId: "r1", blockIndex: 0, toolUseId: "t1", toolName: "bash", input: {} },331 }); // 1332 log.append({333 type: "tool.completed",334 payload: { toolUseId: "t1", modelText: "ok", durationMs: 1, ui: { kind: "exec", summary: "Run" } },335 }); // 2336 expect(() => log.append(compaction(2, 2))).toThrow(/pairing|consume/i);337 await log.close();338 });339});340341describe("canonicalizeJsonValue", () => {342 it("sorts object keys recursively and preserves arrays and scalars", () => {343 const input = { b: [3, { z: 1, a: 2 }], a: null, c: "s" };344 expect(JSON.stringify(canonicalizeJsonValue(input))).toBe('{"a":null,"b":[3,{"a":2,"z":1}],"c":"s"}');345 expect(canonicalizeJsonValue(5)).toBe(5);346 expect(canonicalizeJsonValue(null)).toBe(null);347 expect(canonicalizeJsonValue("x")).toBe("x");348 });349});350