diff --git a/docs/current-handoff.md b/docs/current-handoff.md index 8ab76d4..f4370a5 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -385,6 +385,14 @@ RAW USER EVIDENCE - Current Understanding and Investigation Report are architecturally distinct artefacts. Report interpretations remain explicit, not evidence. - The `≤5` processing bound was experimental apparatus constraint, NOT product requirement. +## Investigation overview synthesis — provider envelope defect confirmed + +- `synthesizeInvestigationOverview()` previously passed the full provider envelope (`{ response: { understanding, plausibleInterpretations }, providerApiPath, providerExecution }`) directly into `validateOverviewResponse()`, which expected only `{ understanding, plausibleInterpretations }`. This caused "Missing or invalid required fields" on real providers. +- Fix now unwraps `.response` before overview validation, matching the proven pattern in `lib/graph/current-understanding-synthesis.js` (line 296: `validateSynthesisResponse(providerResult.response)`). +- Owning regression (`tests/graph/investigation-overview-synthesis.test.js`) now mirrors real provider envelope shape (`{ response: { understanding, plausibleInterpretations }, providerApiPath }`). Regression fails against pre-fix seam and passes after fix. +- No live proof yet. Zero live calls made in this handoff. +- Next boundary: one bounded live product-launch overview Call A before resuming A/B epistemic input control comparison. + ## HISTORICAL — initial-decomposition v0.61 status Initial decomposition experiments (v0.61) ran four successful runs plus one validation failure against the same manufacturing scenario. Key findings: diff --git a/lib/graph/investigation-overview-synthesis.js b/lib/graph/investigation-overview-synthesis.js index 22ac144..c597601 100644 --- a/lib/graph/investigation-overview-synthesis.js +++ b/lib/graph/investigation-overview-synthesis.js @@ -240,9 +240,9 @@ export async function synthesizeInvestigationOverview(params, deps) { // Build overview-specific prompt (keeps evidence/interpretation separate) const prompt = buildOverviewSynthesisPrompt(situationGraph, findings, plausibleInterpretations); - let rawResponse; + let providerResult; try { - rawResponse = await provider.generateReconstruction( + providerResult = await provider.generateReconstruction( prompt, modelName, ); @@ -250,8 +250,8 @@ export async function synthesizeInvestigationOverview(params, deps) { throw new Error(`Overview synthesis provider call failed: ${err.message}`); } - // Validate against overview-specific contract - const validated = validateOverviewResponse(rawResponse); + // Validate against overview-specific contract — unwrap provider envelope + const validated = validateOverviewResponse(providerResult.response); if (!validated.valid) { throw new Error(`Overview synthesis validation failed: ${validated.reason}`); } diff --git a/tests/graph/investigation-overview-synthesis.test.js b/tests/graph/investigation-overview-synthesis.test.js index a34f912..033c2c2 100644 --- a/tests/graph/investigation-overview-synthesis.test.js +++ b/tests/graph/investigation-overview-synthesis.test.js @@ -39,10 +39,13 @@ const fakeProvider = (defaultResponse) => ({ generateReconstruction: vi.fn(() => { return typeof defaultResponse === "function" ? defaultResponse() - : JSON.stringify({ - understanding: "Synthesized understanding from evidence.", - plausibleInterpretations: "Remaining plausible explanations.", - }); + : { + response: { + understanding: "Synthesized understanding from evidence.", + plausibleInterpretations: "Remaining plausible explanations.", + }, + providerApiPath: "/api/chat", + }; }), }); @@ -459,4 +462,61 @@ describe("synthesizeInvestigationOverview — full seam", () => { expect(eligible.map((f) => f.id)).toEqual(["f1", "f2"]); expect(eligible).toHaveLength(2); }); + + // ── Regression: overview synthesis unwraps provider envelope before validation ─────────────── + + describe("overview synthesis — provider envelope unwrapping regression", () => { + it("fake provider returns real-provider envelope shape (response + providerApiPath)", async () => { + const fake = fakeProvider(); + await synthesizeInvestigationOverview( + { situationGraph: graph, findings: [], plausibleInterpretations: [] }, + { provider: fake } + ); + const callResult = fake.generateReconstruction.mock.calls[0][2]; + expect(fake.generateReconstruction).toHaveBeenCalledTimes(1); + }); + + it("valid envelope response is unwrapped and validated successfully", async () => { + const fake = fakeProvider(() => ({ + response: JSON.stringify({ + understanding: "Test understanding.", + plausibleInterpretations: "Test interpretations.", + }), + providerApiPath: "/v1/responses", + })); + const result = await synthesizeInvestigationOverview( + { situationGraph: graph, findings: [], plausibleInterpretations: [] }, + { provider: fake } + ); + expect(result.understanding).toBe("Test understanding."); + expect(result.plausibleInterpretations).toBe("Test interpretations."); + }); + + it("invalid response inside envelope fails validation (regression guard)", async () => { + const fake = fakeProvider(() => ({ + response: JSON.stringify({ wrongField: "value" }), + providerApiPath: "/v1/responses", + })); + await expect( + synthesizeInvestigationOverview( + { situationGraph: graph, findings: [], plausibleInterpretations: [] }, + { provider: fake } + ) + ).rejects.toThrow(/validation failed/); + }); + + it("provider returns .response as raw valid JSON string inside envelope", async () => { + const json = JSON.stringify({ + understanding: "Raw JSON test.", + plausibleInterpretations: "String payload.", + }); + const fake = fakeProvider(() => ({ response: json, providerApiPath: "/api/chat" })); + const result = await synthesizeInvestigationOverview( + { situationGraph: graph, findings: [], plausibleInterpretations: [] }, + { provider: fake } + ); + expect(result.understanding).toBe("Raw JSON test."); + expect(fake.generateReconstruction).toHaveBeenCalledTimes(1); + }); + }); });