220 lines
8.0 KiB
JavaScript
220 lines
8.0 KiB
JavaScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@/lib/llm/provider.js", () => ({
|
|
getProvider: () => ({ generateReconstruction() {} }),
|
|
getProviderModelName: () => "gpt-5.6-terra",
|
|
}));
|
|
|
|
// ── 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;
|
|
});
|
|
|
|
it("uses the central provider model resolution when episode mode supplies no model override", async () => {
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(makeProposalResponse()),
|
|
};
|
|
const { reconsiderCompletedEpisode } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await reconsiderCompletedEpisode(
|
|
{ ...TEST_EPISODE, provider },
|
|
{ buildEpisodePrompt: () => "EPISODE_PROMPT" },
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(provider.generateReconstruction).toHaveBeenCalledWith(
|
|
"EPISODE_PROMPT",
|
|
"gpt-5.6-terra",
|
|
);
|
|
});
|
|
|
|
});
|