import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { config } from "../../src/config.js"; import { LearningOrchestrator } from "../../src/learning/orchestrator.js"; import { SessionLearningStateStore } from "../../src/learning/sessionStateStore.js"; import { MemoryStore } from "../../src/memory/store.js"; import { SessionTranscriptStore } from "../../src/sessions/transcriptStore.js"; import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js"; import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; import { getRuntimeSessionContext } from "../../src/runtime/sessionContext.js"; const owner = { actorKey: "actor-a", projectKey: "personal-actor-a", sessionId: "parent-chat" }; const input = { sessionId: owner.sessionId, userMessage: "question", assistantMessage: "answer", toolCallCount: 0, requestContext: { ...owner, clientSessionId: owner.sessionId, traceId: "learning-test" }, }; const deferred = () => { let resolve!: () => void; const promise = new Promise((done) => { resolve = done; }); return { resolve, promise }; }; describe("learning session lifecycle", () => { let base: string; let originalAuditPath: string; let originalCooldown: number; let records: SessionWorkspaceStore; let state: SessionLearningStateStore; beforeEach(async () => { base = await mkdtemp(join(tmpdir(), "tjwater-learning-lifecycle-")); originalAuditPath = config.LEARNING_AUDIT_LOG_PATH; originalCooldown = config.LEARNING_GATE_TURN_COOLDOWN; config.LEARNING_AUDIT_LOG_PATH = join(base, "audit.log"); config.LEARNING_GATE_TURN_COOLDOWN = 1; records = new SessionWorkspaceStore(join(base, "workspaces")); state = new SessionLearningStateStore(join(base, "legacy-learning"), records); }); afterEach(async () => { config.LEARNING_AUDIT_LOG_PATH = originalAuditPath; config.LEARNING_GATE_TURN_COOLDOWN = originalCooldown; await rm(base, { recursive: true, force: true }); }); const orchestrator = (runtime: object) => new LearningOrchestrator( runtime as OpencodeRuntimeAdapter, new MemoryStore(join(base, "memory"), join(base, "memory-backup")), new SessionTranscriptStore(join(base, "legacy-transcripts"), records), state, ); it("releases both native helper sessions after a completed review and persists progress in chat", async () => { let nextId = 0; const removed: string[] = []; const finished = deferred(); const learning = orchestrator({ createSession: async () => ({ id: `helper-${++nextId}` }), prompt: async () => undefined, messages: async (id: string) => [{ info: { role: "assistant" }, parts: [{ type: "text", text: JSON.stringify( id === "helper-1" ? { should_review: true, confidence: 1, focus: "memory", reason: "useful" } : { memories: [], skills: [], summary: "reviewed" }, ) }] }], removeSession: async (id: string) => { removed.push(id); if (removed.length === 2) finished.resolve(); }, }); await learning.onTurnCompleted(input); await finished.promise; expect(removed).toEqual(["helper-2", "helper-1"]); expect(getRuntimeSessionContext("helper-1")).toBeNull(); expect(getRuntimeSessionContext("helper-2")).toBeNull(); expect((await state.read(owner)).lastReviewedTurn).toBe(1); expect(records.path(owner, "learning.json")).toContain("/personal/"); }); it("cancels a running gate without promoting memory or reviving deleted content", async () => { const started = deferred(); const interrupted = deferred(); const removed: string[] = []; const learning = orchestrator({ createSession: async () => ({ id: "cancelled-gate" }), prompt: async () => { started.resolve(); await interrupted.promise; }, messages: async () => [{ info: { role: "assistant" }, parts: [{ type: "text", text: '{"should_review":true,"confidence":1,"focus":"memory"}' }] }], abortSession: async () => { interrupted.resolve(); }, removeSession: async (id: string) => { removed.push(id); }, }); await learning.onTurnCompleted(input); await started.promise; await records.markDeleted(owner); await learning.cancelSession(owner.sessionId); await records.purge(owner); expect(removed).toEqual(["cancelled-gate"]); expect(getRuntimeSessionContext("cancelled-gate")).toBeNull(); expect(await records.list(owner, "learning.json")).toEqual([]); }); it("releases the helper when model inference fails", async () => { const finished = deferred(); const removed: string[] = []; const learning = orchestrator({ createSession: async () => ({ id: "failed-gate" }), prompt: async () => { throw new Error("model unavailable"); }, removeSession: async (id: string) => { removed.push(id); finished.resolve(); }, }); await learning.onTurnCompleted(input); await finished.promise; expect(removed).toEqual(["failed-gate"]); expect(getRuntimeSessionContext("failed-gate")).toBeNull(); }); });