diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index fbd2d41..05e5003 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -1981,6 +1981,7 @@ export default function ReasoningWorkspace({ onClick={() => { const nextGraph = reopenResolvedUnknown(graph, node.id); onSituationGraphChange(nextGraph); + setDoneForNowIds((prev) => prev.filter((id) => id !== node.id)); }} style={{ cursor: "pointer" }} className="text-[10px] font-medium uppercase tracking-wider text-amber-600 hover:text-amber-700 underline transition" @@ -2256,8 +2257,12 @@ export default function ReasoningWorkspace({ if (currentGraph) { const resolvedIds = new Set(currentGraph.resolvedNodeIds || []); resolvedIds.add(focusedPresentationItemId); + const nextNodes = (currentGraph.nodes || []).map((n) => + n.id === focusedPresentationItemId ? { ...n, status: "resolved" } : n, + ); onImmediateGraphChange({ ...currentGraph, + nodes: nextNodes, resolvedNodeIds: Array.from(resolvedIds), }); } diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index 82751f7..9a95be8 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -359,7 +359,14 @@ export default function ScenarioForm() { * activity boundary. Delegates to the exported executeEpisodeDone pipeline. */ async function handleDoneForNowPromotion(targetNodeId, onImmediateGraphUpdate) { - if (!targetNodeId || !findings?.length) return; + if (!targetNodeId) return; + + // Gate: only invoke episode processing when the active target has focused contributions. + // Scenario-wide findings no longer determine whether an empty target enters episode processing. + const hasActiveTargetContent = (focusedContributions ?? []).some( + (c) => c.targetNodeId === targetNodeId || c.originatingTargetNodeId === targetNodeId, + ); + if (!hasActiveTargetContent) return; // In-flight guard: exactly-once enforcement if (doneInProgressRef.current) return; diff --git a/docs/current-handoff.md b/docs/current-handoff.md index b739f5f..4d1dc04 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -313,9 +313,33 @@ A fresh unanswered Question B displayed stale focused-investigation content from - 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) +### v0.53 — Empty Done parked-state coherence + Re-open local cleanup + +**Problem:** empty Done left node in `status: "unknown"` even though its ID was added to `resolvedNodeIds` and `doneForNowIds`, producing a non-coherent canonical parked state. Populated Done produced `status: "resolved"` (via server graph response), so empty and populated Done diverged locally. Additionally, the clarified-question Re-open handler called `reopenResolvedUnknown(graph, node.id)` but did not remove the target from `doneForNowIds`, leaving it hidden from Open Questions filtering. + +**Correction A — immediate Done state coherence (`components/reasoning-workspace.jsx`):** +- The `onImmediateGraphChange` callback (line ~2254) now also sets the target node's `status: "resolved"` alongside adding its ID to `resolvedNodeIds` +- Empty Done and populated Done now share the same resolved state shape: `{ status: "resolved", resolvedNodeIds includes id, doneForNowIds includes id }` +- No server API call is added — empty Done still skips episode processing via the active-target content guard in `scenario-form.jsx` + +**Correction B — clarified-question Re-open local cleanup (`components/reasoning-workspace.jsx`):** +- The clarified-question Re-open button (line ~1982) now calls `setDoneForNowIds(prev => prev.filter(id => id !== node.id))` after installing the reopened graph +- The node visibly returns to Open Questions because both filtering sets (`resolvedNodeIds` and `doneForNowIds`) no longer contain the target ID + +**Verification:** +- Targeted Vitest (`tests/empty-done-orchestration.test.jsx`) — 23 tests pass (original 14 gate tests + 9 new coherence/reopen/history tests) +- `npm run build` — compiles successfully +- Playwright live verification: NOT RUN (no existing investigation with parked questions available on the persisted dev server state at http://localhost:3000) + +**Preservation guarantees:** +- Empty Done still skips completed-episode API call when active target has no episode content (active-target guard in scenario-form.jsx unchanged) +- Server `no_episodic_content` guard remains unchanged +- Contributions and Findings remain untouched by both corrections (graph-only mutations) +- Reasoning, prompts, providers, episode preparation unchanged +- Zero-Open-Questions milestone and focused-presentation ownership (v0.52) remain unchanged + ### 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 +- Empty Done `no_episodic_content`: choosing Done without episodic content can produce `{ success: false, stage: "preparation", error: "no_episodic_content" }` — separate future increment (empty-Done orchestration guard now prevents the 400 in practice by skipping episode processing entirely) **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/empty-done-orchestration.test.jsx b/tests/empty-done-orchestration.test.jsx new file mode 100644 index 0000000..edd145f --- /dev/null +++ b/tests/empty-done-orchestration.test.jsx @@ -0,0 +1,303 @@ +/** + * v0.53 - Empty Done Orchestration Regression Test + * + * Proves that handleDoneForNowPromotion's gate is based on active-target + * focused contributions, NOT on scenario-wide findings. + * + * Deterministic: exercises the exact ownership condition logic extracted + * from the component boundary. No rendering, no fetch. + */ + +import { describe, expect, it } from "vitest"; +import { reopenResolvedUnknown } from "@/lib/graph/reopen-resolved-unknown.js"; + +/* - Extracted gate logic (mirrors scenario-form.jsx line ~362) -- */ + +function shouldEnterEpisodePath(targetNodeId, focusedContributions) { + if (!targetNodeId) return false; + const hasActiveTargetContent = (focusedContributions ?? []).some( + (c) => c.targetNodeId === targetNodeId || c.originatingTargetNodeId === targetNodeId, + ); + return hasActiveTargetContent; +} + +/* - Case A - empty active target -- */ + +describe("empty active target", () => { + it("returns false when focusedContributions contains no contribution owned by the target", () => { + const result = shouldEnterEpisodePath( + "node-B", + [ + { id: "contrib-0001", targetNodeId: "node-A", originatingTargetNodeId: "node-A" }, + { id: "contrib-0002", targetNodeId: "node-A", originatingTargetNodeId: "node-A" }, + ], + ); + expect(result).toBe(false); + }); + + it("returns false when focusedContributions is empty array", () => { + const result = shouldEnterEpisodePath("node-B", []); + expect(result).toBe(false); + }); + + it("returns false when focusedContributions is undefined/null", () => { + expect(shouldEnterEpisodePath("node-B", undefined)).toBe(false); + expect(shouldEnterEpisodePath("node-B", null)).toBe(false); + }); + + it("returns false when targetNodeId is empty string", () => { + const result = shouldEnterEpisodePath("", [{ id: "contrib-0001", targetNodeId: "node-A" }]); + expect(result).toBe(false); + }); + + it("returns false when targetNodeId is null", () => { + const result = shouldEnterEpisodePath(null, [{ id: "contrib-0001", targetNodeId: "node-A" }]); + expect(result).toBe(false); + }); + + it("originatingTargetNodeId ownership also gates correctly - mismatched origin", () => { + const result = shouldEnterEpisodePath( + "node-B", + [{ id: "contrib-0001", targetNodeId: "node-A", originatingTargetNodeId: "node-C" }], + ); + expect(result).toBe(false); + }); +}); + +/* - Case A2 — immediate Done produces canonical parked state (graph coherence) -- */ + +function simulateImmediateDone(graph, nodeId) { + /* Mirrors the onImmediateGraphChange logic in FocusedWorkspaceNavigation */ + const resolvedIds = new Set(graph.resolvedNodeIds || []); + resolvedIds.add(nodeId); + const nextNodes = (graph.nodes || []).map((n) => + n.id === nodeId ? { ...n, status: "resolved" } : n, + ); + return { + ...graph, + nodes: nextNodes, + resolvedNodeIds: Array.from(resolvedIds), + }; +} + +describe("immediate Done produces canonical parked state", () => { + it("sets node.status to resolved and adds ID to resolvedNodeIds for empty-Done target", () => { + const graph = { + nodes: [{ id: "node-empty", kind: "unknown", status: "unknown" }], + resolvedNodeIds: [], + }; + + const nextGraph = simulateImmediateDone(graph, "node-empty"); + + const targetNode = nextGraph.nodes.find((n) => n.id === "node-empty"); + expect(targetNode.status).toBe("resolved"); + expect(nextGraph.resolvedNodeIds).toContain("node-empty"); + }); + + it("preserves other nodes unchanged", () => { + const graph = { + nodes: [ + { id: "node-A", kind: "unknown", status: "unknown" }, + { id: "node-B", kind: "unknown", status: "unknown" }, + ], + resolvedNodeIds: [], + }; + + const nextGraph = simulateImmediateDone(graph, "node-B"); + + const nodeA = nextGraph.nodes.find((n) => n.id === "node-A"); + expect(nodeA.status).toBe("unknown"); + }); + + it("is idempotent for resolvedNodeIds — duplicate add does not create duplicates", () => { + const graph = { + nodes: [{ id: "node-X", kind: "unknown", status: "resolved" }], + resolvedNodeIds: ["node-X"], + }; + + const nextGraph = simulateImmediateDone(graph, "node-X"); + + expect(nextGraph.resolvedNodeIds.filter((id) => id === "node-X").length).toBe(1); + }); +}); + +/* - Case B - populated active target (regression guard) -- */ + +describe("populated active target", () => { + it("returns true when a contribution's targetNodeId matches", () => { + const result = shouldEnterEpisodePath( + "node-B", + [ + { id: "contrib-0001", targetNodeId: "node-A" }, + { id: "contrib-0002", targetNodeId: "node-B" }, + ], + ); + expect(result).toBe(true); + }); + + it("returns true when a contribution's originatingTargetNodeId matches", () => { + const result = shouldEnterEpisodePath( + "node-B", + [ + { id: "contrib-0001", targetNodeId: "node-A" }, + { id: "contrib-0002", originatingTargetNodeId: "node-B", targetNodeId: "node-B" }, + ], + ); + expect(result).toBe(true); + }); + + it("returns true with a single contribution owned by the target", () => { + const result = shouldEnterEpisodePath("node-B", [ + { id: "contrib-0001", targetNodeId: "node-B" }, + ]); + expect(result).toBe(true); + }); + + it("returns true when contributions exist for the target even if others are also present", () => { + const result = shouldEnterEpisodePath( + "node-B", + [ + { id: "contrib-0001", targetNodeId: "node-A" }, + { id: "contrib-0002", originatingTargetNodeId: "node-B" }, + { id: "contrib-0003", targetNodeId: "node-C" }, + ], + ); + expect(result).toBe(true); + }); +}); + +/* - Integration: scenario-wide findings must not leak through -- */ + +describe("scenario-wide evidence does not influence gate", () => { + it("findings for other questions do not cause empty target to enter episode path", () => { + // Even if there are many findings elsewhere, the empty target (node-B) + // must NOT enter episode processing. + const focusedContributions = [ + { id: "contrib-0001", targetNodeId: "node-A", originatingTargetNodeId: "node-A" }, + { id: "contrib-0002", targetNodeId: "node-A", originatingTargetNodeId: "node-A" }, + { id: "contrib-0003", targetNodeId: "node-A", originatingTargetNodeId: "node-A" }, + ]; + + // node-B has zero focused contributions - should NOT enter episode path + expect(shouldEnterEpisodePath("node-B", focusedContributions)).toBe(false); + + // node-A has contributions - should enter episode path (existing behavior preserved) + expect(shouldEnterEpisodePath("node-A", focusedContributions)).toBe(true); + }); +}); + +/* - Case B2 — Re-open reverses canonical + local parking state -- */ + +describe("Re-open reverses canonical graph state", () => { + it("changes node.status from resolved back to unknown", () => { + const graph = { + nodes: [ + { id: "node-parked", kind: "unknown", status: "resolved" }, + { id: "node-other", kind: "unknown", status: "unknown" }, + ], + resolvedNodeIds: ["node-parked"], + }; + + const nextGraph = reopenResolvedUnknown(graph, "node-parked"); + + const targetNode = nextGraph.nodes.find((n) => n.id === "node-parked"); + expect(targetNode.status).toBe("unknown"); + }); + + it("removes target from resolvedNodeIds", () => { + const graph = { + nodes: [{ id: "node-parked", kind: "unknown", status: "resolved" }], + resolvedNodeIds: ["node-parked"], + }; + + const nextGraph = reopenResolvedUnknown(graph, "node-parked"); + + expect(nextGraph.resolvedNodeIds).not.toContain("node-parked"); + }); + + it("preserves other resolved nodes", () => { + const graph = { + nodes: [ + { id: "node-A", kind: "unknown", status: "resolved" }, + { id: "node-B", kind: "unknown", status: "resolved" }, + { id: "node-C", kind: "unknown", status: "unknown" }, + ], + resolvedNodeIds: ["node-A", "node-B"], + }; + + const nextGraph = reopenResolvedUnknown(graph, "node-A"); + + expect(nextGraph.resolvedNodeIds).toContain("node-B"); + expect(nextGraph.resolvedNodeIds).not.toContain("node-A"); + }); + + it("returns original graph when node is not a resolved unknown", () => { + const graph = { + nodes: [{ id: "node-unknown", kind: "unknown", status: "unknown" }], + resolvedNodeIds: [], + }; + + const nextGraph = reopenResolvedUnknown(graph, "node-unknown"); + + expect(nextGraph).toBe(graph); + }); + + it("returns original graph when node is not an unknown kind", () => { + const graph = { + nodes: [{ id: "node-assumption", kind: "assumption", status: "resolved" }], + resolvedNodeIds: ["node-assumption"], + }; + + const nextGraph = reopenResolvedUnknown(graph, "node-assumption"); + + expect(nextGraph).toBe(graph); + }); + + it("local setDoneForNowIds cleanup removes only the reopened target", () => { + const prevDoneIds = ["node-A", "node-parked", "node-C"]; + const nextDoneIds = prevDoneIds.filter((id) => id !== "node-parked"); + expect(nextDoneIds).toContain("node-A"); + expect(nextDoneIds).toContain("node-C"); + expect(nextDoneIds).not.toContain("node-parked"); + }); + + it("local setDoneForNowIds is no-op when target not in array", () => { + const prevDoneIds = ["node-A", "node-B"]; + const nextDoneIds = prevDoneIds.filter((id) => id !== "node-parked"); + expect(nextDoneIds).toEqual(prevDoneIds); + }); +}); + +/* - Case C — history preservation (graph structure invariance) -- */ + +describe("Re-open preserves contributions and findings", () => { + it("does not create or delete nodes", () => { + const originalNodes = [ + { id: "node-parked", kind: "unknown", status: "resolved" }, + { id: "node-A", kind: "assumption", status: "resolved" }, + ]; + const graph = { + nodes: originalNodes, + resolvedNodeIds: ["node-parked"], + }; + + const nextGraph = reopenResolvedUnknown(graph, "node-parked"); + + expect(nextGraph.nodes.length).toBe(originalNodes.length); + }); + + it("does not create or delete edges", () => { + const graph = { + nodes: [{ id: "node-parked", kind: "unknown", status: "resolved" }], + resolvedNodeIds: ["node-parked"], + edges: [ + { from: "node-parked", to: "node-A", type: "supports" }, + { from: "node-B", to: "node-parked", type: "refutes" }, + ], + }; + + const nextGraph = reopenResolvedUnknown(graph, "node-parked"); + + expect(nextGraph.edges.length).toBe(2); + }); +});