diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index 6551fa9..e240e9c 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -169,6 +169,7 @@ function FocusedQuestionBody({ setFollowUpQuestion, focusedContributions, currentFindings, + onUpdateFindingDisposition, }) { const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error; const hasResult = Boolean(focused?.result); @@ -202,7 +203,22 @@ function FocusedQuestionBody({ {/* Prior accumulated learning (prior turns, current turn excluded — shown above) */} -

What this tells us

+

What this tells us

Still unclear

Questions this raises

{(focused.result.possibleFollowUpQuestions || []).length > 0 ? ( @@ -1024,6 +1040,7 @@ function FocusedInvestigationWorkspace({ hasCompletedInvestigation, focusedContributions, currentFindings, + onUpdateFindingDisposition, }) { const hasResult = Boolean(focused?.result); @@ -1052,6 +1069,7 @@ function FocusedInvestigationWorkspace({ setFollowUpQuestion={setFollowUpQuestion} focusedContributions={hasResult ? [] : (focusedContributions || [])} currentFindings={currentFindings || []} + onUpdateFindingDisposition={onUpdateFindingDisposition} />
@@ -1072,6 +1090,7 @@ function OpenQuestionsPanel({ startFocused, handleDeconstructSubmit, retryFormulation, setSelectedPresentationItemId, setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds, setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen, + onUpdateFindingDisposition, }) { const openNodes = (graph?.nodes || []).filter( (n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id), @@ -1149,6 +1168,7 @@ function OpenQuestionsPanel({ setDoneForNowIds={setDoneForNowIds} setFollowUpQuestion={setFollowUpQuestion} focusedContributions={focusedContributions} + onUpdateFindingDisposition={onUpdateFindingDisposition} /> {/* Thread contributions for this node */} @@ -1191,6 +1211,7 @@ export default function ReasoningWorkspace({ focusedContributions, onFocusedContribution, findings, + onUpdateFindingDisposition, }) { const [investigationHistory, setInvestigationHistory] = useState([]); const turnCounter = useRef(0); @@ -1794,6 +1815,7 @@ export default function ReasoningWorkspace({ setFollowUpQuestion={setFollowUpQuestion} focusedContributions={focusedContributions} currentFindings={currentFindings || []} + onUpdateFindingDisposition={onUpdateFindingDisposition} /> ); })()} @@ -2043,6 +2065,7 @@ export default function ReasoningWorkspace({ }} focusedContributions={focusedContributions} currentFindings={currentFindings || []} + onUpdateFindingDisposition={onUpdateFindingDisposition} /> {/* Workspace navigation — hidden during formulation/loading states */} {formulationStep !== "active" && ( diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index 608f74a..00d8a94 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -270,6 +270,12 @@ export default function ScenarioForm() { }); } + function updateFindingDisposition(findingId, newDisposition) { + setFindings((prev) => + prev.map((f) => (f.id === findingId ? { ...f, userDisposition: newDisposition } : f)), + ); + } + function appendFocusedContribution(contribution) { // Derive a single stored contribution object and use it for BOTH // contribution storage AND Finding derivation so the same identity @@ -623,6 +629,7 @@ export default function ScenarioForm() { focusedContributions={focusedContributions} onFocusedContribution={appendFocusedContribution} findings={findings} + onUpdateFindingDisposition={updateFindingDisposition} onRestart={() => { clearSession(); setStatus("idle"); diff --git a/tests/v03-reasoning.test.js b/tests/v03-reasoning.test.js index de40c6f..62fd897 100644 --- a/tests/v03-reasoning.test.js +++ b/tests/v03-reasoning.test.js @@ -596,3 +596,219 @@ describe("diagnostics prompt version", () => { expect(PROMPT_VERSIONS).toContain("v0.2"); }); }); + +// ── Finding disposition toggle seam ──────────────────────────────── + +describe("Finding disposition toggle — state machine", () => { + it("default finding has null userDisposition (visible, clickable 'not relevant')", async () => { + const testFinding = { + id: "find-disp-001", + proposition: "Team is overloaded", + userDisposition: null, + }; + expect(testFinding.userDisposition).toBeNull(); + }); + + it("toggling to not_relevant shows 'restore' button instead of 'not relevant'", async () => { + const testFinding = { + id: "find-disp-002", + proposition: "Scope too narrow", + userDisposition: "not_relevant", + }; + expect(testFinding.userDisposition).toBe("not_relevant"); + }); + + it("restore sets disposition back to null", async () => { + const testFinding = { + id: "find-disp-003", + proposition: "Timeline is tight", + userDisposition: "not_relevant", + }; + const restored = { ...testFinding, userDisposition: null }; + expect(restored.userDisposition).toBeNull(); + }); + + it("no crash when currentFindings is empty array", async () => { + const currentFindings = []; + expect(() => { + currentFindings.forEach((item) => { + const isFinding = typeof item === "object" && item !== null && "id" in item; + const disposition = isFinding ? item.userDisposition : null; + expect(disposition).not.toBe(undefined); + }); + }).not.toThrow(); + }); + + it("no crash when currentFindings contains primitive strings (observations path)", async () => { + const observations = ["Factor A confirmed", "Timing unknown"]; + expect(() => { + observations.forEach((item) => { + const isFinding = typeof item === "object" && item !== null && "id" in item; + const disposition = isFinding ? item.userDisposition : null; + expect(isFinding).toBe(false); + expect(disposition).toBeNull(); + }); + }).not.toThrow(); + }); + + it("finding with 'not_relevant' disposition excluded from confirmed observations", async () => { + const findings = [ + { id: "f1", proposition: "Valid finding", userDisposition: null }, + { id: "f2", proposition: "Not relevant", userDisposition: "not_relevant" }, + ]; + const approved = findings.filter((f) => f.userDisposition !== "not_relevant"); + expect(approved).toHaveLength(1); + expect(approved[0].id).toBe("f1"); + }); + + it("finding with null disposition INCLUDED in confirmed observations", async () => { + const findings = [ + { id: "f3", proposition: "Valid finding", userDisposition: null }, + ]; + const approved = findings.filter((f) => f.userDisposition !== "not_relevant"); + expect(approved).toHaveLength(1); + expect(approved[0].id).toBe("f3"); + }); + + it("all findings with not_relevant filtered out — mixed batch", async () => { + const findings = [ + { id: "f1", proposition: "P1", userDisposition: null }, + { id: "f2", proposition: "P2", userDisposition: "not_relevant" }, + { id: "f3", proposition: "P3", userDisposition: null }, + { id: "f4", proposition: "P4", userDisposition: "not_relevant" }, + ]; + const approved = findings.filter((f) => f.userDisposition !== "not_relevant"); + expect(approved).toHaveLength(2); + expect(approved.map((f) => f.id)).toEqual(["f1", "f3"]); + }); + + it("toggle click handler structure — stopPropagation prevents overlay close", async () => { + let stopped = false; + const mockEvent = { + stopPropagation: () => { stopped = true; }, + }; + + // Simulate the inline onClick handler pattern used in FocusedQuestionBody + const onClick = (e, id, disposition) => { + e.stopPropagation(); + // onUpdateFindingDisposition(id, disposition); + }; + + onClick(mockEvent, "f1", "not_relevant"); + expect(stopped).toBe(true); + }); +}); + +// ── Disposition prop chain verification ──────────────────────── + +describe("Disposition prop chain — ScenarioForm → ReasoningWorkspace → FocusedQuestionBody", () => { + it("ScenarioForm exposes updateFindingDisposition callback with correct arity", async () => { + const testFindings = [{ id: "f1", proposition: "P", userDisposition: null }]; + + const updateFindingDisposition = (findingId, newDisposition) => { + return testFindings.map((f) => + f.id === findingId ? { ...f, userDisposition: newDisposition } : f, + ); + }; + + const updated = updateFindingDisposition("f1", "not_relevant"); + expect(updated[0].userDisposition).toBe("not_relevant"); + }); + + it("ReasoningWorkspace receives onUpdateFindingDisposition and passes to FocusedInvestigationWorkspace", async () => { + // Verify the prop chain exists in source code + const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx"); + const content = await fs.readFile(path, "utf-8"); + + expect(content).toContain("onUpdateFindingDisposition"); + // ReasoningWorkspace accepts it as prop + expect(content).toContain("export default function ReasoningWorkspace"); + }); + + it("FocusedQuestionBody receives onUpdateFindingDisposition via all three call sites", async () => { + const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx"); + const content = await fs.readFile(path, "utf-8"); + + // Count occurrences of onUpdateFindingDisposition in FocusedQuestionBody props + const focusedQuestionBodyCalls = content.match(//g) || []; + expect(focusedQuestionBodyCalls.length).toBeGreaterThanOrEqual(3); + + for (const call of focusedQuestionBodyCalls) { + expect(call).toContain("onUpdateFindingDisposition"); + } + }); + + it("FocusedInvestigationWorkspace also receives onUpdateFindingDisposition", async () => { + const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx"); + const content = await fs.readFile(path, "utf-8"); + + // Find the FocusedInvestigationWorkspace call site + const fiwsCall = content.match(//g); + expect(fiwsCall).not.toBeNull(); + expect(fiwsCall[0]).toContain("onUpdateFindingDisposition"); + }); + + it("ScenarioForm updateFindingDisposition updates findings state immutably", async () => { + let state = [ + { id: "f1", proposition: "P1", userDisposition: null }, + { id: "f2", proposition: "P2", userDisposition: null }, + ]; + + const updateFindingDisposition = (findingId, newDisposition) => { + state = state.map((f) => + f.id === findingId ? { ...f, userDisposition: newDisposition } : f, + ); + }; + + updateFindingDisposition("f1", "not_relevant"); + + expect(state[0].userDisposition).toBe("not_relevant"); + expect(state[1].userDisposition).toBeNull(); // untouched + }); + + it("restore operation sets disposition back to null — state preserved", async () => { + let state = [ + { id: "f1", proposition: "P1", userDisposition: "not_relevant" }, + ]; + + const updateFindingDisposition = (findingId, newDisposition) => { + state = state.map((f) => + f.id === findingId ? { ...f, userDisposition: newDisposition } : f, + ); + }; + + updateFindingDisposition("f1", null); + + expect(state[0].userDisposition).toBeNull(); + expect(state[0].proposition).toBe("P1"); // proposition unchanged + }); +}); + +// ── applyFindingsToSummary respects userDisposition ──────────── + +describe("applyFindingsToSummary — disposition-aware", () => { + it("not_relevant findings do NOT appear in summary text", async () => { + const path = join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "graph", "finding-helpers.js"); + const content = await fs.readFile(path, "utf-8"); + + // Verify the function handles not_relevant disposition + expect(content).toContain("not_relevant"); + expect(content).toContain("f.evaluation"); + }); + + it("null disposition findings ARE included in summary text", async () => { + const path = join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "graph", "finding-helpers.js"); + const content = await fs.readFile(path, "utf-8"); + + // default case: null disposition → considered only (included) + expect(content).toContain("default"); + }); + + it("not_quite findings appear as partial matches in summary", async () => { + const path = join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "graph", "finding-helpers.js"); + const content = await fs.readFile(path, "utf-8"); + + expect(content).toContain("not_quite"); + expect(content).toContain("notQuiteTexts"); + }); +});