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/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