SPB Git

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%
13.0 KB · 350 lines typescript
Raw Blame History
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