From 0b5a38f83de79fed6a0fc15200c2696cb240f46e Mon Sep 17 00:00:00 2001 From: robbond Date: Mon, 7 Sep 2026 18:36:38 +0100 Subject: [PATCH] fix(confidence-engine): persist done-for-now episode closure --- app/api/cases/update/route.js | 9 ++- docs/current-handoff.md | 1 + tests/app/api/cases-update-route.test.js | 95 ++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/app/api/cases/update/route.js b/app/api/cases/update/route.js index e4e0a3f..3eed03f 100644 --- a/app/api/cases/update/route.js +++ b/app/api/cases/update/route.js @@ -129,9 +129,16 @@ async function handleEpisodeMode(situationGraph, body) { ); } + const resolvedNodeIds = new Set(application.updatedSituationGraph.resolvedNodeIds ?? []); + resolvedNodeIds.add(body.targetNodeId); + const updatedSituationGraph = { + ...application.updatedSituationGraph, + resolvedNodeIds: [...resolvedNodeIds], + }; + return Response.json({ success: true, - updatedSituationGraph: application.updatedSituationGraph, + updatedSituationGraph, proposal: reasoning.proposal, }, { status: 200 }); } diff --git a/docs/current-handoff.md b/docs/current-handoff.md index bb4e149..d9fef3a 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -45,6 +45,7 @@ If YES, the next live experiment is one timed/costed OpenAI UI investigation mea - Terra synthesis now supplies its own output schema and unwraps `providerResult.response` before validation; deterministic synthesis coverage is 62/62 PASS and the saved isolated Terra synthesis POST returned HTTP 200. - Real Terra completed-episode reconsideration can legitimately return no additional meaningful graph change. Completed episodes now tolerate only that exact compatibility outcome; ordinary no-op updates and invalid completed-episode proposals remain rejected, without fake graph mutation or new next-question steering. - The full apply-proposal owner suite remains known-red in independent pre-existing 60B.43 tests, so the changed Done-for-now boundary was verified through exact isolated owner tests. Zero live calls occurred during closeout. Next boundary: reuse the existing investigation and click Done for now once, observing cases/update and subsequent synthesis. +- Live UI proved Done for now briefly clarified the question, then server graph replacement reopened it: the client sends `preDoneGraph`, and the server previously had no deterministic closure owner. Episode-mode update now adds the selected `targetNodeId` to `resolvedNodeIds` only after successful semantic application; semantic no-ops and meaningful mutations remain valid, while failed episodes do not resolve the target and ordinary updates are unchanged. Zero live calls occurred during implementation. Next boundary: one live Done-for-now check on the existing investigation. ## Repository checkpoint diff --git a/tests/app/api/cases-update-route.test.js b/tests/app/api/cases-update-route.test.js index b899b30..7b168d1 100644 --- a/tests/app/api/cases-update-route.test.js +++ b/tests/app/api/cases-update-route.test.js @@ -1,11 +1,45 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeGraph, makeNode } from "@/lib/graph/schema.js"; const mockUpdateCase = vi.fn(); +const mockReconsiderCompletedEpisode = vi.fn(); +const mockApplyValidatedProposal = vi.fn(); +const mockPrepareCompletedEpisode = vi.fn(); vi.mock("@/lib/graph/orchestrator.js", () => ({ updateCase: (...args) => mockUpdateCase(...args), + reconsiderCompletedEpisode: (...args) => mockReconsiderCompletedEpisode(...args), })); +vi.mock("@/lib/graph/apply-proposal.js", () => ({ + applyValidatedProposal: (...args) => mockApplyValidatedProposal(...args), +})); + +vi.mock("@/lib/graph/episode-preparation.js", () => ({ + prepareCompletedEpisode: (...args) => mockPrepareCompletedEpisode(...args), +})); + +function makeEpisodeGraph() { + return makeGraph({ + centralStatement: "Episode scenario", + currentSummary: "Episode graph", + nodes: [makeNode({ id: "target", label: "Target question", kind: "unknown", status: "unknown" })], + edges: [], + activeUnknownNodeId: "target", + resolvedNodeIds: ["already-resolved"], + }); +} + +function mockSuccessfulEpisodeApplication(graph, overrides = {}) { + mockPrepareCompletedEpisode.mockReturnValue({ turns: [{ question: "Q?", answer: "A." }] }); + mockReconsiderCompletedEpisode.mockResolvedValue({ success: true, proposal: {} }); + mockApplyValidatedProposal.mockResolvedValue({ + success: true, + updatedSituationGraph: graph, + ...overrides, + }); +} + function makeSuccessResult() { return { success: true, @@ -76,6 +110,67 @@ describe("app/api/cases/update route", () => { expect(mockUpdateCase.mock.calls[0][0]).not.toHaveProperty("modelName"); }); + it("authoritatively resolves the selected target after a successful no-op episode", async () => { + const graph = makeEpisodeGraph(); + mockSuccessfulEpisodeApplication(graph); + const { POST } = await import("@/app/api/cases/update/route.js"); + + const response = await POST(new Request("http://localhost/api/cases/update", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + episodeMode: true, + situationGraph: graph, + targetNodeId: "target", + contributions: [{ targetNodeId: "target" }], + }), + })); + + const body = await response.json(); + expect(response.status).toBe(200); + expect(body.updatedSituationGraph.resolvedNodeIds).toEqual( + expect.arrayContaining(["already-resolved", "target"]), + ); + expect(body.updatedSituationGraph.nodes).toEqual(graph.nodes); + expect(body.updatedSituationGraph.edges).toEqual(graph.edges); + }); + + it("preserves meaningful episode mutations while authoritatively resolving the target", async () => { + const graph = makeEpisodeGraph(); + const mutatedGraph = { + ...graph, + nodes: [...graph.nodes, makeNode({ id: "meaningful", label: "Meaningful change", kind: "observation", status: "known" })], + }; + mockSuccessfulEpisodeApplication(mutatedGraph); + const { POST } = await import("@/app/api/cases/update/route.js"); + + const response = await POST(new Request("http://localhost/api/cases/update", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ episodeMode: true, situationGraph: graph, targetNodeId: "target", contributions: [{ targetNodeId: "target" }] }), + })); + + const body = await response.json(); + expect(body.updatedSituationGraph.nodes).toEqual(mutatedGraph.nodes); + expect(body.updatedSituationGraph.resolvedNodeIds).toContain("target"); + }); + + it("does not return an authoritative closure when completed-episode reconsideration fails", async () => { + const graph = makeEpisodeGraph(); + mockPrepareCompletedEpisode.mockReturnValue({ turns: [{ question: "Q?", answer: "A." }] }); + mockReconsiderCompletedEpisode.mockResolvedValue({ success: false, stage: "proposal_compatibility", error: "invalid" }); + const { POST } = await import("@/app/api/cases/update/route.js"); + + const response = await POST(new Request("http://localhost/api/cases/update", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ episodeMode: true, situationGraph: graph, targetNodeId: "target", contributions: [{ targetNodeId: "target" }] }), + })); + + expect(response.status).toBe(422); + await expect(response.json()).resolves.not.toHaveProperty("updatedSituationGraph"); + }); + it("invalid JSON returns 400", async () => { const { POST } = await import("@/app/api/cases/update/route.js"); const request = {