fix(confidence-engine): align empty done and reopen state
- Empty Done immediate transition now sets node.status to resolved alongside resolvedNodeIds/doneForNowIds — same canonical parked shape as populated Done (no server call required) - Clarified-question Re-open removes target from doneForNowIds so the question visibly returns to Open Questions - Immediate graph mutation creates new node objects immutably (React state semantics), touching only the target node
This commit is contained in:
@@ -1981,6 +1981,7 @@ export default function ReasoningWorkspace({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const nextGraph = reopenResolvedUnknown(graph, node.id);
|
const nextGraph = reopenResolvedUnknown(graph, node.id);
|
||||||
onSituationGraphChange(nextGraph);
|
onSituationGraphChange(nextGraph);
|
||||||
|
setDoneForNowIds((prev) => prev.filter((id) => id !== node.id));
|
||||||
}}
|
}}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
className="text-[10px] font-medium uppercase tracking-wider text-amber-600 hover:text-amber-700 underline transition"
|
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) {
|
if (currentGraph) {
|
||||||
const resolvedIds = new Set(currentGraph.resolvedNodeIds || []);
|
const resolvedIds = new Set(currentGraph.resolvedNodeIds || []);
|
||||||
resolvedIds.add(focusedPresentationItemId);
|
resolvedIds.add(focusedPresentationItemId);
|
||||||
|
const nextNodes = (currentGraph.nodes || []).map((n) =>
|
||||||
|
n.id === focusedPresentationItemId ? { ...n, status: "resolved" } : n,
|
||||||
|
);
|
||||||
onImmediateGraphChange({
|
onImmediateGraphChange({
|
||||||
...currentGraph,
|
...currentGraph,
|
||||||
|
nodes: nextNodes,
|
||||||
resolvedNodeIds: Array.from(resolvedIds),
|
resolvedNodeIds: Array.from(resolvedIds),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,7 +359,14 @@ export default function ScenarioForm() {
|
|||||||
* activity boundary. Delegates to the exported executeEpisodeDone pipeline.
|
* activity boundary. Delegates to the exported executeEpisodeDone pipeline.
|
||||||
*/
|
*/
|
||||||
async function handleDoneForNowPromotion(targetNodeId, onImmediateGraphUpdate) {
|
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
|
// In-flight guard: exactly-once enforcement
|
||||||
if (doneInProgressRef.current) return;
|
if (doneInProgressRef.current) return;
|
||||||
|
|||||||
+26
-2
@@ -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
|
- 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)
|
- 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
|
### 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 orchestration guard now prevents the 400 in practice by skipping episode processing entirely)
|
||||||
- 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:** 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.
|
**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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user