diff --git a/app/api/focused-investigation/deconstruct/route.js b/app/api/focused-investigation/deconstruct/route.js index d0c6657..c7c092b 100644 --- a/app/api/focused-investigation/deconstruct/route.js +++ b/app/api/focused-investigation/deconstruct/route.js @@ -72,7 +72,7 @@ export async function POST(request) { return Response.json({ success: true, - targetNodeId: raw.targetNodeId, + targetNodeId: body.targetNodeId, observations: raw.observations, uncertainties: raw.uncertainties, assumptions: raw.assumptions, diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index 7b09ceb..bd5369d 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -310,6 +310,88 @@ function EvidenceLimitCard({ summary }) { ); } +// ── Per-thread contribution badge (compact indicator) ────────────── + +function ThreadContributionsBadge({ nodeId, contributions }) { + const threadContribs = contributions.filter((c) => c.targetNodeId === nodeId); + if (!threadContribs.length) return null; + + // Show most recent contribution summary inline + const latest = threadContribs[threadContribs.length - 1]; + + const nonEmptyGroups = []; + for (const key of ["observations", "uncertainties", "assumptions", "relationships"]) { + const arr = latest[key]; + if (Array.isArray(arr) && arr.length > 0) nonEmptyGroups.push(key); + } + + return ( +
+ {/* Thread learning indicator — collapsed by default; user can expand to inspect history */} +
+ + 📝 {threadContribs.length} learned contribution{threadContribs.length !== 1 ? "s" : ""} + +
+ {/* All contributions listed in order */} + {threadContribs.map((c, idx) => ( +
+ {idx > 0 &&
Contribution #{c.sequence || idx + 1}
} + {/* What this tells us */} + {c.observations?.length ? ( +
+

What this tells us

+
    + {c.observations.map((o, i) => ( +
  • {o}
  • + ))} +
+
+ ) : null} + + {/* Still unclear */} + {c.uncertainties?.length ? ( +
+

Still unclear

+
    + {c.uncertainties.map((u, i) => ( +
  • {u}
  • + ))} +
+
+ ) : null} + + {/* Assumptions */} + {c.assumptions?.length ? ( +
+

Assumptions

+
    + {c.assumptions.map((a, i) => ( +
  • {a}
  • + ))} +
+
+ ) : null} + + {/* Connections */} + {c.relationships?.length ? ( +
+

Connections

+
    + {c.relationships.map((r, i) => ( +
  • {r.from} → {r.to} ({r.type})
  • + ))} +
+
+ ) : null} +
+ ))} +
+
+
+ ); +} + // ── Quiet facilitator state (experiment mode) ───────────────────── function QuietStateCard() { @@ -710,11 +792,12 @@ function OpenQuestionsPanel({ focused, formulationStep, formulateMsg, processingStep, deconstructMsg, doneForNowIds, startFocused, handleDeconstructSubmit, retryFormulation, setSelectedPresentationItemId, setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds, - setFollowUpQuestion, + setFollowUpQuestion, focusedContributions, }) { const openNodes = (graph?.nodes || []).filter( (n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id), ); + if (openNodes.length <= 1) return null; return ( @@ -788,8 +871,6 @@ function OpenQuestionsPanel({ <>

What this tells us

Still unclear

-

Assumptions

-

Connections

Questions this raises

{(focused.result.possibleFollowUpQuestions || []).length > 0 ? (
@@ -809,6 +890,8 @@ function OpenQuestionsPanel({

None yet

)}
+

Assumptions

+

Connections

)} @@ -825,6 +908,9 @@ function OpenQuestionsPanel({
); })()} + + {/* Thread contributions for this node */} + ); @@ -836,9 +922,10 @@ function OpenQuestionsPanel({

Done for now

{graph.nodes.filter((n) => doneForNowIds.includes(n.id)).map((node) => ( -
+

{node.label}

- + +
))}
@@ -930,6 +1017,7 @@ export default function ReasoningWorkspace({ const graph = result?.situationGraph ?? null; const hasGraph = Boolean(graph); + const diagnostics = result?.diagnostics ?? null; const newlySurfacedNodeIds = result?.newlySurfacedNodeIds || []; const genuineCompletion = hasGenuineCompletion(graph); @@ -1020,13 +1108,56 @@ export default function ReasoningWorkspace({ const focused = getFocusedInvestigation(); + // Gate evidence-limit card: do NOT show when active investigation paths remain. + const showEvidenceLimit = !( + processingStep === "active" || + (focused?.question?.trim() && !processingStep) || + (hasGraph && !genuineCompletion) + ); + // ── RTO.13B — workflow handlers ────────────────────────────── function startFocused(nodeId) { const target = nodeId || focusedPresentationItemId; if (!target) return; + + const priorContribs = (focusedContributions || []).filter( + (c) => c.targetNodeId === target, + ); + setFocusedPresentationItemId(target); setFocusedAnswer(""); + + if (priorContribs.length > 0) { + // Reopen path: resume from accumulated contribution history. + // Do NOT call doFormulate — the user's prior investigation direction + // is preserved; only follow-up questions surface for explicit selection. + const latest = priorContribs[priorContribs.length - 1]; + + setFocusedInvestigations((prev) => ({ + ...prev, + [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, + }, + })); + + setFormulationStep("idle"); + return; + } + + // Fresh thread path — unchanged original behaviour. setFormulationStep("active"); setFocusedInvestigations((prev) => ({ ...prev, @@ -1103,6 +1234,7 @@ export default function ReasoningWorkspace({ }); setProcessingStep("idle"); + setFocusedInvestigations((prev) => ({ ...prev, [targetNodeId]: { ...prev[targetNodeId], result: data, answer: answerText, error: null }, @@ -1230,68 +1362,24 @@ export default function ReasoningWorkspace({ {/* ── RTO.29D — initial post-Analyse reflection ──────── */} {postAnalyseStatus === "success" && (
- {/* Current Understanding + Situation — prominent two-column orienting surface */} -
-
- {/* Current Understanding — prominent orienting surface */} -
-

- Current Understanding -

-

{propUnderstanding}

-
- - {/* Initial proposed findings — unknowns + plausible interpretations from reconstruction */} -
-

- Open Questions -

- {(() => { - const resolvedIds = new Set(graph?.resolvedNodeIds || []); - // Surface only candidate items from the semantic reconstruction that are worth investigating: - // — unknowns (importantUnknowns from the LLM's reconstruction) - // — assumptions (plausibleInterpretations from the LLM's reconstruction) - // Skips observations, states, relationships, transitions — these are already established facts/context. - // Both kinds check status !== "resolved" and excluded resolvedIds to mirror OpenQuestionsPanel logic. - const candidateKinds = ["unknown", "assumption"]; - return ( - (graph?.nodes || []) - .filter( - (n) => - candidateKinds.includes(n.kind) && - n.status !== "resolved" && - !resolvedIds.has(n.id), - ) - .map((node) => { - const tag = node.kind === "assumption" ? "Plausible interpretation" : "Unclear"; - return ( - - ); - }) - ); - })()} -
+ {/* Current Understanding + Situation — independent vertical flow */} +
+ {/* Current Understanding — prominent orienting surface */} +
+

+ Current Understanding +

+

{propUnderstanding}

{/* Situation panel during initial reflection */} {hasGraph && ( -
+
)} {!hasGraph && scenario && ( -
+

Situation @@ -1303,20 +1391,70 @@ export default function ReasoningWorkspace({

)}
-
- )} + + {/* Initial proposed findings — unknowns + plausible interpretations from reconstruction */} +
+

+ Open Questions +

+ {(() => { + const resolvedIds = new Set(graph?.resolvedNodeIds || []); + // Surface only candidate items from the semantic reconstruction that are worth investigating: + // — unknowns (importantUnknowns from the LLM's reconstruction) + // — assumptions (plausibleInterpretations from the LLM's reconstruction) + // Skips observations, states, relationships, transitions — these are already established facts/context. + // Both kinds check status !== "resolved" and excluded resolvedIds to mirror OpenQuestionsPanel logic. + const candidateKinds = ["unknown", "assumption"]; + return ( + (graph?.nodes || []) + .filter( + (n) => + candidateKinds.includes(n.kind) && + n.status !== "resolved" && + !resolvedIds.has(n.id), + ) + .map((node) => { + const tag = node.kind === "assumption" ? "Plausible interpretation" : "Unclear"; + return ( + + ); + }) + ); + })()} +
+
+ )} + {/* ── Workspace grid: persistent whenever a graph exists ─── */} {hasGraph && (
- {/* ── Left lane: active conversation & notebook ───────── */} -
+ + {/* Current Understanding — independent row, full-width of left area (cols 1-2) */} + {propUnderstanding && hasCurrentSummaryCondition && postAnalyseStatus !== "success" && ( +
+ +
+ )} + + {/* Left column below Understanding: Investigation + Open Questions */} +
{/* Current investigation (prominent hero section) */} {postAnalyseStatus !== "success" && ( )} - {/* ── Open questions (case workspace) — no ranking bias ── */} {postAnalyseStatus !== "success" && ( )} {/* Terminal state — suppressed during initial reflection */} - {postAnalyseStatus !== "success" && status === "success" && !hasSelectedQuestion && ( + {postAnalyseStatus !== "success" && status === "success" && !hasSelectedQuestion && showEvidenceLimit && ( <> {genuineCompletion && ( @@ -1357,84 +1496,71 @@ export default function ReasoningWorkspace({ )} - {/* Supporting context within conversation lane */} - {hasCurrentSummaryCondition && ( - <> - {postAnalyseStatus !== "success" && ( - - )} - {/* ── Experiment 12: progress panel A / B / C toggle (temporary experimental UI) — de-emphasised by branch-as-context experiment RTO.25D */} - {hasGraph && ( -
-
- - / - - / - -
-
- {panelVariant === "a" - ? - : panelVariant === "b" - ? - : - } -
-
- )} - + {/* ── Experiment 12: progress panel A / B / C toggle (temporary experimental UI) — de-emphasised by branch-as-context experiment RTO.25D */} + {hasGraph && ( +
+
+ + / + + / + +
+
+ {panelVariant === "a" + ? + : panelVariant === "b" + ? + : + } +
+
)}
- {/* ── Right lane: stable supporting reference ───────── */} + {/* ── Right lane: stable supporting reference (independent column) ───────── */} {hasCurrentSummaryCondition && ( -
- {/* RTO.26B — situation card from scenario text when no graph */} - {!graph && scenario && ( -
-

- Situation -

-

- {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); + }); +});