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:
@@ -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,
|
||||
|
||||
@@ -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,17 +1362,36 @@ 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 + 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">
|
||||
<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:max-w-xs">
|
||||
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement} />
|
||||
</div>
|
||||
)}
|
||||
{!hasGraph && scenario && (
|
||||
<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
|
||||
</h2>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-600">
|
||||
{scenario}
|
||||
</p>
|
||||
</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">
|
||||
@@ -1283,40 +1434,27 @@ export default function ReasoningWorkspace({
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Situation panel during initial reflection */}
|
||||
{hasGraph && (
|
||||
<div className="space-y-6 lg:col-span-1">
|
||||
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement} />
|
||||
</div>
|
||||
)}
|
||||
{!hasGraph && scenario && (
|
||||
<div className="space-y-6 lg:col-span-1">
|
||||
<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>
|
||||
)}
|
||||
</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,12 +1496,6 @@ 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">
|
||||
@@ -1416,25 +1549,18 @@ export default function ReasoningWorkspace({
|
||||
</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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user