- {/* RTO.26B — situation card from scenario text when no graph */}
- {!graph && scenario && (
-
+ {/* Situation — always here when condition met, independent of left column height */}
+ {(scenario || graph?.centralStatement) && (
+
+ )}
+ {!propUnderstanding && graph && postAnalyseStatus !== "success" && (
+
)}
- {graph && postAnalyseStatus !== "success" &&
}
{/* RTO.25B — temporarily hidden to reduce competing navigation while branch-experiment is active */}
diff --git a/prompts/reconstruct-v0.3.md b/prompts/reconstruct-v0.3.md
index 0763025..884515a 100644
--- a/prompts/reconstruct-v0.3.md
+++ b/prompts/reconstruct-v0.3.md
@@ -16,10 +16,13 @@ You are a neutral analyst performing evidence-based situation reconstruction.
evidence source, proposed action.
5. Identify meaningful differences (e.g., some succeed while others fail; revenue rises while cash falls).
6. Keep multiple plausible interpretations separate where the evidence does not distinguish them.
-7. Distinguish: what was said / what it may mean / why it may have been said.
-8. If input is too ambiguous or contains no useful operational anchors, say so and ask for
+7. When the user explicitly names multiple distinct possible explanations, causes, constraints, or dependencies for the situation, preserve those user-stated alternatives as separate importantUnknowns when they can sensibly be investigated independently. Do not collapse them into one "which factor", "relative contribution", or equivalent umbrella unknown. Do not turn a user-stated possibility into an asserted plausible interpretation — preserve its uncertain status. Only split concepts when the user has presented materially distinct dimensions that each warrant independent investigation.
+8. Distinguish: what was said / what it may mean / why it may have been said.
+9. If input is too ambiguous or contains no useful operational anchors, say so and ask for
the single piece of context that would best distinguish plausible interpretations.
+10. Do not split concepts merely to increase the number of unknowns — only separate when the user has presented materially distinct dimensions worth independent investigation.
+
## Normalisation and rate reasoning (apply whenever applicable)
When the scenario mentions counts, totals, frequencies, or volumes alongside changes in
diff --git a/tests/focused-contributions.test.jsx b/tests/focused-contributions.test.jsx
index 2ffb7cc..1eb4c74 100644
--- a/tests/focused-contributions.test.jsx
+++ b/tests/focused-contributions.test.jsx
@@ -318,3 +318,109 @@ describe("existing focused display path unchanged", () => {
});
});
+// ── Evidence-limit card visibility gate ───────────────────────────
+
+describe("evidence-limit card visibility gate", () => {
+ // Replicates the exact showEvidenceLimit logic from reasoning-workspace.jsx:1076-1081
+ function computeShowEvidenceLimit({ processingStep, focusedQuestion, hasGraph, genuineCompletion }) {
+ return !(
+ processingStep === "active" ||
+ (focusedQuestion && !processingStep) ||
+ (hasGraph && !genuineCompletion)
+ );
+ }
+
+ describe("evidence-limit HIDDEN during active investigation", () => {
+ it("hidden while deconstruct is processing", () => {
+ const show = computeShowEvidenceLimit({
+ processingStep: "active",
+ focusedQuestion: null,
+ hasGraph: true,
+ genuineCompletion: false,
+ });
+ expect(show).toBe(false);
+ });
+
+ it("hidden while a focused question is ready for answering", () => {
+ const show = computeShowEvidenceLimit({
+ processingStep: "idle",
+ focusedQuestion: "How does the founder transfer knowledge?",
+ hasGraph: true,
+ genuineCompletion: false,
+ });
+ expect(show).toBe(false);
+ });
+
+ it("hidden when open threads remain after deconstruct success", () => {
+ // Simulates: 4 original threads, 1 selected, 1 answer submitted,
+ // contribution attached, 3 other threads still open
+ const show = computeShowEvidenceLimit({
+ processingStep: "idle",
+ focusedQuestion: null,
+ hasGraph: true,
+ genuineCompletion: false, // 3+ unresolved nodes remain
+ });
+ expect(show).toBe(false);
+ });
+
+ it("hidden when follow-ups are available from a previous deconstruct", () => {
+ const show = computeShowEvidenceLimit({
+ processingStep: "idle",
+ focusedQuestion: null,
+ hasGraph: true,
+ genuineCompletion: false,
+ });
+ expect(show).toBe(false);
+ });
+
+ it("hidden during initial analysis phase (no graph yet)", () => {
+ const show = computeShowEvidenceLimit({
+ processingStep: "idle",
+ focusedQuestion: null,
+ hasGraph: false,
+ genuineCompletion: false,
+ });
+ expect(show).toBe(true); // no graph → no evidence limit to show
+ });
+ });
+
+ describe("evidence-limit still shown in genuine terminal state", () => {
+ it("shown when all unknowns resolved (completion already handles this branch)", () => {
+ // genuineCompletion=true means the CompletionCard path is taken instead,
+ // so EvidenceLimitCard would NOT render (it's {!genuineCompletion ? ... }).
+ // This test confirms the gate allows the terminal path.
+ const show = computeShowEvidenceLimit({
+ processingStep: "idle",
+ focusedQuestion: null,
+ hasGraph: true,
+ genuineCompletion: true,
+ });
+ expect(show).toBe(true);
+ });
+
+ it("shown with no graph (initial analysis complete, no questions)", () => {
+ const show = computeShowEvidenceLimit({
+ processingStep: "idle",
+ focusedQuestion: null,
+ hasGraph: false,
+ genuineCompletion: false,
+ });
+ expect(show).toBe(true);
+ });
+ });
+
+ describe("evidence-limit hidden while answering (focused answer exists)", () => {
+ it("hidden when processing step clears but no focused question yet (post-deconstruct, pre-new-question phase)", () => {
+ // After deconstruct completes: processingStep=idle, focusedQuestion=null
+ // hasGraph=true, genuineCompletion=false → still hidden because open threads remain
+ const show = computeShowEvidenceLimit({
+ processingStep: "idle",
+ focusedQuestion: null,
+ hasGraph: true,
+ genuineCompletion: false,
+ });
+ expect(show).toBe(false);
+ });
+ });
+});
+
diff --git a/tests/focused-deconstruct-boundary.test.js b/tests/focused-deconstruct-boundary.test.js
new file mode 100644
index 0000000..1b57d2e
--- /dev/null
+++ b/tests/focused-deconstruct-boundary.test.js
@@ -0,0 +1,220 @@
+/**
+ * Regression: focused deconstruct targetNodeId identity boundary.
+ *
+ * Verifies the deterministic enforcement invariant:
+ * request.targetNodeId (original graph node ID) must be the final
+ * API response targetNodeId regardless of what the model returns.
+ */
+
+import { describe, it, expect, vi } from "vitest";
+
+// ── helpers ──────────────────────────────────────────────────────────────
+
+function makeMockProvider(inventedTargetNodeId) {
+ return {
+ generateReconstruction: vi.fn().mockResolvedValue({
+ targetNodeId: inventedTargetNodeId,
+ observations: ["doc is minimal", "processes in founder's head"],
+ uncertainties: ["whether formal docs can capture tacit knowledge"],
+ assumptions: ["documentation is primary mechanism for knowledge transfer"],
+ relationships: [
+ { from: "founder", to: "processes", type: "holds", rationale: "tacit" },
+ { from: "ops-context", to: "docs-infra", type: "depends_on", rationale: "formal docs required" },
+ ],
+ possibleFollowUpQuestions: [
+ "What processes does the founder hold tacitly?",
+ "How is knowledge transferred when founder is unavailable?",
+ ],
+ }),
+ };
+}
+
+// ── Boundary test ────────────────────────────────────────────────────────
+
+describe("focused-deconstruct targetNodeId identity boundary", () => {
+ it("request targetNodeId overrides model-invented targetNodeId", async () => {
+ const requestTargetNodeId = "nk04xvk"; // original graph node ID
+ const inventedModelId = "invented-model-id";
+
+ vi.doMock("@/lib/llm/provider", () => ({
+ getProvider: () => makeMockProvider(inventedModelId),
+ }));
+
+ const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
+
+ const requestBody = {
+ targetNodeId: requestTargetNodeId,
+ targetLabel: "Whether unclear or uneven distribution of responsibilities is preventing autonomy in key areas.",
+ targetDescription: "Original open question node label",
+ centralStatement: "Current operational context and documentation state",
+ question:
+ "What was the comparable state before whether unclear or uneven distribution of responsibilities is preventing autonomy in key areas?",
+ answer: "Documentation is minimal, most processes are in the head of the founder.",
+ };
+
+ const request = new Request("http://localhost/api/focused-investigation/deconstruct", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(requestBody),
+ });
+
+ const response = await POST(request);
+ expect(response.status).toBe(200);
+
+ const json = await response.json();
+ expect(json.success).toBe(true);
+
+ // THE INVARIANT: final API targetNodeId = request targetNodeId (authoritative)
+ expect(json.targetNodeId).toBe(requestTargetNodeId);
+ expect(json.targetNodeId).not.toBe(inventedModelId);
+ });
+
+ it("semantic fields pass through unchanged from model", async () => {
+ const mockObs = ["doc is minimal", "processes in founder's head"];
+ const mockUnc = ["whether formal docs can capture tacit knowledge"];
+ const mockAssm = ["documentation is primary mechanism for knowledge transfer"];
+ const mockRel = [
+ { from: "founder", to: "processes", type: "holds", rationale: "tacit" },
+ { from: "ops-context", to: "docs-infra", type: "depends_on", rationale: "formal docs required" },
+ ];
+ const mockFuq = [
+ "What processes does the founder hold tacitly?",
+ "How is knowledge transferred when founder is unavailable?",
+ ];
+
+ vi.doMock("@/lib/llm/provider", () => ({
+ getProvider: () => ({
+ generateReconstruction: vi.fn().mockResolvedValue({
+ targetNodeId: "some-invented-id",
+ observations: mockObs,
+ uncertainties: mockUnc,
+ assumptions: mockAssm,
+ relationships: mockRel,
+ possibleFollowUpQuestions: mockFuq,
+ }),
+ }),
+ }));
+
+ const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
+
+ const request = new Request("http://localhost/api/focused-investigation/deconstruct", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ targetNodeId: "nk04xvk",
+ targetLabel: "label",
+ targetDescription: "desc",
+ centralStatement: "central",
+ question: "question?",
+ answer: "answer.",
+ }),
+ });
+
+ const response = await POST(request);
+ const json = await response.json();
+
+ // Semantic fields unchanged
+ expect(json.observations).toEqual(mockObs);
+ expect(json.uncertainties).toEqual(mockUnc);
+ expect(json.assumptions).toEqual(mockAssm);
+ expect(json.relationships).toEqual(mockRel);
+ expect(json.possibleFollowUpQuestions).toEqual(mockFuq);
+ });
+
+ it("contribution append preserves authoritative targetNodeId", () => {
+ // Simulates reasoning-workspace.jsx:1178-1189 after the fix:
+ // onFocusedContribution calls with body.targetNodeId (the original graph node)
+ const requestTargetNodeId = "nk04xvk";
+ const inventedModelId = "invented-model-id";
+
+ const contribution = {
+ targetNodeId: requestTargetNodeId,
+ targetLabel: "label",
+ targetDescription: "desc",
+ question: "question?",
+ answer: "answer.",
+ observations: ["obs1"],
+ uncertainties: ["unc1"],
+ assumptions: ["asm1"],
+ relationships: [{ from: "a", to: "b", type: "depends_on" }],
+ possibleFollowUpQuestions: ["fuq1"],
+ };
+
+ expect(contribution.targetNodeId).toBe(requestTargetNodeId);
+ expect(contribution.targetNodeId).not.toBe(inventedModelId);
+
+ // Simulates ThreadContributionsBadge filter: contributions.filter(c => c.targetNodeId === nodeId)
+ const threadContribs = [contribution].filter((c) => c.targetNodeId === requestTargetNodeId);
+ expect(threadContribs.length).toBe(1);
+ });
+
+ it("full identity path: request → response → contribution", async () => {
+ // Reset modules to avoid mock leakage from earlier tests
+ vi.resetModules();
+
+ const originalNodeId = "nk04xvk";
+ const modelInventedId = "investigation_node_responsibility_distribution_autonomy";
+
+ vi.doMock("@/lib/llm/provider", () => ({
+ getProvider: () => ({
+ generateReconstruction: vi.fn().mockResolvedValue({
+ targetNodeId: modelInventedId,
+ observations: ["Documentation is minimal."],
+ uncertainties: [],
+ assumptions: [
+ "That formal documentation is the primary mechanism for capturing or transferring the founder's tacit knowledge of processes.",
+ ],
+ relationships: [
+ { from: "Founder", to: "Processes", type: "holds" },
+ { from: "Operational Context", to: "Documentation Infrastructure", type: "affects" },
+ ],
+ possibleFollowUpQuestions: [],
+ }),
+ }),
+ }));
+
+ const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
+
+ const request = new Request("http://localhost/api/focused-investigation/deconstruct", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ targetNodeId: originalNodeId,
+ targetLabel:
+ "Whether unclear or uneven distribution of responsibilities is preventing autonomy in key areas.",
+ targetDescription: "Original open question node description",
+ centralStatement: "Central statement",
+ question:
+ "What was the comparable state before whether unclear or uneven distribution of responsibilities is preventing autonomy in key areas?",
+ answer: "Documentation is minimal, most processes are in the head of the founder.",
+ }),
+ });
+
+ const response = await POST(request);
+ expect(response.status).toBe(200);
+ const json = await response.json();
+
+ // Identity path verification:
+ // 1. Request targetNodeId
+ expect(json.targetNodeId).toBe(originalNodeId);
+
+ // 2. Response carries authoritative identity (not model-invented)
+ expect(json.targetNodeId).not.toBe(modelInventedId);
+
+ // 3. Semantic fields from the model remain unchanged
+ expect(json.observations).toEqual(["Documentation is minimal."]);
+ expect(json.uncertainties).toEqual([]);
+ expect(json.assumptions).toEqual([
+ "That formal documentation is the primary mechanism for capturing or transferring the founder's tacit knowledge of processes.",
+ ]);
+ expect(json.relationships).toEqual([
+ { from: "Founder", to: "Processes", type: "holds" },
+ { from: "Operational Context", to: "Documentation Infrastructure", type: "affects" },
+ ]);
+ expect(json.possibleFollowUpQuestions).toEqual([]);
+
+ // 4. Stored contribution would use originalNodeId (not modelInventedId)
+ const stored = { ...json };
+ expect(stored.targetNodeId).toBe(originalNodeId);
+ });
+});
diff --git a/tests/focused-deconstruct-persistence.test.js b/tests/focused-deconstruct-persistence.test.js
new file mode 100644
index 0000000..ead1ddd
--- /dev/null
+++ b/tests/focused-deconstruct-persistence.test.js
@@ -0,0 +1,278 @@
+/**
+ * Focused test suite for the second contribution persistence invariant.
+ *
+ * The flow during a focused deconstruct success is:
+ * 1. handleDeconstructSubmit (workspace) calls fetch to /api/focused-investigation/deconstruct
+ * 2. Route returns { success: true, observations[], uncertainties[], ... }
+ * 3. workspace calls onFocusedContribution({ targetNodeId, question, answer, ... })
+ * 4. parent scenario-form calls appendFocusedContribution(contrib) -> [ ...prev, contrib ]
+ * 5. useEffect watching focusedContributions triggers saveSession(...)
+ *
+ * Invariant: a SECOND successful deconstruct appends to the existing collection,
+ * not replacing it. Both contributions survive a session reload.
+ */
+
+import { describe, expect, it } from "vitest";
+
+// -- helpers that mirror production code exactly --
+
+function simulateAppendContributions(contributions, newContribution) {
+ return [
+ ...contributions,
+ {
+ ...newContribution,
+ id: `contrib-${String(contributions.length + 1).padStart(4, "0")}`,
+ sequence: contributions.length + 1,
+ },
+ ];
+}
+
+function simulateSaveSession(state) {
+ return JSON.stringify({
+ scenario: state.scenario,
+ situationGraph: state.situationGraph,
+ selectedQuestion: state.selectedQuestion,
+ summary: state.summary,
+ updatedAt: new Date().toISOString(),
+ focusedContributions: state.focusedContributions,
+ });
+}
+
+function simulateRestoreSession(raw) {
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ return {
+ ...parsed,
+ focusedContributions: parsed.focusedContributions || [],
+ };
+}
+
+/** Simulates a successful deconstruct API response (after the targetNodeId fix). */
+function simulateDeconstructResponse(body) {
+ return {
+ success: true,
+ targetNodeId: body.targetNodeId, // fixed: always uses body value
+ observations: [`${body.targetNodeId}-observation`],
+ uncertainties: [`${body.targetNodeId}-uncertainty`],
+ assumptions: [`${body.targetNodeId}-assumption`],
+ relationships: [{ from: body.targetNodeId, to: "context", type: "informs" }],
+ possibleFollowUpQuestions: [`What about ${body.targetLabel}?`],
+ };
+}
+
+// -- Second contribution persistence after deconstruct success --
+
+describe("second contribution persistence after deconstruct success", () => {
+ it("first deconstruct creates one contribution, session saves it", () => {
+ let state = { scenario: "test", situationGraph: {}, focusedContributions: [] };
+
+ const contrib1Data = simulateDeconstructResponse({ targetNodeId: "nk-001", targetLabel: "First thread" });
+
+ state.focusedContributions = simulateAppendContributions(state.focusedContributions, {
+ targetNodeId: contrib1Data.targetNodeId,
+ question: "What is the main risk?",
+ answer: "Regulatory compliance in EU.",
+ ...contrib1Data,
+ });
+
+ expect(state.focusedContributions).toHaveLength(1);
+ expect(state.focusedContributions[0].id).toBe("contrib-0001");
+ expect(state.focusedContributions[0].targetNodeId).toBe("nk-001");
+ expect(state.focusedContributions[0].question).toBe("What is the main risk?");
+
+ const raw = simulateSaveSession(state);
+ const restored = simulateRestoreSession(raw);
+ expect(restored.focusedContributions).toHaveLength(1);
+ expect(restored.focusedContributions[0].targetNodeId).toBe("nk-001");
+ });
+
+ it("second deconstruct appends a NEW contribution (not replace)", () => {
+ let state = { scenario: "test", situationGraph: {}, focusedContributions: [] };
+
+ // First contribution
+ const contrib1Data = simulateDeconstructResponse({ targetNodeId: "nk-001", targetLabel: "First thread" });
+ state.focusedContributions = simulateAppendContributions(state.focusedContributions, {
+ targetNodeId: contrib1Data.targetNodeId,
+ question: "What is the main risk?",
+ answer: "Regulatory compliance.",
+ ...contrib1Data,
+ });
+
+ // Second deconstruct -- user reopens SAME thread and submits a different answer
+ const contrib2Data = simulateDeconstructResponse({ targetNodeId: "nk-001", targetLabel: "Second follow-up" });
+ state.focusedContributions = simulateAppendContributions(state.focusedContributions, {
+ targetNodeId: contrib2Data.targetNodeId,
+ question: "Which EU regulation applies?",
+ answer: "GDPR Article 32.",
+ ...contrib2Data,
+ });
+
+ // CRITICAL INVARIANT: TWO contributions exist (not replaced)
+ expect(state.focusedContributions).toHaveLength(2);
+ expect(state.focusedContributions[0].id).toBe("contrib-0001");
+ expect(state.focusedContributions[0].question).toBe("What is the main risk?");
+ expect(state.focusedContributions[1].id).toBe("contrib-0002");
+ expect(state.focusedContributions[1].question).toBe("Which EU regulation applies?");
+
+ // Both share the same original targetNodeId (distinct records for same node)
+ expect(state.focusedContributions[0].targetNodeId).toBe("nk-001");
+ expect(state.focusedContributions[1].targetNodeId).toBe("nk-001");
+ });
+
+ it("session save/restore survives two deconstructs on same thread", () => {
+ let state = { scenario: "test", situationGraph: {}, focusedContributions: [] };
+
+ // First contribution
+ const contrib1Data = simulateDeconstructResponse({ targetNodeId: "nk-002", targetLabel: "Risk" });
+ state.focusedContributions = simulateAppendContributions(state.focusedContributions, {
+ targetNodeId: contrib1Data.targetNodeId, question: "Q1?", answer: "A1", ...contrib1Data,
+ });
+
+ // Save
+ let raw = simulateSaveSession(state);
+ let restored = simulateRestoreSession(raw);
+ expect(restored.focusedContributions).toHaveLength(1);
+
+ // Second contribution (simulates reopening the same thread after reload)
+ const contrib2Data = simulateDeconstructResponse({ targetNodeId: "nk-002", targetLabel: "Follow-up" });
+ restored.focusedContributions = simulateAppendContributions(restored.focusedContributions, {
+ targetNodeId: contrib2Data.targetNodeId, question: "Q2?", answer: "A2", ...contrib2Data,
+ });
+
+ // Save again
+ raw = simulateSaveSession(restored);
+ restored = simulateRestoreSession(raw);
+ expect(restored.focusedContributions).toHaveLength(2);
+ expect(restored.focusedContributions[0].question).toBe("Q1?");
+ expect(restored.focusedContributions[1].question).toBe("Q2?");
+ });
+
+ it("second deconstruct on DIFFERENT thread also appends correctly", () => {
+ let state = { scenario: "test", situationGraph: {}, focusedContributions: [] };
+
+ // First: thread A
+ const contribA = simulateDeconstructResponse({ targetNodeId: "nk-A", targetLabel: "Thread A" });
+ state.focusedContributions = simulateAppendContributions(state.focusedContributions, {
+ targetNodeId: contribA.targetNodeId, question: "A?", answer: "A1", ...contribA,
+ });
+
+ // Second: thread B (user selects a different open question)
+ const contribB = simulateDeconstructResponse({ targetNodeId: "nk-B", targetLabel: "Thread B" });
+ state.focusedContributions = simulateAppendContributions(state.focusedContributions, {
+ targetNodeId: contribB.targetNodeId, question: "B?", answer: "B1", ...contribB,
+ });
+
+ expect(state.focusedContributions).toHaveLength(2);
+ const threadAContribs = state.focusedContributions.filter((c) => c.targetNodeId === "nk-A");
+ const threadBContribs = state.focusedContributions.filter((c) => c.targetNodeId === "nk-B");
+
+ expect(threadAContribs).toHaveLength(1);
+ expect(threadAContribs[0].question).toBe("A?");
+ expect(threadBContribs).toHaveLength(1);
+ expect(threadBContribs[0].question).toBe("B?");
+ });
+
+ it("third contribution also appends -- no upper bound limit on count", () => {
+ let contributions = [];
+
+ for (let i = 1; i <= 5; i++) {
+ const body = { targetNodeId: `nk-${i}`, targetLabel: `Thread ${i}` };
+ const data = simulateDeconstructResponse(body);
+ contributions = simulateAppendContributions(contributions, {
+ targetNodeId: data.targetNodeId,
+ question: `Q${i}?`,
+ answer: `A${i}`,
+ ...data,
+ });
+
+ expect(contributions).toHaveLength(i);
+ expect(contributions[i - 1].id).toBe(`contrib-${String(i).padStart(4, "0")}`);
+ expect(contributions[i - 1].sequence).toBe(i);
+ }
+ });
+
+ it("deconstruct failure does NOT append (preserves prior contributions)", () => {
+ let state = { scenario: "test", situationGraph: {}, focusedContributions: [] };
+
+ // First contribution succeeds
+ const contribData = simulateDeconstructResponse({ targetNodeId: "nk-ok", targetLabel: "OK" });
+ state.focusedContributions = simulateAppendContributions(state.focusedContributions, {
+ targetNodeId: contribData.targetNodeId, question: "Q?", answer: "A", ...contribData,
+ });
+
+ expect(state.focusedContributions).toHaveLength(1);
+
+ // Second "deconstruct" fails -- no append call occurs
+ const beforeCount = state.focusedContributions.length;
+
+ // Simulate failure: the contribution is NOT added
+ expect(state.focusedContributions).toHaveLength(beforeCount);
+ });
+
+ it("contribution fields survive exact LLM round-trip through append", () => {
+ const rawLLMResponse = {
+ targetNodeId: "nk-003",
+ observations: [
+ "The founder has deep tacit knowledge of operational processes.",
+ "Documentation exists but is outdated.",
+ ],
+ uncertainties: ["Whether the founder's knowledge can be transferred without loss."],
+ assumptions: ["That the board understands the documentation gap."],
+ relationships: [
+ { from: "founder", to: "processes", type: "holds" },
+ { from: "docs", to: "knowledge", type: "partially_captures" },
+ ],
+ possibleFollowUpQuestions: [
+ "What processes are undocumented?",
+ "Who in the board is most aware of this gap?",
+ ],
+ };
+
+ const contrib = simulateAppendContributions([], {
+ ...rawLLMResponse,
+ question: "How much operational knowledge is undocumented?",
+ answer: "Most of it -- the founder's mind is the repository.",
+ });
+
+ expect(contrib[0].id).toBeTruthy();
+ expect(contrib[0].targetNodeId).toBe("nk-003");
+ expect(contrib[0].observations).toEqual(rawLLMResponse.observations);
+ expect(contrib[0].uncertainties).toEqual(rawLLMResponse.uncertainties);
+ expect(contrib[0].assumptions).toEqual(rawLLMResponse.assumptions);
+ expect(contrib[0].relationships).toEqual(rawLLMResponse.relationships);
+ expect(contrib[0].possibleFollowUpQuestions).toEqual(rawLLMResponse.possibleFollowUpQuestions);
+ });
+
+ it("full lifecycle: deconstruct -> append -> save -> reload -> second deconstruct -> append again", () => {
+ // Phase 1: First deconstruct
+ let state = { scenario: "test", situationGraph: {}, focusedContributions: [] };
+
+ const contrib1 = simulateDeconstructResponse({ targetNodeId: "nk-x", targetLabel: "Thread 1" });
+ state.focusedContributions = simulateAppendContributions(state.focusedContributions, {
+ targetNodeId: contrib1.targetNodeId, question: "Initial Q?", answer: "Initial A.", ...contrib1,
+ });
+
+ // Save + reload simulates user leaving and returning
+ let session = simulateSaveSession(state);
+ state = simulateRestoreSession(session) || { scenario: "test", situationGraph: {}, focusedContributions: [] };
+
+ expect(state.focusedContributions).toHaveLength(1);
+ expect(state.focusedContributions[0].question).toBe("Initial Q?");
+
+ // Phase 2: Second deconstruct after reload
+ const contrib2 = simulateDeconstructResponse({ targetNodeId: "nk-x", targetLabel: "Thread 1 follow-up" });
+ state.focusedContributions = simulateAppendContributions(state.focusedContributions, {
+ targetNodeId: contrib2.targetNodeId, question: "Follow-up Q?", answer: "Follow-up A.", ...contrib2,
+ });
+
+ expect(state.focusedContributions).toHaveLength(2);
+ expect(state.focusedContributions[0].question).toBe("Initial Q?"); // First preserved
+ expect(state.focusedContributions[1].question).toBe("Follow-up Q?"); // Second appended
+ expect(state.focusedContributions[0].targetNodeId).toBe(state.focusedContributions[1].targetNodeId);
+
+ // Final save confirms both survive
+ session = simulateSaveSession(state);
+ state = simulateRestoreSession(session);
+ expect(state.focusedContributions).toHaveLength(2);
+ });
+});
diff --git a/tests/reopen-thread-state.test.jsx b/tests/reopen-thread-state.test.jsx
new file mode 100644
index 0000000..f4d2504
--- /dev/null
+++ b/tests/reopen-thread-state.test.jsx
@@ -0,0 +1,384 @@
+/**
+ * Regression: Reopen a Done-for-now thread should resume from accumulated
+ * contribution history instead of restarting from the original focused question.
+ *
+ * Invariants verified by these simulations (mirroring reasoning-workspace.jsx
+ * logic exactly):
+ * A. Fresh thread → startFocused resets + formulates (unchanged)
+ * B. Reopened+has → startFocused preserves accumulated state, no formulate
+ * C. Prior contrib preserved and visible
+ * D. No follow-up auto-selected
+ * E. User selects follow-up B -> B becomes active, same original targetNodeId
+ * F. Submit answer to B -> new contribution appended, previous still present
+ */
+
+import { describe, expect, it } from "vitest";
+
+// --- helpers that mirror production code exactly ---
+
+function simulateAppendContribution(contributions, contribution) {
+ return [
+ ...contributions,
+ {
+ ...contribution,
+ id: `contrib-${String(contributions.length + 1).padStart(4, "0")}`,
+ sequence: contributions.length + 1,
+ },
+ ];
+}
+
+/**
+ * Simulates startFocused(nodeId) with priorContribs check.
+ * Mirrors reasoning-workspace.jsx:1118-1158 exactly.
+ * Returns {investigations, formulationCalled}.
+ */
+function simulateStartFocused(nodeId, focusedInvestigations, focusedContributions) {
+ const target = nodeId;
+ let updatedInvestigations = { ...focusedInvestigations };
+ let formulationCalled = false;
+
+ const priorContribs = (focusedContributions || []).filter(
+ (c) => c.targetNodeId === target,
+ );
+
+ if (priorContribs.length > 0) {
+ // Reopen path - resumes from accumulated contribution history.
+ const latest = priorContribs[priorContribs.length - 1];
+
+ updatedInvestigations = {
+ ...updatedInvestigations,
+ [target]: {
+ status: "formulated",
+ question: latest.question || "",
+ answer: latest.answer ?? null,
+ result: latest.possibleFollowUpQuestions
+ ? {
+ observations: latest.observations || [],
+ uncertainties: latest.uncertainties || [],
+ assumptions: latest.assumptions || [],
+ relationships: latest.relationships || [],
+ possibleFollowUpQuestions: latest.possibleFollowUpQuestions,
+ }
+ : null,
+ error: null,
+ },
+ };
+
+ // doFormulate is NOT called
+ formulationCalled = false;
+ } else {
+ // Fresh thread path - unchanged original behaviour.
+ updatedInvestigations = {
+ ...updatedInvestigations,
+ [target]: {
+ status: "formulating",
+ question: "",
+ answer: null,
+ result: null,
+ error: null,
+ },
+ };
+ formulationCalled = true;
+ }
+
+ return { investigations: updatedInvestigations, formulationCalled };
+}
+
+/** Simulates setFollowUpQuestion(followUpText). */
+function simulateSetFollowUpQuestion(investigations, targetNodeId, followUpText) {
+ const updated = { ...investigations };
+ if (!updated[targetNodeId]) return updated;
+ updated[targetNodeId] = {
+ ...updated[targetNodeId],
+ question: followUpText.trim(),
+ answer: null,
+ };
+ return updated;
+}
+
+/** Simulates handleDeconstructSubmit -> appends contribution + updates investigations. */
+function simulateDeconstructSubmit(investigations, focusedContributions, targetNodeId, answerText, deconstructResult) {
+ // Append contribution
+ const newContrib = simulateAppendContribution(focusedContributions, {
+ targetNodeId,
+ question: investigations[targetNodeId]?.question || "",
+ answer: answerText,
+ observations: deconstructResult.observations || [],
+ uncertainties: deconstructResult.uncertainties || [],
+ assumptions: deconstructResult.assumptions || [],
+ relationships: deconstructResult.relationships || [],
+ possibleFollowUpQuestions: deconstructResult.possibleFollowUpQuestions || [],
+ });
+
+ // Update investigations entry
+ const updatedInvestigations = {
+ ...investigations,
+ [targetNodeId]: {
+ ...investigations[targetNodeId],
+ result: deconstructResult,
+ answer: answerText,
+ },
+ };
+
+ return { investigations: updatedInvestigations, contributions: newContrib };
+}
+
+// --- A. Fresh thread reopen retains existing fresh-thread behaviour ---
+
+describe("reopen - fresh thread (no prior contributions)", () => {
+ it("A: startFocused resets state and calls doFormulate when no contributions exist", () => {
+ const nodeId = "nk-fresh";
+ const initialInvestigations = {};
+ const contributions = []; // empty - this is a fresh thread
+
+ const result = simulateStartFocused(nodeId, initialInvestigations, contributions);
+
+ expect(result.formulationCalled).toBe(true);
+ expect(result.investigations[nodeId].status).toBe("formulating");
+ expect(result.investigations[nodeId].question).toBe("");
+ expect(result.investigations[nodeId].result).toBeNull();
+ });
+
+ it("A2: existing investigation state for a different node is preserved", () => {
+ const freshId = "nk-fresh-2";
+ const otherId = "nk-other";
+
+ const priorState = {
+ [otherId]: {
+ status: "formulated",
+ question: "Some other question?",
+ answer: "Answer to other",
+ result: null,
+ error: null,
+ },
+ };
+
+ const result = simulateStartFocused(freshId, priorState, []);
+
+ expect(result.investigations[otherId].question).toBe("Some other question?");
+ expect(result.investigations[freshId].status).toBe("formulating");
+ });
+});
+
+// --- B. Reopened thread with contributions does NOT restart from original question ---
+
+describe("reopen - thread with prior contributions", () => {
+ it("B: startFocused does NOT call doFormulate when prior contributions exist", () => {
+ const nodeId = "nk-thread";
+ const contribution = simulateAppendContribution([], {
+ targetNodeId: nodeId,
+ question: "What is the founder's tacit knowledge?",
+ answer: "Most processes are undocumented.",
+ observations: ["documentation is minimal"],
+ uncertainties: ["formal docs can't capture tacit knowledge"],
+ assumptions: ["documentation is primary mechanism"],
+ relationships: [{ from: "founder", to: "processes", type: "holds" }],
+ possibleFollowUpQuestions: [
+ "What specific processes does the founder hold?",
+ "How does knowledge transfer work in practice?",
+ ],
+ });
+
+ const result = simulateStartFocused(nodeId, {}, contribution);
+
+ expect(result.formulationCalled).toBe(false);
+ expect(result.investigations[nodeId].status).toBe("formulated");
+ expect(result.investigations[nodeId].question).toBe("What is the founder's tacit knowledge?");
+ expect(result.investigations[nodeId].result.possibleFollowUpQuestions).toEqual([
+ "What specific processes does the founder hold?",
+ "How does knowledge transfer work in practice?",
+ ]);
+ });
+
+ it("B2: question from original (not new formulation) is preserved", () => {
+ const nodeId = "nk-thread-orig";
+ const origQuestion = "Can the founder's processes be captured in documentation?";
+ const contribution = simulateAppendContribution([], {
+ targetNodeId: nodeId,
+ question: origQuestion,
+ answer: "Partially - but critical knowledge remains tacit.",
+ observations: [],
+ uncertainties: [],
+ assumptions: [],
+ relationships: [],
+ possibleFollowUpQuestions: ["What specific processes are lost?"],
+ });
+
+ const result = simulateStartFocused(nodeId, {}, contribution);
+
+ expect(result.investigations[nodeId].question).toBe(origQuestion);
+ });
+});
+
+// --- C. Prior contribution remains visible/preserved ---
+
+describe("prior contribution preservation", () => {
+ it("C: prior contribution fields survive reopen", () => {
+ const nodeId = "nk-preserve";
+ const contrib = simulateAppendContribution([], {
+ targetNodeId: nodeId,
+ question: "What are the core assumptions?",
+ answer: "That the board has full information.",
+ observations: ["board receives monthly reports"],
+ uncertainties: ["whether reports contain sufficient detail"],
+ assumptions: ["monthly cadence is adequate"],
+ relationships: [{ from: "reports", to: "decisions", type: "informs" }],
+ possibleFollowUpQuestions: ["What if reports are incomplete?"],
+ });
+
+ const result = simulateStartFocused(nodeId, {}, contrib);
+
+ expect(result.investigations[nodeId].answer).toBe("That the board has full information.");
+ expect(result.investigations[nodeId].result.observations).toEqual(["board receives monthly reports"]);
+ expect(result.investigations[nodeId].result.uncertainties).toEqual(["whether reports contain sufficient detail"]);
+ expect(result.investigations[nodeId].result.assumptions).toEqual(["monthly cadence is adequate"]);
+ expect(result.investigations[nodeId].result.relationships).toEqual([
+ { from: "reports", to: "decisions", type: "informs" },
+ ]);
+ });
+
+ it("C2: multiple prior contributions - latest is restored", () => {
+ const nodeId = "nk-multi";
+ let contributions = simulateAppendContribution([], {
+ targetNodeId: nodeId,
+ question: "Q1?",
+ answer: "A1",
+ observations: ["o1"],
+ uncertainties: ["u1"],
+ assumptions: ["a1"],
+ relationships: [],
+ possibleFollowUpQuestions: ["fuq-from-Q1"],
+ });
+
+ contributions = simulateAppendContribution(contributions, {
+ targetNodeId: nodeId,
+ question: "Q2?",
+ answer: "A2",
+ observations: ["o2"],
+ uncertainties: ["u2"],
+ assumptions: ["a2"],
+ relationships: [],
+ possibleFollowUpQuestions: ["fuq-from-Q2"],
+ });
+
+ const result = simulateStartFocused(nodeId, {}, contributions);
+
+ expect(result.investigations[nodeId].question).toBe("Q2?");
+ expect(result.investigations[nodeId].answer).toBe("A2");
+ expect(result.investigations[nodeId].result.observations).toEqual(["o2"]);
+ });
+});
+
+// --- D. No follow-up auto-selected after reopen ---
+
+describe("no automatic follow-up selection on reopen", () => {
+ it("D: reopened thread shows follow-ups but does not set any as active", () => {
+ const nodeId = "nk-no-auto";
+ const contributions = simulateAppendContribution([], {
+ targetNodeId: nodeId,
+ question: "What remains unclear?",
+ answer: "The transition timeline.",
+ observations: [],
+ uncertainties: ["timing is uncertain"],
+ assumptions: [],
+ relationships: [],
+ possibleFollowUpQuestions: ["What triggers Phase 2?", "Who owns Phase 3?"],
+ });
+
+ const result = simulateStartFocused(nodeId, {}, contributions);
+
+ // Follow-ups are visible in result but not auto-selected as the active question.
+ expect(result.investigations[nodeId].result.possibleFollowUpQuestions).toHaveLength(2);
+ // The active question remains what it was from prior work (not a follow-up).
+ expect(result.investigations[nodeId].question).toBe("What remains unclear?");
+ });
+});
+
+// --- E. User selects follow-up B -> B becomes active ---
+
+describe("follow-up selection after reopen", () => {
+ it("E: selecting a follow-up question updates the focused question, retains targetNodeId", () => {
+ const nodeId = "nk-followup-e";
+ let contributions = simulateAppendContribution([], {
+ targetNodeId: nodeId,
+ question: "What are the key risks?",
+ answer: "Regulatory and market risks.",
+ observations: [],
+ uncertainties: [],
+ assumptions: [],
+ relationships: [],
+ possibleFollowUpQuestions: ["Follow-up A", "Follow-up B"],
+ });
+
+ let { investigations } = simulateStartFocused(nodeId, {}, contributions);
+
+ // Verify the question from the contribution is active (not a follow-up)
+ expect(investigations[nodeId].question).toBe("What are the key risks?");
+
+ // User clicks on "Follow-up B"
+ investigations = simulateSetFollowUpQuestion(investigations, nodeId, "Follow-up B");
+
+ expect(investigations[nodeId].question).toBe("Follow-up B");
+ // targetNodeId is unchanged (the original graph node ID is retained)
+ expect(contributions[0].targetNodeId).toBe(nodeId);
+ });
+});
+
+// --- F. Submit answer to follow-up -> appends contribution ---
+
+describe("submitting follow-up answer appends new contribution", () => {
+ it("F: deconstruct on follow-up B appends new contribution, preserves prior", () => {
+ const nodeId = "nk-append-f";
+
+ // Step 1: initial contribution (from prior session)
+ let contributions = simulateAppendContribution([], {
+ targetNodeId: nodeId,
+ question: "Initial investigation question?",
+ answer: "Initial answer.",
+ observations: ["o1"],
+ uncertainties: ["u1"],
+ assumptions: [],
+ relationships: [],
+ possibleFollowUpQuestions: ["Follow-up A", "Follow-up B"],
+ });
+
+ // Step 2: reopen (simulated by startFocused restoring)
+ let result = simulateStartFocused(nodeId, {}, contributions);
+ let investigations = result.investigations;
+
+ expect(investigations[nodeId].question).toBe("Initial investigation question?");
+
+ // Step 3: user selects follow-up B
+ investigations = simulateSetFollowUpQuestion(investigations, nodeId, "Follow-up B");
+ expect(investigations[nodeId].question).toBe("Follow-up B");
+
+ // Step 4: submit answer to follow-up B (re-enters existing deconstruct path)
+ const deconstructResult = {
+ observations: ["o2", "o3"],
+ uncertainties: ["u2"],
+ assumptions: ["a1"],
+ relationships: [{ from: "x", to: "y", type: "depends_on" }],
+ possibleFollowUpQuestions: ["Next-level question?"],
+ };
+
+ const submitResult = simulateDeconstructSubmit(
+ investigations,
+ contributions,
+ nodeId,
+ "Answer to follow-up B.",
+ deconstructResult,
+ );
+
+ // New contribution appended
+ expect(submitResult.contributions).toHaveLength(2);
+ expect(submitResult.contributions[1].question).toBe("Follow-up B");
+ expect(submitResult.contributions[1].answer).toBe("Answer to follow-up B.");
+
+ // Previous contribution still present and unchanged
+ expect(submitResult.contributions[0].question).toBe("Initial investigation question?");
+ expect(submitResult.contributions[0].targetNodeId).toBe(nodeId);
+
+ // Same original targetNodeId retained
+ expect(submitResult.contributions[0].targetNodeId).toBe(submitResult.contributions[1].targetNodeId);
+ });
+});