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
385 lines
14 KiB
React
385 lines
14 KiB
React
/**
|
|
* 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);
|
|
});
|
|
});
|