From 10cbcbdd05ab3e2f4a9a49dea5c0bf31deba4e4d Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 28 Aug 2026 11:56:31 +0100 Subject: [PATCH] fix(confidence-engine): preserve investigation activity across turns ThreadContributionsBadge, PriorContributionsSummary, and SecondaryPreviousLearning all filtered contributions via c.targetNodeId === nodeId. Multi-turn follow-up Contributions carry a different immediate targetNodeId while the canonical origin remains on Findings (originatingTargetNodeId). Repaired: all contribution filters now match on EITHER c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId. handleDeconstructSubmit carries originatingTargetNodeId from focusedPresentationItemId as provenance for cold-return recovery. --- components/reasoning-workspace.jsx | 15 ++- docs/current-handoff.md | 18 +++ tests/open-questions-vs-assumptions.test.jsx | 134 ++++++++++++++++++- 3 files changed, 162 insertions(+), 5 deletions(-) diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index 4cac47c..8d83a26 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -471,7 +471,9 @@ function EvidenceLimitCard({ summary }) { // ── Prior contribution summary (embedded within FocusedQuestionBody) ─── function PriorContributionsSummary({ nodeId, contributions }) { - const threadContribs = contributions.filter((c) => c.targetNodeId === nodeId); + const threadContribs = (contributions || []).filter( + (c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId, + ); if (!threadContribs.length) return null; // Exclude the most recent contribution — it is already shown as the current result above. @@ -519,7 +521,9 @@ function PriorContributionsSummary({ nodeId, contributions }) { // ── Standalone previous learning block (for two-column secondary placement) ─── function SecondaryPreviousLearning({ nodeId, contributions }) { - const threadContribs = contributions.filter((c) => c.targetNodeId === nodeId); + const threadContribs = (contributions || []).filter( + (c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId, + ); if (!threadContribs.length) return null; // Exclude the most recent contribution — it is already shown as the current result above. @@ -567,7 +571,9 @@ function SecondaryPreviousLearning({ nodeId, contributions }) { // ── Thread contributions badge (standalone — used outside focused body) ─── function ThreadContributionsBadge({ nodeId, contributions }) { - const threadContribs = contributions.filter((c) => c.targetNodeId === nodeId); + const threadContribs = (contributions || []).filter( + (c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId, + ); if (!threadContribs.length) return null; // Show most recent contribution summary inline @@ -1401,7 +1407,7 @@ export default function ReasoningWorkspace({ if (!target) return; const priorContribs = (focusedContributions || []).filter( - (c) => c.targetNodeId === target, + (c) => c.targetNodeId === target || c.originatingTargetNodeId === target, ); setFocusedPresentationItemId(target); @@ -1511,6 +1517,7 @@ export default function ReasoningWorkspace({ // Persist contribution to case-level owner (ScenarioForm) onFocusedContribution?.({ targetNodeId, + originatingTargetNodeId: focusedPresentationItemId, targetLabel: targetNode?.label || "", targetDescription: targetNode?.description || "", question: focused.question, diff --git a/docs/current-handoff.md b/docs/current-handoff.md index 7ac4003..48bf866 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -1017,6 +1017,24 @@ The remaining boundaries are NOT persistence issues. They belong to the next fea - Finding merge/split ownership - Final Finding → graph mapping mechanism +### v0.49 — MULTI-TURN / COLD-RETURN ACTIVITY VISIBILITY REPAIR (2026-08-28) + +**Closed:** Multi-turn/cold-return regression in Open Question activity visibility (INVESTIGATING cue). + +**Root cause:** `ThreadContributionsBadge`, `PriorContributionsSummary`, and `SecondaryPreviousLearning` all filtered contributions via `c.targetNodeId === nodeId`. Multi-turn follow-up Contributions carry a different immediate `targetNodeId` while the canonical origin remains on Findings (`originatingTargetNodeId`). When only the follow-up turn survives cold return, the filter finds zero matches. + +**Repaired identity rule:** All contribution filters now match on EITHER `c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId`. This captures both direct-target contributions and follow-up contributions whose origin anchors to a different Open Question. + +**New persisted field on Contribution:** `originatingTargetNodeId` — set from `focusedPresentationItemId` at deconstruct submit time. It is NOT a new investigation flag; it is provenance that lets existing discovery surfaces recover multi-turn history. + +**Files changed:** +- `components/reasoning-workspace.jsx` — patched 4 filter sites + `handleDeconstructSubmit` to carry `originatingTargetNodeId` +- `tests/open-questions-vs-assumptions.test.jsx` — added v0.49 multi-turn regression tests and repaired `getThreadContribs` helper + +**No changes to:** Finding eligibility, Finding disposition, Current Understanding, Done-for-now promotion, SituationGraph, persistence provider, or graph reasoning. + +--- + ### BRANCH CLEAN STATE This branch has NO tracked working tree changes at HEAD b215846. Documentation closure recorded in this section only. No production or test modifications required. diff --git a/tests/open-questions-vs-assumptions.test.jsx b/tests/open-questions-vs-assumptions.test.jsx index 0bf92aa..5d24e00 100644 --- a/tests/open-questions-vs-assumptions.test.jsx +++ b/tests/open-questions-vs-assumptions.test.jsx @@ -749,7 +749,10 @@ describe("same-node focused result reopen", () => { describe("Focused investigation history cue", () => { function getThreadContribs(nodeId, contributions) { - return (contributions || []).filter((c) => c.targetNodeId === nodeId); + // v0.49 repaired: match on targetNodeId OR originatingTargetNodeId + return (contributions || []).filter( + (c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId, + ); } function showsInvestigatingCue(threadContribCount) { @@ -856,4 +859,133 @@ describe("Focused investigation history cue", () => { expect(threadContribs).toHaveLength(0); expect(showsInvestigatingCue(threadContribs.length)).toBe(false); }); + + // ── v0.49 MULTI-TURN FOLLOW-UP REGRESSION ─────────────────── + // Hypothesis: ThreadContributionsBadge depends only on the latest/direct + // Contribution targetNodeId, causing focused history to become invisible + // when follow-up turns retain a different immediate target identity. + + it("multi-turn follow-up contribution with different targetNodeId causes OPEN QUESTION A to show NO INVESTIGATING (regression)", () => { + // Turn 1: direct contribution to Open Question A — this is the baseline that works + const contribTurn1 = { + id: "contrib-001", + targetNodeId: "oq-originating", // same as the Open Question node + question: "First focused question on A", + observations: ["Fact from turn 1"], + uncertainties: [], + assumptions: [], + relationships: [], + }; + + // Turn 2: follow-up contribution belongs to the SAME focused investigation + // but targets a different intermediate node (the follow-up itself) + const contribTurn2 = { + id: "contrib-002", + targetNodeId: "follow_up_intermediate", // DIFFERENT from oq-originating + question: "Follow-up on turn 1", + observations: ["Fact from turn 2"], + uncertainties: [], + assumptions: [], + relationships: [], + }; + + const contributions = [contribTurn1, contribTurn2]; + + // Both contributions exist — baseline check passes + expect(getThreadContribs("oq-originating", contributions).length).toBe(1); + expect(showsInvestigatingCue(getThreadContribs("oq-originating", contributions).length)).toBe(true); + }); + + it("multi-turn FOLLOW-UP ONLY: Open Question A shows INVESTIGATING when Turn 2 carries originatingTargetNodeId (repaired)", () => { + // After repair: follow-up contribution carries originatingTargetNodeId linking back to A. + const contribTurn2 = { + id: "contrib-002", + targetNodeId: "follow_up_intermediate", + originatingTargetNodeId: "oq-originating", + question: "Follow-up on turn 1", + observations: ["Fact from turn 2"], + uncertainties: [], + assumptions: [], + relationships: [], + }; + + const contributions = [contribTurn2]; + + // The repaired filter matches originatingTargetNodeId back to A + const threadContribs = getThreadContribs("oq-originating", contributions); + expect(threadContribs).toHaveLength(1); + expect(showsInvestigatingCue(threadContribs.length)).toBe(true); + }); + + it("multi-turn with contributing to A directly still shows INVESTIGATING (no regression for direct contributions)", () => { + const contribToOrigin = { + id: "contrib-005", + targetNodeId: "oq-originating", + observations: ["direct to origin"], + }; + + const threadContribs = getThreadContribs("oq-originating", [contribToOrigin]); + expect(threadContribs).toHaveLength(1); + expect(showsInvestigatingCue(threadContribs.length)).toBe(true); + }); + + it("multi-turn with originatingTargetNodeId recovers INVESTIGATING for A (repaired expectation)", () => { + // Simulated repaired state: Contribution carries originatingTargetNodeId + const contribTurn1 = { + id: "contrib-001", + targetNodeId: "oq-originating", + originatingTargetNodeId: "oq-originating", + observations: ["Fact from turn 1"], + }; + + const contribTurn2 = { + id: "contrib-002", + targetNodeId: "follow_up_intermediate", + originatingTargetNodeId: "oq-originating", + observations: ["Fact from turn 2"], + }; + + const contributions = [contribTurn1, contribTurn2]; + + // Repaired filter: matches direct OR originating target + const getThreadContribsRepaired = (nodeId, c) => + (c || []).filter( + (item) => item.targetNodeId === nodeId || item.originatingTargetNodeId === nodeId, + ); + + const threadContribs = getThreadContribsRepaired("oq-originating", contributions); + expect(threadContribs).toHaveLength(2); + expect(showsInvestigatingCue(threadContribs.length)).toBe(true); + }); + + it("multi-turn: repaired filter recovers INVESTIGATING when ONLY follow-up exists (cold return repaired)", () => { + const contribTurn2 = { + id: "contrib-002", + targetNodeId: "follow_up_intermediate", + originatingTargetNodeId: "oq-originating", + observations: ["Fact from turn 2"], + }; + + const getThreadContribsRepaired = (nodeId, c) => + (c || []).filter( + (item) => item.targetNodeId === nodeId || item.originatingTargetNodeId === nodeId, + ); + + const threadContribs = getThreadContribsRepaired("oq-originating", [contribTurn2]); + expect(threadContribs).toHaveLength(1); + expect(showsInvestigatingCue(threadContribs.length)).toBe(true); + }); + + it("unrelated follow-up does NOT falsely show INVESTIGATING on any Open Question", () => { + const unrelated = { + id: "contrib-unrelated", + targetNodeId: "some_other_node", + observations: [], + }; + + expect(getThreadContribs("oq-originating", [unrelated]).length).toBe(0); + expect(showsInvestigatingCue(getThreadContribs("oq-originating", [unrelated]).length)).toBe(false); + expect(getThreadContribs("some_other_node", [unrelated]).length).toBe(1); + // some_other_node would show INVESTIGATING, but it's NOT oq-originating + }); });