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%
5.7 KB · 160 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: tests/cli/resume.test.ts4 * Description: Session resume wiring — create a session file, resume it through the composition root, projection matches.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { mkdtempSync, rmSync } from "node:fs";11import { tmpdir } from "node:os";12import { join } from "node:path";13import { afterEach, beforeEach, describe, expect, it } from "vitest";14import { foldTurnState } from "../../src/agent/index.js";15import { assembleEngine, openSessionContext } from "../../src/cli/engine.js";16import { FileLogger } from "../../src/cli/logger.js";17import { loadConfig } from "../../src/config/index.js";18import { buildConversation } from "../../src/session/index.js";1920let root: string;21let cwd: string;22let sessionsDir: string;23let logger: FileLogger;2425beforeEach(() => {26  root = mkdtempSync(join(tmpdir(), "khaelor-cli-test-"));27  cwd = join(root, "project");28  sessionsDir = join(root, "sessions");29  logger = new FileLogger(join(root, "logs", "test.log"));30});3132afterEach(() => {33  rmSync(root, { recursive: true, force: true });34});3536async function testConfig(): Promise<Awaited<ReturnType<typeof loadConfig>>> {37  return loadConfig({38    cwd,39    env: { ANTHROPIC_API_KEY: "test-key-not-real" },40    userConfigDir: join(root, "no-user-config"),41  });42}4344describe("session resume wiring", () => {45  it("create → record events → resume by replay → projection matches", async () => {46    const config = await testConfig();4748    // ── first run: create a session and record a turn that dies mid-tool ──49    const first = await openSessionContext({ cwd, sessionsDir, logger });50    const firstEngine = await assembleEngine({51      config,52      cwd,53      context: first,54      logger,55      dataDir: join(root, "data"),56      userDir: join(root, "no-user-config"),57    });58    first.session.publishDurable({59      type: "user.message-created",60      payload: { text: "add retry logic to the store", mentions: [] },61    });62    first.session.publishDurable({63      type: "model.request-started",64      payload: {65        requestId: "req-1",66        model: config.model,67        purpose: "main",68        contextStats: { estimatedInputTokens: 10, sections: [] },69      },70    });71    first.session.publishDurable({72      type: "model.text-block-completed",73      payload: { requestId: "req-1", blockIndex: 0, text: "Reading the store first." },74    });75    first.session.publishDurable({76      type: "tool.requested",77      payload: {78        requestId: "req-1",79        blockIndex: 1,80        toolUseId: "toolu_dangling",81        toolName: "read",82        input: { file_path: "src/store.ts" },83      },84    });85    // Simulated crash: no terminal tool event, no clean shutdown ordering issues.86    await firstEngine.shutdown();87    const sessionId = first.sessionId;8889    // ── second run: resume = replay through the same composition root ──90    const resumed = await openSessionContext({ cwd, sessionsDir, resumeId: sessionId, logger });91    expect(resumed.resumed).toBe(true);92    expect(resumed.sessionId).toBe(sessionId);93    expect(resumed.log.replayedEvents.length).toBeGreaterThanOrEqual(5); // session.started + 4 above9495    const resumedEngine = await assembleEngine({96      config,97      cwd,98      context: resumed,99      logger,100      dataDir: join(root, "data"),101      userDir: join(root, "no-user-config"),102    });103104    const events = resumed.session.events();105    // SessionResumed recorded with the highest replayed seq.106    const resumeEvent = events.find((e) => e.type === "session.resumed");107    expect(resumeEvent).toBeDefined();108    if (resumeEvent?.type === "session.resumed") {109      expect(resumeEvent.payload.replayedSeq).toBe(resumed.log.replayedEvents.length);110      expect(resumeEvent.payload.toolNames).toContain("read");111    }112113    // Dangling tool_use closed durably at resume time (EVENT_MODEL §6.5.2).114    const cancelled = events.find((e) => e.type === "tool.cancelled");115    expect(cancelled).toBeDefined();116    if (cancelled?.type === "tool.cancelled") {117      expect(cancelled.payload.toolUseId).toBe("toolu_dangling");118      expect(cancelled.payload.reason).toBe("resume-recovery");119    }120121    // The kernel's decision state sees no pending work and stays protocol-valid.122    const state = foldTurnState(events);123    expect(state.pending).toEqual([]);124    expect(state.hasUserMessage).toBe(true);125126    // The LLM history projection replays intact: user text + assistant blocks paired.127    const conversation = buildConversation(events);128    const userTurn = conversation.find((m) => m.role === "user");129    expect(JSON.stringify(userTurn)).toContain("add retry logic to the store");130    const toolUses = conversation.flatMap((m) => m.content).filter((b) => b.type === "tool_use");131    const toolResults = conversation132      .flatMap((m) => m.content)133      .filter((b) => b.type === "tool_result");134    expect(toolUses.length).toBe(toolResults.length); // every tool_use has its tool_result135136    await resumedEngine.shutdown();137  });138139  it("fresh sessions record SessionStarted with the project hash", async () => {140    const config = await testConfig();141    const context = await openSessionContext({ cwd, sessionsDir, logger });142    const engine = await assembleEngine({143      config,144      cwd,145      context,146      logger,147      dataDir: join(root, "data"),148      userDir: join(root, "no-user-config"),149    });150    const started = context.session.events().find((e) => e.type === "session.started");151    expect(started).toBeDefined();152    if (started?.type === "session.started") {153      expect(started.payload.projectHash).toBe(context.hash);154      expect(started.payload.workingDirectory).toBe(cwd);155      expect(started.payload.model).toBe(config.model);156    }157    await engine.shutdown();158  });159});160