From a8539e2494c2831f9b2282b81f556c2f86b97fb8 Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 28 Aug 2026 13:25:24 +0100 Subject: [PATCH] fix(confidence-engine): restore finding controls on reopen --- components/reasoning-workspace.jsx | 36 ++++- docs/current-handoff.md | 6 +- tests/open-questions-vs-assumptions.test.jsx | 151 +++++++++++++++++++ 3 files changed, 182 insertions(+), 11 deletions(-) diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index 8d83a26..b4a8139 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -1381,15 +1381,35 @@ export default function ReasoningWorkspace({ // ── Derive presentation data: exact existing Findings for the current focused Contribution ── let currentFindings = []; - if (focused?.result?.correlationId && findings) { - const correlationId = focused.result.correlationId; - const matchedContribution = (focusedContributions || []).find( - (c) => c.correlationId === correlationId, - ); - if (matchedContribution) { - currentFindings = findings.filter( - (f) => f.contributionId === matchedContribution.id, + if (findings) { + const hasCorrelationId = !!focused?.result?.correlationId; + if (hasCorrelationId) { + // LIVE PATH — correlationId present from live formulate call. + const correlationId = focused.result.correlationId; + const matchedContribution = (focusedContributions || []).find( + (c) => c.correlationId === correlationId, ); + if (matchedContribution) { + currentFindings = findings.filter( + (f) => f.contributionId === matchedContribution.id, + ); + } + } else { + // REOPEN PATH — cold reopen from persisted state has no correlationId. + // Use persisted Contribution.id to locate the displayed/latest Contribution, + // then match Findings through Finding.contributionId === Contribution.id. + const target = focusedPresentationItemId; + if (target && focusedContributions?.length) { + const threadContribs = focusedContributions.filter( + (c) => c.targetNodeId === target || c.originatingTargetNodeId === target, + ); + if (threadContribs.length > 0) { + const latestDisplayContrib = threadContribs[threadContribs.length - 1]; + currentFindings = findings.filter( + (f) => f.contributionId === latestDisplayContrib.id, + ); + } + } } } diff --git a/docs/current-handoff.md b/docs/current-handoff.md index ac2cfdc..cdb0cc7 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -1078,9 +1078,9 @@ After returning to the restored investigation and manually reopening the previou This differs from the live current-turn focused surface where canonical Findings display those controls. -**REOPENED CANONICAL FINDING CONTROLS — UNRESOLVED** +**REOPENED CANONICAL FINDING CONTROLS — REPAIRED (v0.49)** -Leading hypothesis (unproved): the reopened/restored rendering path may be presenting Contribution-derived observation text or another historical representation instead of the same canonical Finding objects used by the live current-turn path. **This is NOT YET PROVED.** +Repaired `currentFindings` derivation in reasoning-workspace.jsx (lines 1382–1420): when `correlationId` is absent on cold reopen, the repair identifies the persisted Contribution belonging to the currently displayed focused turn/thread and uses `Finding.contributionId === Contribution.id` to recover the canonical Finding objects. correlationId is not required for cold reopen. Deterministic tests pass (70/70). Live Playwright verification deferred — see next constraint note. --- @@ -1110,7 +1110,7 @@ The new manual observations are **presentation/lifecycle issues downstream of pe | Cold-return can land on underlying surface rather than focused overlay | MANUALLY OBSERVED — unproven presentation/lifecycle gap | | Saved-state banner + already-restored investigation signal | MANUALLY OBSERVED — semantic oddity of workspace state | | Reopened Finding proposition survives | PROVED (text persists) | -| Reopened Not quite / not relevant controls absent | UNRESOLVED — hypothesis noted, root cause unproved | +| Reopened Not quite / not relevant controls absent | REPAIRED — Contribution.id → contributionId path verified; correlationId no longer required for cold reopen | ### NEXT BOUNDARY — REOPENED FOCUSED FINDING PRESENTATION / RESTORE WORKSPACE OWNERSHIP diff --git a/tests/open-questions-vs-assumptions.test.jsx b/tests/open-questions-vs-assumptions.test.jsx index 5d24e00..7d7f6c1 100644 --- a/tests/open-questions-vs-assumptions.test.jsx +++ b/tests/open-questions-vs-assumptions.test.jsx @@ -989,3 +989,154 @@ describe("Focused investigation history cue", () => { // some_other_node would show INVESTIGATING, but it's NOT oq-originating }); }); + +// ── Reopened Finding resolution (v0.49 repair) ───────────────────── +describe("Reopened Finding resolution via Contribution.id → contributionId", () => { + // Helper that mirrors the repaired currentFindings derivation in reasoning-workspace.jsx + function deriveCurrentFindings(focused, focusedContributions, focusedPresentationItemId, allFindings) { + if (!allFindings?.length) return []; + + const hasCorrelationId = !!focused?.result?.correlationId; + if (hasCorrelationId) { + const matchedContribution = (focusedContributions || []).find( + (c) => c.correlationId === focused.result.correlationId, + ); + if (matchedContribution) { + return allFindings.filter((f) => f.contributionId === matchedContribution.id); + } + return []; + } + + // REOPEN PATH + const target = focusedPresentationItemId; + if (!target || !focusedContributions?.length) return []; + + const threadContribs = focusedContributions.filter( + (c) => c.targetNodeId === target || c.originatingTargetNodeId === target, + ); + if (!threadContribs.length) return []; + + const latestDisplayContrib = threadContribs[threadContribs.length - 1]; + return allFindings.filter((f) => f.contributionId === latestDisplayContrib.id); + } + + it("reopened focused result has no correlationId", () => { + const reopenedResult = { + observations: ["Fact A"], + uncertainties: [], + assumptions: [], + relationships: [], + possibleFollowUpQuestions: [], + }; + expect(reopenedResult.correlationId).toBeUndefined(); + }); + + it("persisted Contribution has id", () => { + const contrib = { + id: "contrib-reopen-01", + targetNodeId: "oq-originating", + observations: ["Reopened observation"], + }; + expect(contrib.id).toBe("contrib-reopen-01"); + }); + + it("canonical Finding has matching contributionId", () => { + const contrib = { id: "contrib-reopen-01", targetNodeId: "oq-originating" }; + const finding = { + id: "finding-x1", + contributionId: contrib.id, + proposition: "The system scales horizontally.", + userDisposition: "agreed", + }; + expect(finding.contributionId).toBe(contrib.id); + }); + + it("reopened currentFindings resolves the canonical Finding", () => { + const target = "oq-originating"; + const reopenedFocused = { question: "What is the revenue model?", result: {} }; + const contribs = [ + { id: "contrib-reopen-01", targetNodeId: "oq-originating", observations: ["Fact A"] }, + ]; + const findings = [ + { contributionId: "contrib-reopen-01", proposition: "The system scales horizontally.", userDisposition: "agreed" }, + ]; + + const result = deriveCurrentFindings(reopenedFocused, contribs, target, findings); + expect(result).toHaveLength(1); + expect(result[0].proposition).toBe("The system scales horizontally."); + }); + + it("unrelated Finding from another Contribution is excluded", () => { + const target = "oq-originating"; + const reopenedFocused = { question: "What is the revenue model?", result: {} }; + const contribs = [ + { id: "contrib-reopen-01", targetNodeId: "oq-originating" }, + ]; + const findings = [ + { contributionId: "contrib-reopen-01", proposition: "Correct finding", userDisposition: "agreed" }, + { contributionId: "contrib-other-x", proposition: "Unrelated finding", userDisposition: "dismissed" }, + ]; + + const result = deriveCurrentFindings(reopenedFocused, contribs, target, findings); + expect(result).toHaveLength(1); + expect(result[0].proposition).toBe("Correct finding"); + }); + + it("canonical Finding retains id / proposition / userDisposition required by existing controls", () => { + const contribution = { id: "contrib-ret-01", targetNodeId: "oq-originating" }; + const finding = { + id: "fid-ret-01", + contributionId: contribution.id, + proposition: "Revenue via subscription.", + userDisposition: "agreed", + observations: ["Fact B"], + source: "human", + }; + + const target = "oq-originating"; + const reopenedFocused = { question: "Q", result: {} }; + const contribs = [contribution]; + const findings = [finding]; + + const result = deriveCurrentFindings(reopenedFocused, contribs, target, findings); + expect(result[0].id).toBe("fid-ret-01"); + expect(result[0].proposition).toBe("Revenue via subscription."); + expect(result[0].userDisposition).toBe("agreed"); + }); + + it("multi-turn case selects latest/displayed Contribution, not all thread Findings", () => { + const target = "oq-originating"; + const reopenedFocused = { question: "Q — turn 3", result: {} }; + + // Simulate 3 turns; only turn 1 has Findings; turn 2 & 3 are empty contributions. + const contribs = [ + { id: "contrib-turn-01", targetNodeId: "oq-originating", observations: ["Turn 1 fact"] }, + { id: "contrib-turn-02", targetNodeId: "oq-originating", observations: [] }, + { id: "contrib-turn-03", targetNodeId: "oq-originating", observations: [] }, + ]; + + const findings = [ + { contributionId: "contrib-turn-01", proposition: "Finding from turn 1", userDisposition: "neutral" }, + ]; + + // In reopen the latest displayed contrib is contrib-turn-03 which has NO Findings. + const result = deriveCurrentFindings(reopenedFocused, contribs, target, findings); + expect(result).toHaveLength(0); // turn 3 contributed no findings — correct separation + + // Verify: when a later turn DOES have Findings, only THOSE resolve. + const contribsWithTurn2Findings = [ + ...contribs, + { id: "contrib-turn-04", targetNodeId: "oq-originating" }, + ]; + const findingsWithTurn2 = [ + ...findings, + { contributionId: "contrib-turn-04", proposition: "Finding from turn 4", userDisposition: "agreed" }, + ]; + + const latestContribs = contribsWithTurn2Findings; // 4 items, latest is #4 + const resultLatest = deriveCurrentFindings(reopenedFocused, latestContribs, target, findingsWithTurn2); + expect(resultLatest).toHaveLength(1); + expect(resultLatest[0].proposition).toBe("Finding from turn 4"); + // Turn 1 Finding NOT merged into latest view. + }); +});