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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user