import { beforeEach, describe, expect, it, vi } from "vitest"; // ── Fixtures ──────────────────────────────────────────────── function makePreparedEpisode(overrides = {}) { return { situationGraph: { centralStatement: "Complaint counts increased.", nodes: [ { id: "n-unknown", label: "Rate denominator", kind: "unknown", status: "unknown" }, ], edges: [], activeUnknownNodeId: "n-unknown", resolvedNodeIds: [], }, targetNodeId: "n-unknown", turns: [ { contributionId: "contrib-a", sequence: 1, question: "What denominator?", answer: "100 units" }, { contributionId: "contrib-b", sequence: 2, question: "Is it consistent?", answer: "Yes" }, ], eligibleCanonicalFindings: [ { findingId: "f-1", contributionId: "contrib-a", proposition: "Denominator is fixed at 100.", endorsement: null }, ], excludedFindingProvenance: [], ...overrides, }; } function makeProposalResponse(overrides = {}) { return { addedNodes: [], updatedNodes: [ { nodeId: "n-unknown", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "100 units", reason: "Denominator confirmed.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n-unknown"], affectedNodeIds: [], selectedQuestion: null, answerMeaning: null, structuralActionRequired: false, ...overrides, }; } const MOCK_CONFIG = { OLLAMA_MODEL: "configured" }; const TEST_EPISODE = makePreparedEpisode(); // ── Tests ─────────────────────────────────────────────────── describe("episode reasoning seam (reconsiderCompletedEpisode)", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); }); it("uses the episode-aware prompt builder, not legacy buildGraphUpdatePrompt", async () => { const buildEpisodeAwareGraphPrompt = vi.fn().mockReturnValue("EPISODE_PROMPT"); const buildGraphUpdatePrompt = vi.fn(); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposalResponse()), }; // epiParams carries provider via its own key (as in the API contract) const result = await (await import("@/lib/graph/orchestrator.js")).reconsiderCompletedEpisode( { ...TEST_EPISODE, provider }, { getModelName: () => "configured", buildEpisodePrompt: buildEpisodeAwareGraphPrompt }, ); expect(result.success).toBe(true); expect(buildEpisodeAwareGraphPrompt).toHaveBeenCalledWith({ episode: expect.any(Object) }); expect(provider.generateReconstruction).toHaveBeenCalledWith("EPISODE_PROMPT", "configured"); expect(buildGraphUpdatePrompt).not.toHaveBeenCalled(); }); it("calls provider.generateReconstruction exactly once with the built episode prompt", async () => { const buildEpisodeAwareGraphPrompt = vi.fn().mockReturnValue("EPISODE_PROMPT_V2"); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposalResponse()), }; const { reconsiderCompletedEpisode } = await import("@/lib/graph/orchestrator.js"); const result = await reconsiderCompletedEpisode( { ...TEST_EPISODE, provider }, { getModelName: () => "configured", buildEpisodePrompt: buildEpisodeAwareGraphPrompt }, ); expect(result.success).toBe(true); expect(provider.generateReconstruction.mock.calls.length).toBe(1); expect(provider.generateReconstruction).toHaveBeenCalledWith("EPISODE_PROMPT_V2", "configured"); }); it("reuses existing parser — returns parsed GraphUpdateProposal shape for valid JSON", async () => { const provider = { generateReconstruction: vi.fn().mockResolvedValue(JSON.stringify(makeProposalResponse())), }; const { reconsiderCompletedEpisode } = await import("@/lib/graph/orchestrator.js"); const result = await reconsiderCompletedEpisode( TEST_EPISODE, { getModelName: () => "configured", provider }, ); expect(result.success).toBe(true); expect(result.stage).toBe("proposal_ready"); expect(result.proposal).toMatchObject({ updatedNodes: [expect.objectContaining({ nodeId: "n-unknown" })], resolvedUnknownNodeIds: ["n-unknown"], answerMeaning: null, structuralActionRequired: false, }); }); it("preserves existing parser error behaviour for invalid provider result", async () => { const provider = { generateReconstruction: vi.fn().mockResolvedValue("{ not valid json }"), }; const { reconsiderCompletedEpisode } = await import("@/lib/graph/orchestrator.js"); const result = await reconsiderCompletedEpisode( TEST_EPISODE, { getModelName: () => "configured", provider }, ); expect(result.success).toBe(false); expect(result.stage).toBe("proposal_validation"); expect(result.error).toBe("Invalid graph update proposal"); expect(result.statusCode).toBe(502); expect(result.proposalErrors).toBeInstanceOf(Array); }); it("does not perform any graph mutation or application", async () => { const applyValidatedProposal = vi.fn(); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposalResponse()), }; const { reconsiderCompletedEpisode } = await import("@/lib/graph/orchestrator.js"); const result = await reconsiderCompletedEpisode( TEST_EPISODE, { getModelName: () => "configured", provider, applyValidatedProposal }, ); expect(result.success).toBe(true); expect(applyValidatedProposal).not.toHaveBeenCalled(); // No mutation of the input episode's graph expect(TEST_EPISODE.situationGraph.nodes.length).toBe(1); }); it("resolves configured model via assertConfig fallback when no deps model is injected", async () => { // Set minimal env vars for assertConfig to succeed in this specific test const prevBaseUrl = process.env.OLLAMA_BASE_URL; const prevModel = process.env.OLLAMA_MODEL; process.env.OLLAMA_BASE_URL = "http://localhost:11434"; process.env.OLLAMA_MODEL = "test-model"; const modelCaptured = []; const provider = { generateReconstruction: vi.fn().mockImplementation((prompt, model) => { modelCaptured.push(model); return makeProposalResponse(); }), }; // Reset modules so assertConfig reads fresh env vars await vi.resetModules(); const { reconsiderCompletedEpisode } = await import("@/lib/graph/orchestrator.js"); // Empty deps — matches route.js call: reconsiderCompletedEpisode(prepared) // The fix ensures assertConfig() provides a model instead of leaving it null const result = await reconsiderCompletedEpisode( { ...TEST_EPISODE, provider }, {}, ); expect(result.success).toBe(true); expect(modelCaptured.length).toBe(1); expect(modelCaptured[0]).not.toBeNull(); expect(typeof modelCaptured[0]).toBe("string"); // Restore env if (prevBaseUrl == null) delete process.env.OLLAMA_BASE_URL; else process.env.OLLAMA_BASE_URL = prevBaseUrl; if (prevModel == null) delete process.env.OLLAMA_MODEL; else process.env.OLLAMA_MODEL = prevModel; }); });