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