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
279 lines
12 KiB
JavaScript
279 lines
12 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|