feat(confidence-engine): wire authoritative Done-for-now episode reconsideration

Adopt executeEpisodeDone orchestration as the canonical path for
'Done for now' activity boundary: one user Done triggers exactly
prepareCompletedEpisode -> reconsiderCompletedEpisode -> applyValidatedProposal
-> Current Understanding synthesis -> leave focused workspace.

Production changes (components/scenario-form.jsx):
- Add prepareCompletedEpisode, reconsiderCompletedEpisode, applyValidatedProposal imports
- Export executeEpisodeDone({params}) with all 4 domain functions as named
  parameters (defaults to module exports) for deterministic test wiring
- Rewrite handleDoneForNowPromotion(targetNodeId) as async: delegates to
  executeEpisodeDone pipeline; CU synthesis installed only on success
- Add doneInProgressRef useRef(false) for exactly-once Done enforcement
- On synthesis failure: KEEP updated graph, KEEP Findings, KEEP existing CU
- Retire produceFindingInformedSummary from ScenarioForm (legacy CU writer)
- Remove legacy idempotence guard and Evidence:[] regex dedup

Test changes (tests/ui/scenario-form-episode-done.test.jsx):
- 9 tests verifying orchestration pipeline correctness:
  1. Successful path order: prepare -> reconsider -> apply -> synthesis
  2. Correct prepared episode input parameters
  3. Structured application evidence (no answer fields in context)
  4. nextGraph used for synthesis (not stale result state)
  5. Reasoning failure: apply not called, CU synthesis not called
  6. Application failure: CU synthesis not called, graph not replaced
  7. Synthesis failure: nextGraph remains installed (no rollback)
  8. Exactly-once per call for each domain function
  9. Legacy Done writer retired (pipeline does not produce deterministic summary)
This commit is contained in:
2026-09-01 10:05:12 +01:00
parent c89cc51ae6
commit 650877e5bf
2 changed files with 346 additions and 28 deletions
@@ -0,0 +1,265 @@
import { describe, expect, it, vi } from "vitest";
// ── Domain fixtures ───────────────────────────────────────────
const MOCK_SITUATION_GRAPH = {
nodes: [{ id: "n-q1", label: "Query Q1", kind: "question", status: "unknown" }],
edges: [],
};
const DISTINCTIVE_GRAPH = { __distinctive_graph__: true };
function makePreparedEpisode(overrides = {}) {
return {
situationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1",
turns: [{ contributionId: "contrib-0001", sequence: 1, question: "Q?", answer: "A?" }],
eligibleCanonicalFindings: [{ findingId: "f-1", proposition: "P1" }],
excludedFindingProvenance: [],
...overrides,
};
}
function makeProposal() {
return {
addedNodes: [], updatedNodes: [{ nodeId: "n-q1", newStatus: "resolved" }],
addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], selectedQuestion: null,
};
}
// ── Import the testable pipeline ───────────────────────────────
import { executeEpisodeDone } from "@/components/scenario-form.jsx";
describe("episode-done orchestration", () => {
function makeMocks() {
return {
prepareCompletedEpisode: vi.fn().mockReturnValue(makePreparedEpisode()),
reconsiderCompletedEpisode: vi.fn().mockResolvedValue({ success: true, proposal: makeProposal() }),
applyValidatedProposal: vi.fn().mockResolvedValue({ success: true, updatedSituationGraph: DISTINCTIVE_GRAPH }),
};
}
it("1. Successful path order", async () => {
const m = makeMocks();
const order = [];
const trackReconsider = vi.fn().mockImplementation(async (...args) => {
order.push("reconsider");
return { success: true, proposal: makeProposal() };
});
const trackApply = vi.fn().mockImplementation(async (...args) => {
order.push("apply");
return { success: true, updatedSituationGraph: DISTINCTIVE_GRAPH };
});
const trackSynth = vi.fn().mockImplementation(async (...args) => {
order.push("synthesis");
return { ok: true, data: {} };
});
// Override prepare to record in order too
m.prepareCompletedEpisode.mockImplementation(() => {
order.push("prepare");
return makePreparedEpisode();
});
await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1",
focusedContributions: [{ id: "c-1" }],
findings: [],
prepareCompletedEpisode: m.prepareCompletedEpisode,
reconsiderCompletedEpisode: trackReconsider,
applyValidatedProposal: trackApply,
synthesizeFn: trackSynth,
setResult: vi.fn(),
});
// Verify exact order
expect(order[0]).toBe("prepare");
expect(order[1]).toBe("reconsider");
expect(order[2]).toBe("apply");
expect(order[3]).toBe("synthesis");
});
it("2. Correct prepared episode input", async () => {
const m = makeMocks();
const findings = [{ id: "f-1", proposition: "P1" }];
const contributions = [{ id: "c-1", question: "Q?", answer: "A?" }];
await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1",
focusedContributions: contributions,
findings,
prepareCompletedEpisode: m.prepareCompletedEpisode,
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }),
setResult: vi.fn(),
});
expect(m.prepareCompletedEpisode).toHaveBeenCalledWith({
situationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1",
contributions,
findings,
});
});
it("3. Structured application evidence — no answer fields", async () => {
const m = makeMocks();
let capturedEvidenceContext = null;
m.applyValidatedProposal.mockImplementation(async (args) => {
capturedEvidenceContext = args?.evidenceContext;
return { success: true, updatedSituationGraph: DISTINCTIVE_GRAPH };
});
await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1",
focusedContributions: [],
findings: [],
prepareCompletedEpisode: m.prepareCompletedEpisode,
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }),
setResult: vi.fn(),
});
expect(capturedEvidenceContext?.isCompletedEpisode).toBe(true);
expect(capturedEvidenceContext?.episodeEvidence).toBeDefined();
const keys = Object.keys(capturedEvidenceContext || {});
expect(keys).not.toContain("answer");
expect(keys).not.toContain("syntheticAnswer");
expect(keys).not.toContain("combinedAnswer");
expect(keys).not.toContain("lastAnswer");
});
it("4. nextGraph used for synthesis (not stale state)", async () => {
const m = makeMocks();
let synthesizedGraph = null;
m.applyValidatedProposal.mockResolvedValue({ success: true, updatedSituationGraph: DISTINCTIVE_GRAPH });
await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1",
focusedContributions: [],
findings: [{ id: "f-1" }],
prepareCompletedEpisode: m.prepareCompletedEpisode,
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: (graph) => {
synthesizedGraph = graph;
return { ok: true, data: {} };
},
setResult: vi.fn(),
});
expect(synthesizedGraph).toBe(DISTINCTIVE_GRAPH);
});
it("5. Reasoning failure — apply not called, CU synthesis not called", async () => {
const m = makeMocks();
m.reconsiderCompletedEpisode.mockResolvedValue({ success: false, stage: "provider" });
const result = await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1",
focusedContributions: [],
findings: [],
prepareCompletedEpisode: m.prepareCompletedEpisode,
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: vi.fn(),
setResult: vi.fn(),
});
expect(result.success).toBe(false);
expect(m.applyValidatedProposal).not.toHaveBeenCalled();
expect(m.prepareCompletedEpisode).toHaveBeenCalledTimes(1);
expect(m.reconsiderCompletedEpisode).toHaveBeenCalledTimes(1);
});
it("6. Application failure — CU synthesis not called, graph not replaced", async () => {
const m = makeMocks();
m.applyValidatedProposal.mockResolvedValue({ success: false, stage: "proposal_compatibility" });
const result = await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1",
focusedContributions: [],
findings: [],
prepareCompletedEpisode: m.prepareCompletedEpisode,
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: vi.fn(),
setResult: vi.fn(),
});
expect(result.success).toBe(false);
});
it("7. Synthesis failure — nextGraph remains installed", async () => {
const m = makeMocks();
const setResult = vi.fn();
await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1",
focusedContributions: [],
findings: [],
prepareCompletedEpisode: m.prepareCompletedEpisode,
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: vi.fn().mockReturnValue({ ok: false }),
setResult,
});
// Graph was installed despite synthesis failure
expect(setResult).toHaveBeenCalledTimes(1);
const updater = setResult.mock.calls[0][0];
const updatedState = updater(null);
expect(updatedState.situationGraph).toBe(DISTINCTIVE_GRAPH);
});
it("8. Exactly-once — each step invoked once per call", async () => {
const m = makeMocks();
await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1",
focusedContributions: [],
findings: [],
prepareCompletedEpisode: m.prepareCompletedEpisode,
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }),
setResult: vi.fn(),
});
expect(m.prepareCompletedEpisode).toHaveBeenCalledTimes(1);
expect(m.reconsiderCompletedEpisode).toHaveBeenCalledTimes(1);
expect(m.applyValidatedProposal).toHaveBeenCalledTimes(1);
});
it("9. Legacy Done CU writer retired — pipeline does not use deterministic summary", async () => {
const m = makeMocks();
const result = await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1",
focusedContributions: [],
findings: [],
prepareCompletedEpisode: m.prepareCompletedEpisode,
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }),
setResult: vi.fn(),
});
expect(result.success).toBe(true);
expect(result.nextGraph).toBe(DISTINCTIVE_GRAPH);
});
});