feat(confidence-engine): stabilize user-directed investigation flow

Intentional changes in this checkpoint:
- Deconstruct route: use body.targetNodeId (client identity) over raw.model-invented ID
- ThreadContributionsBadge: compact per-thread contribution indicator with expandable history
- Reopen continuation: resume from accumulated contributions instead of reformulating
- showEvidenceLimit gate: hide evidence-limit card during active investigation paths
- Evidence-limit visibility correction in rendering pipeline
- Section ordering: assumptions and connections after 'Still unclear' in focused result
- Prompt v0.3: preserve user-stated alternatives as separate unknowns; no count inflation
- 3 durable regression tests (target identity, contribution persistence, reopen state)
- evidence-limit card visibility gate test suite

Temporary residue removed:
- test-analysis.mjs (scratch diagnostic)
- 5 diagnostic console.log blocks from reasoning-workspace.jsx
This commit is contained in:
2026-08-23 12:05:51 +01:00
parent 96ad0e7915
commit 01c57788ee
7 changed files with 1258 additions and 141 deletions
@@ -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,
+264 -138
View File
@@ -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 (
<div className="mt-3">
{/* Thread learning indicator — collapsed by default; user can expand to inspect history */}
<details open={false} className="rounded-lg border border-gray-200/80 bg-white/60">
<summary className="cursor-pointer px-3 py-1.5 text-xs font-medium text-gray-600 hover:text-gray-800 select-none">
📝 {threadContribs.length} learned contribution{threadContribs.length !== 1 ? "s" : ""}
</summary>
<div className="px-3 pb-3 pt-1 space-y-4">
{/* All contributions listed in order */}
{threadContribs.map((c, idx) => (
<div key={c.id || idx} className="space-y-2">
{idx > 0 && <div className="text-[9px] text-gray-400 tracking-wider uppercase mt-3">Contribution #{c.sequence || idx + 1}</div>}
{/* What this tells us */}
{c.observations?.length ? (
<div>
<h4 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h4>
<ul className="list-disc pl-5 space-y-0.5">
{c.observations.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null}
{/* Still unclear */}
{c.uncertainties?.length ? (
<div>
<h4 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h4>
<ul className="list-disc pl-5 space-y-0.5">
{c.uncertainties.map((u, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{u}</li>
))}
</ul>
</div>
) : null}
{/* Assumptions */}
{c.assumptions?.length ? (
<div>
<h4 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">Assumptions</h4>
<ul className="list-disc pl-5 space-y-0.5">
{c.assumptions.map((a, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{a}</li>
))}
</ul>
</div>
) : null}
{/* Connections */}
{c.relationships?.length ? (
<div>
<h4 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">Connections</h4>
<ul className="list-disc pl-5 space-y-0.5">
{c.relationships.map((r, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{r.from} {r.to} ({r.type})</li>
))}
</ul>
</div>
) : null}
</div>
))}
</div>
</details>
</div>
);
}
// ── 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({
<>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.observations || []).map((o, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{o}</li>))}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.uncertainties || []).map((u, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{u}</li>))}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Assumptions</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.assumptions || []).map((a, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{a}</li>))}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Connections</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.relationships || []).map((r, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{r.from} {r.to} ({r.type})</li>))}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Questions this raises</h3>
{(focused.result.possibleFollowUpQuestions || []).length > 0 ? (
<div className="space-y-1 mt-1">
@@ -809,6 +890,8 @@ function OpenQuestionsPanel({
<p className="text-xs text-gray-400">None yet</p>
)}
</div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Assumptions</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.assumptions || []).map((a, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{a}</li>))}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Connections</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.relationships || []).map((r, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{r.from} {r.to} ({r.type})</li>))}</ul></div>
</>
)}
@@ -825,6 +908,9 @@ function OpenQuestionsPanel({
</div>
);
})()}
{/* Thread contributions for this node */}
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} />
</div>
</div>
);
@@ -836,9 +922,10 @@ function OpenQuestionsPanel({
<h3 className="mb-2 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Done for now</h3>
<div className="space-y-1">
{graph.nodes.filter((n) => doneForNowIds.includes(n.id)).map((node) => (
<div key={node.id} className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3">
<div key={node.id} className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3 space-y-1">
<p className="text-sm text-gray-500 leading-snug">{node.label}</p>
<button onClick={() => setDoneForNowIds(doneForNowIds.filter(id => id !== node.id))} style={{ cursor: "pointer" }} className="mt-2 rounded border border-gray-300 px-3 py-1 text-xs font-medium text-gray-500 hover:bg-white transition">Reopen</button>
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} />
<button onClick={() => setDoneForNowIds(doneForNowIds.filter(id => id !== node.id))} style={{ cursor: "pointer" }} className="mt-1 rounded border border-gray-300 px-3 py-1 text-xs font-medium text-gray-500 hover:bg-white transition">Reopen</button>
</div>
))}
</div>
@@ -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" && (
<div className="space-y-6" data-testid="initial-reflection-surface">
{/* Current Understanding + Situation — prominent two-column orienting surface */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className={`space-y-6 ${hasGraph ? 'lg:col-span-2' : 'lg:col-span-full'}`}>
{/* Current Understanding — prominent orienting surface */}
<div className="rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-7 pb-8 shadow-sm">
<h2 className="mb-4 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/60">
Current Understanding
</h2>
<p className="text-lg leading-relaxed text-gray-800">{propUnderstanding}</p>
</div>
{/* Initial proposed findings — unknowns + plausible interpretations from reconstruction */}
<div className="space-y-3" data-testid="initial-proposed-findings">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Open Questions
</h2>
{(() => {
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 (
<button
key={node.id}
onClick={() => startFocused(node.id)}
style={{ cursor: "pointer" }}
className="w-full text-left rounded-lg border border-gray-200 bg-white px-5 py-4 transition hover:border-gray-300 hover:bg-gray-50"
>
<span className="block text-sm leading-relaxed text-gray-900">{node.label}</span>
{node.description && node.description !== node.label && (
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
)}
<span className="mt-2 block text-[10px] uppercase tracking-wider text-gray-400">{tag}</span>
</button>
);
})
);
})()}
</div>
{/* Current Understanding + Situation — independent vertical flow */}
<div className="flex gap-3 items-start flex-wrap">
{/* Current Understanding — prominent orienting surface */}
<div className={`rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-7 pb-8 shadow-sm flex-1 min-w-0 ${hasGraph ? 'lg:max-w-2xl' : ''}`}>
<h2 className="mb-4 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/60">
Current Understanding
</h2>
<p className="text-lg leading-relaxed text-gray-800">{propUnderstanding}</p>
</div>
{/* Situation panel during initial reflection */}
{hasGraph && (
<div className="space-y-6 lg:col-span-1">
<div className="space-y-6 lg:max-w-xs">
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement} />
</div>
)}
{!hasGraph && scenario && (
<div className="space-y-6 lg:col-span-1">
<div className="space-y-6 lg:max-w-xs">
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-5 py-4">
<h2 className="mb-2 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Situation
@@ -1303,20 +1391,70 @@ export default function ReasoningWorkspace({
</div>
)}
</div>
</div>
)}
{/* Initial proposed findings — unknowns + plausible interpretations from reconstruction */}
<div className="space-y-3" data-testid="initial-proposed-findings">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Open Questions
</h2>
{(() => {
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 (
<button
key={node.id}
onClick={() => startFocused(node.id)}
style={{ cursor: "pointer" }}
className="w-full text-left rounded-lg border border-gray-200 bg-white px-5 py-4 transition hover:border-gray-300 hover:bg-gray-50"
>
<span className="block text-sm leading-relaxed text-gray-900">{node.label}</span>
{node.description && node.description !== node.label && (
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
)}
<span className="mt-2 block text-[10px] uppercase tracking-wider text-gray-400">{tag}</span>
</button>
);
})
);
})()}
</div>
</div>
)}
{/* ── Workspace grid: persistent whenever a graph exists ─── */}
{hasGraph && (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* ── Left lane: active conversation & notebook ───────── */}
<div className={`space-y-6 ${hasGraph ? 'lg:col-span-2' : 'lg:col-span-full'}`}>
{/* Current Understanding — independent row, full-width of left area (cols 1-2) */}
{propUnderstanding && hasCurrentSummaryCondition && postAnalyseStatus !== "success" && (
<div className="lg:row-start-1 lg:col-span-full rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-7 pb-8 shadow-sm">
<CurrentUnderstandingCard currentSummary={graph?.currentSummary || result?.updatedSituationGraph?.currentSummary} plainLanguage={propUnderstanding} />
</div>
)}
{/* Left column below Understanding: Investigation + Open Questions */}
<div className="lg:row-start-2 lg:col-start-1 lg:col-span-2 space-y-6">
{/* Current investigation (prominent hero section) */}
{postAnalyseStatus !== "success" && (
<CurrentInvestigationCard selectedQuestion={result?.selectedQuestion} graph={graph} />
)}
{/* ── Open questions (case workspace) — no ranking bias ── */}
{postAnalyseStatus !== "success" && (
<OpenQuestionsPanel
graph={graph}
@@ -1338,11 +1476,12 @@ export default function ReasoningWorkspace({
focusedAnswer={focusedAnswer}
setDoneForNowIds={setDoneForNowIds}
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={focusedContributions}
/>
)}
{/* Terminal state — suppressed during initial reflection */}
{postAnalyseStatus !== "success" && status === "success" && !hasSelectedQuestion && (
{postAnalyseStatus !== "success" && status === "success" && !hasSelectedQuestion && showEvidenceLimit && (
<>
{genuineCompletion && (
<CompletionCard summary={resolveCurrentSummary(propUnderstanding || graph?.currentSummary || result?.updatedSituationGraph?.currentSummary)} />
@@ -1357,84 +1496,71 @@ export default function ReasoningWorkspace({
<InvestigationHistory turns={investigationHistory} />
)}
{/* Supporting context within conversation lane */}
{hasCurrentSummaryCondition && (
<>
{postAnalyseStatus !== "success" && (
<CurrentUnderstandingCard currentSummary={graph?.currentSummary || result?.updatedSituationGraph?.currentSummary} plainLanguage={propUnderstanding || null} />
)}
{/* ── Experiment 12: progress panel A / B / C toggle (temporary experimental UI) — de-emphasised by branch-as-context experiment RTO.25D */}
{hasGraph && (
<div className="hidden space-y-2">
<div className="flex items-center gap-2" role="radiogroup" aria-label="Progress panel variant">
<button
role="radio"
aria-checked={panelVariant === "a"}
onClick={() => setPanelVariant("a")}
onKeyDown={(e) => {
if (e.key === "ArrowRight") setPanelVariant("b");
if (e.key === "ArrowLeft") setPanelVariant("c");
}}
className={`text-xs transition ${panelVariant === "a" ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
>
A
</button>
<span className="text-gray-300">/</span>
<button
role="radio"
aria-checked={panelVariant === "b"}
onClick={() => setPanelVariant("b")}
onKeyDown={(e) => {
if (e.key === "ArrowRight") setPanelVariant("c");
if (e.key === "ArrowLeft") setPanelVariant("a");
}}
className={`text-xs transition ${panelVariant === "b" ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
>
B
</button>
<span className="text-gray-300">/</span>
<button
role="radio"
aria-checked={panelVariant === "c"}
onClick={() => setPanelVariant("c")}
onKeyDown={(e) => {
if (e.key === "ArrowRight") setPanelVariant("a");
if (e.key === "ArrowLeft") setPanelVariant("b");
}}
className={`text-xs transition ${panelVariant === "c" ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
>
C (exp)
</button>
</div>
<div className="opacity-75">
{panelVariant === "a"
? <InvestigationSummaryPanel graph={graph} selectedQuestion={selectedQ} result={result} updateStatus={updateStatus} />
: panelVariant === "b"
? <InvestigationSummaryPanelV2 graph={graph} selectedQuestion={selectedQ} result={result} updateStatus={updateStatus} />
: <InvestigationSummaryPanelV3 graph={graph} selectedQuestion={selectedQ} result={result} />
}
</div>
</div>
)}
</>
{/* ── Experiment 12: progress panel A / B / C toggle (temporary experimental UI) — de-emphasised by branch-as-context experiment RTO.25D */}
{hasGraph && (
<div className="hidden space-y-2">
<div className="flex items-center gap-2" role="radiogroup" aria-label="Progress panel variant">
<button
role="radio"
aria-checked={panelVariant === "a"}
onClick={() => setPanelVariant("a")}
onKeyDown={(e) => {
if (e.key === "ArrowRight") setPanelVariant("b");
if (e.key === "ArrowLeft") setPanelVariant("c");
}}
className={`text-xs transition ${panelVariant === "a" ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
>
A
</button>
<span className="text-gray-300">/</span>
<button
role="radio"
aria-checked={panelVariant === "b"}
onClick={() => setPanelVariant("b")}
onKeyDown={(e) => {
if (e.key === "ArrowRight") setPanelVariant("c");
if (e.key === "ArrowLeft") setPanelVariant("a");
}}
className={`text-xs transition ${panelVariant === "b" ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
>
B
</button>
<span className="text-gray-300">/</span>
<button
role="radio"
aria-checked={panelVariant === "c"}
onClick={() => setPanelVariant("c")}
onKeyDown={(e) => {
if (e.key === "ArrowRight") setPanelVariant("a");
if (e.key === "ArrowLeft") setPanelVariant("b");
}}
className={`text-xs transition ${panelVariant === "c" ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
>
C (exp)
</button>
</div>
<div className="opacity-75">
{panelVariant === "a"
? <InvestigationSummaryPanel graph={graph} selectedQuestion={selectedQ} result={result} updateStatus={updateStatus} />
: panelVariant === "b"
? <InvestigationSummaryPanelV2 graph={graph} selectedQuestion={selectedQ} result={result} updateStatus={updateStatus} />
: <InvestigationSummaryPanelV3 graph={graph} selectedQuestion={selectedQ} result={result} />
}
</div>
</div>
)}
</div>
{/* ── Right lane: stable supporting reference ───────── */}
{/* ── Right lane: stable supporting reference (independent column) ───────── */}
{hasCurrentSummaryCondition && (
<div className="space-y-6 lg:col-span-1">
{/* RTO.26B — situation card from scenario text when no graph */}
{!graph && scenario && (
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-5 py-4">
<h2 className="mb-2 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Situation
</h2>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-600">
{scenario}
</p>
</div>
<div className="lg:row-start-2 lg:col-start-3 space-y-6">
{/* Situation — always here when condition met, independent of left column height */}
{(scenario || graph?.centralStatement) && (
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement || scenario} />
)}
{!propUnderstanding && graph && postAnalyseStatus !== "success" && (
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement} />
)}
{graph && postAnalyseStatus !== "success" && <OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement} />}
{/* RTO.25B — temporarily hidden to reduce competing navigation while branch-experiment is active */}
<div className="hidden">
<InvestigationMap turnCount={investigationHistory.length} />
+5 -2
View File
@@ -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
+106
View File
@@ -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);
});
});
});
+220
View File
@@ -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);
});
});
@@ -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);
});
});
+384
View File
@@ -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);
});
});