/** * KHAELOR * File: tests/cli/resume.test.ts * Description: Session resume wiring — create a session file, resume it through the composition root, projection matches. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { foldTurnState } from "../../src/agent/index.js"; import { assembleEngine, openSessionContext } from "../../src/cli/engine.js"; import { FileLogger } from "../../src/cli/logger.js"; import { loadConfig } from "../../src/config/index.js"; import { buildConversation } from "../../src/session/index.js"; let root: string; let cwd: string; let sessionsDir: string; let logger: FileLogger; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "khaelor-cli-test-")); cwd = join(root, "project"); sessionsDir = join(root, "sessions"); logger = new FileLogger(join(root, "logs", "test.log")); }); afterEach(() => { rmSync(root, { recursive: true, force: true }); }); async function testConfig(): Promise>> { return loadConfig({ cwd, env: { ANTHROPIC_API_KEY: "test-key-not-real" }, userConfigDir: join(root, "no-user-config"), }); } describe("session resume wiring", () => { it("create → record events → resume by replay → projection matches", async () => { const config = await testConfig(); // ── first run: create a session and record a turn that dies mid-tool ── const first = await openSessionContext({ cwd, sessionsDir, logger }); const firstEngine = await assembleEngine({ config, cwd, context: first, logger, dataDir: join(root, "data"), userDir: join(root, "no-user-config"), }); first.session.publishDurable({ type: "user.message-created", payload: { text: "add retry logic to the store", mentions: [] }, }); first.session.publishDurable({ type: "model.request-started", payload: { requestId: "req-1", model: config.model, purpose: "main", contextStats: { estimatedInputTokens: 10, sections: [] }, }, }); first.session.publishDurable({ type: "model.text-block-completed", payload: { requestId: "req-1", blockIndex: 0, text: "Reading the store first." }, }); first.session.publishDurable({ type: "tool.requested", payload: { requestId: "req-1", blockIndex: 1, toolUseId: "toolu_dangling", toolName: "read", input: { file_path: "src/store.ts" }, }, }); // Simulated crash: no terminal tool event, no clean shutdown ordering issues. await firstEngine.shutdown(); const sessionId = first.sessionId; // ── second run: resume = replay through the same composition root ── const resumed = await openSessionContext({ cwd, sessionsDir, resumeId: sessionId, logger }); expect(resumed.resumed).toBe(true); expect(resumed.sessionId).toBe(sessionId); expect(resumed.log.replayedEvents.length).toBeGreaterThanOrEqual(5); // session.started + 4 above const resumedEngine = await assembleEngine({ config, cwd, context: resumed, logger, dataDir: join(root, "data"), userDir: join(root, "no-user-config"), }); const events = resumed.session.events(); // SessionResumed recorded with the highest replayed seq. const resumeEvent = events.find((e) => e.type === "session.resumed"); expect(resumeEvent).toBeDefined(); if (resumeEvent?.type === "session.resumed") { expect(resumeEvent.payload.replayedSeq).toBe(resumed.log.replayedEvents.length); expect(resumeEvent.payload.toolNames).toContain("read"); } // Dangling tool_use closed durably at resume time (EVENT_MODEL §6.5.2). const cancelled = events.find((e) => e.type === "tool.cancelled"); expect(cancelled).toBeDefined(); if (cancelled?.type === "tool.cancelled") { expect(cancelled.payload.toolUseId).toBe("toolu_dangling"); expect(cancelled.payload.reason).toBe("resume-recovery"); } // The kernel's decision state sees no pending work and stays protocol-valid. const state = foldTurnState(events); expect(state.pending).toEqual([]); expect(state.hasUserMessage).toBe(true); // The LLM history projection replays intact: user text + assistant blocks paired. const conversation = buildConversation(events); const userTurn = conversation.find((m) => m.role === "user"); expect(JSON.stringify(userTurn)).toContain("add retry logic to the store"); const toolUses = conversation.flatMap((m) => m.content).filter((b) => b.type === "tool_use"); const toolResults = conversation .flatMap((m) => m.content) .filter((b) => b.type === "tool_result"); expect(toolUses.length).toBe(toolResults.length); // every tool_use has its tool_result await resumedEngine.shutdown(); }); it("fresh sessions record SessionStarted with the project hash", async () => { const config = await testConfig(); const context = await openSessionContext({ cwd, sessionsDir, logger }); const engine = await assembleEngine({ config, cwd, context, logger, dataDir: join(root, "data"), userDir: join(root, "no-user-config"), }); const started = context.session.events().find((e) => e.type === "session.started"); expect(started).toBeDefined(); if (started?.type === "session.started") { expect(started.payload.projectHash).toBe(context.hash); expect(started.payload.workingDirectory).toBe(cwd); expect(started.payload.model).toBe(config.model); } await engine.shutdown(); }); });