diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index a9e1291..fbd2d41 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -143,6 +143,12 @@ function FocusedQuestionBody({ onUpdateFindingDisposition, onUpdateFindingProposition, }) { + // ── Active-thread contribution scoping (v0.52) ── + // focusedContributions is scenario-wide; present only the active node's contributions. + const threadContribs = (focusedContributions || []).filter( + (c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId, + ); + const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error; const hasResult = Boolean(focused?.result); const hasAnswer = Boolean(focused?.answer); @@ -151,7 +157,7 @@ function FocusedQuestionBody({ // Completed context: result (primary) OR prior contributions (fallback during processing/error). // Processing and error are transient states — they must NOT collapse completed context. const hasCompletedContext = Boolean(focused?.result) || (() => { - const pc = [...(focusedContributions || [])].reverse().find((c) => c?.question && c?.answer); + const pc = [...threadContribs].reverse().find((c) => c?.question && c?.answer); return !!pc; })(); @@ -159,7 +165,7 @@ function FocusedQuestionBody({ // After setFollowUpQuestion() mutates focused.question/answer, derive from the // latest completed Contribution so the narrative remains correct. // Active follow-up detection: primary via result (when result exists), fallback via priorContribs (error state may have null result). - const priorContribs = [...(focusedContributions || [])].reverse(); + const priorContribs = [...threadContribs].reverse(); // Follow-ups from result are primary; priorContribs is fallback when result is null. const followUpsFromResult = focused?.result?.possibleFollowUpQuestions || []; const hasActiveFollowUpFromResult = diff --git a/docs/current-handoff.md b/docs/current-handoff.md index a6d6aae..b739f5f 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -276,8 +276,46 @@ A real completed investigation reached zero Open Questions, clarified questions - This session corrected placement via Edit only; deterministic verification via targeted Vitest (145 tests) and build — deliberately did not repeat Playwright - All 145 tests pass; production build compiles successfully -**Open defects (unchanged):** -- Focused-investigation state bleed: newly selected Open Question can show stale previous-question material — separate future increment +### Focused investigation presentation ownership — v0.52 correction (verified) + +**Live evidence that motivated this correction:** + +A fresh unanswered Question B displayed stale focused-investigation content from a previously answered Question A across every presentation surface: +- Previously answered / Your response +- What this tells us +- Still unclear +- Questions this raises +- Assumptions +- Connections + +**Root cause:** `FocusedQuestionBody` in `components/reasoning-workspace.jsx` iterated over the scenario-wide `focusedContributions` array for all derivations (`hasCompletedContext`, latest completed contribution, and every effective-presentation fallback: observations, uncertainties, follow-ups, assumptions, relationships). No active-question scoping was applied. + +**Correction applied — scoped contribution presentation:** + +- `FocusedQuestionBody` now derives a thread-local subset before any derivation: + ```js + const threadContribs = (focusedContributions || []).filter( + (c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId, + ); + ``` +- `hasCompletedContext`, latest completed contribution, and all effective presentation fallbacks use that scoped collection; +- scenario-wide `focusedContributions` history is preserved in memory — only the presentation derivation is narrowed; +- `originatingTargetNodeId` is also checked so follow-up contributions remain attributed to their originating question; +- fresh Question B no longer inherits Question A's focused presentation content; +- reopening or revisiting Question A still correctly uses Question A's own historical contribution content. + +**Verified:** +- targeted Vitest (`tests/open-questions-vs-assumptions.test.jsx`) — 3 new test cases (fresh B scoped to zero, active A retains its history, originatingTargetNodeId scoping) — pass +- `npm run build` — compiles successfully +- Rob manually verified the live UI on a persisted investigation: fresh unanswered Question B no longer shows stale focused-investigation content from Question B; confirmed across all six presentation surfaces listed above + +**Verification notes:** +- Claude Playwright was not used for final verification because the canonical dev server was unavailable at that point +- Full Vitest suite was not re-run in this session (only targeted regression test) + +### Open defects + +- **Focused-investigation state bleed**: Resolved by v0.52 correction above (scoped presentation derivation). Verified by targeted Vitest, build, and Rob manual visual verification. - Empty Done `no_episodic_content`: choosing Done without episodic content can produce `{ success: false, stage: "preparation", error: "no_episodic_content" }` — separate future increment -**Next restart point:** Place milestone at Open Questions position when reverting placement correction. See v0.51 placement section above for the exact ternary pattern. +**Next restart point:** The empty-Done `no_episodic_content` 400. Implement and verify that a Done action taken when no episodic evidence exists produces the same user-facing state (CU refresh with appropriate messaging) without a 400 error. diff --git a/tests/open-questions-vs-assumptions.test.jsx b/tests/open-questions-vs-assumptions.test.jsx index 8de66c3..60b9dea 100644 --- a/tests/open-questions-vs-assumptions.test.jsx +++ b/tests/open-questions-vs-assumptions.test.jsx @@ -2875,4 +2875,136 @@ describe("Zero Open Questions — end-of-investigation review invitation", () => expect(state.showInvitation).toBe(false); expect(state.clarificationNodes).toHaveLength(0); }); +}); + +// ── v0.52: Focused presentation ownership — fresh question must not inherit another's content ── + +describe("v0.52 focused presentation is scoped to active question ownership", () => { + // Simulates the exact scoped-contributions derivation in FocusedQuestionBody (v0.52 fix) + function deriveScopedContribs(focusedContributions, nodeId) { + return (focusedContributions || []).filter( + (c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId, + ); + } + + function simulateFQB(priorContribs, focused) { + const hasAnswer = Boolean(focused?.answer); + const hasResult = Boolean(focused?.result); + const hasCompletedContext = hasResult || (() => { + const pc = [...priorContribs].reverse().find((c) => c?.question && c?.answer); + return !!pc; + })(); + + const latestCompletedContrib = priorContribs.find((c) => c?.question && c?.answer); + const effectiveObservations = focused?.result?.observations ?? priorContribs.find((c) => c?.observations)?.observations; + const effectiveUncertainties = focused?.result?.uncertainties ?? priorContribs.find((c) => c?.uncertainties)?.uncertainties; + const effectiveFollowUps = focused?.result?.possibleFollowUpQuestions || priorContribs.find((c) => c?.possibleFollowUpQuestions)?.possibleFollowUpQuestions; + const effectiveAssumptions = focused?.result?.assumptions || priorContribs.find((c) => c?.assumptions)?.assumptions; + const effectiveRelationships = focused?.result?.relationships || priorContribs.find((c) => c?.relationships)?.relationships; + + return { + hasCompletedContext, + latestCompletedContrib, + effectiveObservations, + effectiveUncertainties, + effectiveFollowUps, + effectiveAssumptions, + effectiveRelationships, + }; + } + + // ── Scenario: Question A has historical contributions; Question B is fresh ── + const questionAContrib = { + id: "contrib-a-1", + targetNodeId: "node-A", + originatingTargetNodeId: "node-A", + question: "What drives user engagement?", + answer: "Social proof and urgency signals.", + observations: ["Users respond to countdown timers"], + uncertainties: ["Unclear if this generalises beyond SaaS"], + possibleFollowUpQuestions: ["What is the optimal timer duration?"], + assumptions: ["Users are time-pressured"], + relationships: [["engagement", "temporal_pressure"]], + }; + + const scenarioWideContributions = [questionAContrib]; + + it("Case 1 — fresh B has no active-thread contributions (Question A content absent from Question B presentation)", () => { + const activeNodeId = "node-B"; // fresh question, no own contributions + + const scopedContribs = deriveScopedContribs(scenarioWideContributions, activeNodeId); + expect(scopedContribs).toHaveLength(0); + + const focused = { + status: "formulated", + question: "How many users visit daily?", + answer: null, + result: null, + }; + + const derived = simulateFQB(scopedContribs, focused); + + // None of the A-owned fields should appear + expect(derived.hasCompletedContext).toBe(false); + expect(derived.latestCompletedContrib).toBeUndefined(); + expect(derived.effectiveObservations).toBeUndefined(); + expect(derived.effectiveUncertainties).toBeUndefined(); + expect(derived.effectiveFollowUps).toBeUndefined(); + expect(derived.effectiveAssumptions).toBeUndefined(); + expect(derived.effectiveRelationships).toBeUndefined(); + }); + + it("Case 2 — active node = A retains its own contribution history", () => { + const activeNodeId = "node-A"; // reopen Question A + + const scopedContribs = deriveScopedContribs(scenarioWideContributions, activeNodeId); + expect(scopedContribs).toHaveLength(1); + expect(scopedContribs[0].id).toBe("contrib-a-1"); + + const focused = { + status: "formulated", + question: questionAContrib.question, + answer: questionAContrib.answer, + result: { + observations: questionAContrib.observations, + uncertainties: questionAContrib.uncertainties, + possibleFollowUpQuestions: questionAContrib.possibleFollowUpQuestions, + assumptions: questionAContrib.assumptions, + relationships: questionAContrib.relationships, + }, + }; + + const derived = simulateFQB(scopedContribs, focused); + + // A's own content is present + expect(derived.hasCompletedContext).toBe(true); + expect(derived.latestCompletedContrib.id).toBe("contrib-a-1"); + expect(derived.effectiveObservations).toEqual(questionAContrib.observations); + expect(derived.effectiveUncertainties).toEqual(questionAContrib.uncertainties); + expect(derived.effectiveFollowUps).toEqual(questionAContrib.possibleFollowUpQuestions); + expect(derived.effectiveAssumptions).toEqual(questionAContrib.assumptions); + expect(derived.effectiveRelationships).toEqual(questionAContrib.relationships); + }); + + it("Case 3 — originatingTargetNodeId also scopes (follow-up contribution belongs to A)", () => { + const followUpContrib = { + id: "contrib-a-followup", + targetNodeId: "node-A-followup-intermediate", + originatingTargetNodeId: "node-A", + question: "What is the optimal timer duration?", + answer: "15-30 seconds is the sweet spot.", + observations: ["Timer above the CTA works best"], + }; + + const contribs = [followUpContrib]; + + // Matches A via originatingTargetNodeId + const scopedA = deriveScopedContribs(contribs, "node-A"); + expect(scopedA).toHaveLength(1); + expect(scopedA[0].id).toBe("contrib-a-followup"); + + // Does NOT match B + const scopedB = deriveScopedContribs(contribs, "node-B"); + expect(scopedB).toHaveLength(0); + }); }); \ No newline at end of file