2519 lines
99 KiB
JavaScript
2519 lines
99 KiB
JavaScript
import { describe, it, expect } from "vitest";
|
|
import fs from "fs";
|
|
import { fileURLToPath } from "url";
|
|
import path from "path";
|
|
import { situationGraphSchema } from "@/lib/graph/schema.js";
|
|
import {
|
|
validateGraphReferences,
|
|
detectDuplicateNodeIds,
|
|
} from "@/lib/graph/utils.js";
|
|
|
|
// ── Load committed decision-options fixture directly (60A.7) ─────────────
|
|
const DECISION_OPTIONS_FIXTURE_PATH = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"fixtures/pre-anchored-decision-options.json",
|
|
);
|
|
const DECISION_OPTIONS_FIXTURE = JSON.parse(fs.readFileSync(DECISION_OPTIONS_FIXTURE_PATH, "utf-8"));
|
|
|
|
const PRODUCT_LAUNCH_CUSTOMER_SIGNING_FIXTURE_PATH = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"fixtures/pre-anchored-product-launch-customer-signing.json",
|
|
);
|
|
const PRODUCT_LAUNCH_CUSTOMER_SIGNING_FIXTURE = JSON.parse(
|
|
fs.readFileSync(PRODUCT_LAUNCH_CUSTOMER_SIGNING_FIXTURE_PATH, "utf-8"),
|
|
);
|
|
|
|
/**
|
|
* Synchronous simulator of the pre-anchored update-only mode with a custom fixture.
|
|
* Mirrors what reproduce-multi-turn-investigation.mjs does when FIXTURE_MODE is set
|
|
* and an explicit graph (e.g. decision-options) is supplied via initialGraph.
|
|
*/
|
|
function runPreAnchoredSimulationWithFixture(cfg) {
|
|
let startCalls = 0;
|
|
let updateCalls = 0;
|
|
let apiLog = [];
|
|
|
|
const initialGraph = cfg?.initialGraph;
|
|
const answer = cfg?.answer ?? "Relocate. The £2M annual saving justifies the consequences.";
|
|
|
|
// ── Block on missing graph before any calls ──
|
|
if (initialGraph == null) {
|
|
return {
|
|
startCalls, updateCalls, type: "anchor_validation_failed", exitCode: 1,
|
|
anchorCount: 0, apiLog, nodes: [], edges: [],
|
|
};
|
|
}
|
|
|
|
// ── Block on missing ANSWER_2 before any calls (mirrors production behaviour) ──
|
|
if (!answer || String(answer).trim() === "") {
|
|
return {
|
|
startCalls, updateCalls, type: "blocked_no_answer", exitCode: 1,
|
|
blockedMessage: "BLOCKED - missing ANSWER_2", apiLog,
|
|
};
|
|
}
|
|
|
|
// Pre-anchored: no Start call — graph is supplied directly.
|
|
let graph = JSON.parse(JSON.stringify(initialGraph));
|
|
|
|
// Derive previousQuestion from the fixture's unresolved_question field.
|
|
const unresolvedQuestion = DECISION_OPTIONS_FIXTURE.unresolved_question;
|
|
let question = unresolvedQuestion || "Which option leaves us better off overall?";
|
|
|
|
// Track the exact Update request body for direct assertions.
|
|
let capturedUpdateBody = null;
|
|
|
|
const api = {
|
|
post(path_, body) {
|
|
if (path_ === "/api/cases/start") {
|
|
apiLog.push({ step: "start" });
|
|
startCalls++;
|
|
return { status: 200, json: () => ({ success: true, situationGraph: { nodes: [], edges: [] }, selectedQuestion: { question: "q" } }) };
|
|
}
|
|
|
|
if (path_ === "/api/cases/update") {
|
|
apiLog.push({ step: "update", answer: body.answer });
|
|
capturedUpdateBody = body;
|
|
updateCalls++;
|
|
|
|
const resp = typeof cfg.onResponseUpdate === "function"
|
|
? cfg.onResponseUpdate(updateCalls - 1)
|
|
: null;
|
|
|
|
if (resp) {
|
|
return { status: resp.success ? 200 : 422, json: () => resp };
|
|
}
|
|
|
|
// Default pre-anchored success response
|
|
return {
|
|
status: 200,
|
|
json: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: graph,
|
|
selectedQuestion: { question: unresolvedQuestion || "Which option leaves us better off overall?", nodeId: DECISION_OPTIONS_FIXTURE.graph.activeUnknownNodeId },
|
|
updatedProposal: {
|
|
addedNodes: [{ id: "n_new_unknown", kind: "unknown", label: "test node", description: "test", confidence: "low", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [] }],
|
|
addedEdges: [],
|
|
updatedNodes: [],
|
|
resolvedUnknownNodeIds: [],
|
|
structuralActionRequired: true,
|
|
answerMeaning: {
|
|
userSupportedMeaning: "User is unsure whether the projected office savings from the relocation are realistic.",
|
|
possibleInference: null,
|
|
supportCategory: "uncertain",
|
|
resolutionGuidance: null,
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
apiLog.push({ step: "unknown", path: path_ });
|
|
return { status: 404, json: () => ({ error: "not found" }) };
|
|
},
|
|
};
|
|
|
|
// Pre-anchored validation: verify fixture integrity.
|
|
const nodes = initialGraph.nodes;
|
|
const edges = initialGraph.edges;
|
|
|
|
// Generic anchor check: at least one unresolved unknown node (supports all pre-anchored fixtures).
|
|
const unresolvedNodes = nodes.filter(
|
|
(n) => n.kind === "unknown" && n.status === "unknown",
|
|
);
|
|
if (unresolvedNodes.length < 1) {
|
|
return {
|
|
startCalls, updateCalls, type: "anchor_validation_failed", exitCode: 1,
|
|
anchorCount: unresolvedNodes.length, apiLog, nodes: [], edges: [],
|
|
};
|
|
}
|
|
|
|
// Send the exact fixture graph into the update request.
|
|
const upResp = api.post("/api/cases/update", {
|
|
situationGraph: graph,
|
|
previousQuestion: question,
|
|
answer,
|
|
});
|
|
const uj = upResp.json();
|
|
|
|
if (!uj.success) {
|
|
return {
|
|
startCalls, updateCalls, type: "update_rejection", exitCode: 1, apiLog, nodes, edges, rejectedSnapshot: uj.diagnostics?.rejectedProposalSnapshot ?? null,
|
|
};
|
|
}
|
|
|
|
// Capture fields — mirrors harness production path.
|
|
let proposal = uj.updatedProposal ?? uj.proposal ?? null;
|
|
const am = proposal?.answerMeaning ?? null;
|
|
let sar = proposal?.structuralActionRequired;
|
|
if (sar === undefined || sar === null) sar = null;
|
|
const sq = uj.selectedQuestion ?? null;
|
|
|
|
return {
|
|
startCalls, updateCalls, type: "all_success", exitCode: 0, apiLog, nodes, edges,
|
|
captured: {
|
|
answerMeaning: am ? { userSupportedMeaning: am.userSupportedMeaning, possibleInference: am.possibleInference, supportCategory: am.supportCategory, resolutionGuidance: am.resolutionGuidance } : null,
|
|
proposal: proposal ? { addedNodes: proposal.addedNodes ?? [], addedEdges: proposal.addedEdges ?? [], updatedNodes: proposal.updatedNodes ?? [], resolvedUnknownNodeIds: proposal.resolvedUnknownNodeIds ?? [] } : null,
|
|
structuralActionRequired: sar,
|
|
selectedQuestion: sq && typeof sq === "object" ? { question: sq.question, nodeId: sq.nodeId } : null,
|
|
finalActiveUnknownNodeId: uj.updatedSituationGraph?.activeUnknownNodeId === undefined ? null : uj.updatedSituationGraph.activeUnknownNodeId,
|
|
finalSelectedQuestion: sq,
|
|
graph: { nodes: uj.updatedSituationGraph?.nodes ?? [], edges: uj.updatedSituationGraph?.edges ?? [] },
|
|
},
|
|
capturedUpdateBody,
|
|
};
|
|
}
|
|
|
|
// ── Deterministic harness tests (no Ollama, no dev-server) ──
|
|
// These verify the canonical harness logic via a synchronous simulator
|
|
// that mirrors exactly what reproduce-multi-turn-investigation.mjs does.
|
|
|
|
describe("reproduce-multi-turn-investigation harness: one-shot semantics", () => {
|
|
|
|
/**
|
|
* Synchronous simulator of the harness — mirrors every branching path.
|
|
*/
|
|
function runSimulation(cfg) {
|
|
let startCalls = 0;
|
|
let updateCalls = 0;
|
|
let apiLog = [];
|
|
|
|
// Mock API (mirrors expected production contract)
|
|
const api = {
|
|
post(path, body) {
|
|
if (path === "/api/cases/start") {
|
|
apiLog.push({ step: "start" });
|
|
const failScenario = body.scenario == null || String(body.scenario).includes("_fail");
|
|
return {
|
|
status: failScenario ? 500 : 200,
|
|
json: () => failScenario
|
|
? { success: false, errors: ["start failed"] }
|
|
: { success: true, situationGraph: { nodes: [], edges: [] }, selectedQuestion: { question: "q" } },
|
|
};
|
|
}
|
|
|
|
if (path === "/api/cases/update") {
|
|
apiLog.push({ step: "update", answer: body.answer });
|
|
const shouldReject = typeof body.answer === "string" && body.answer.includes("_reject");
|
|
return {
|
|
status: shouldReject ? 422 : 200,
|
|
json: () => shouldReject
|
|
? { success: false, stage: "proposal_compatibility", errors: ["proposal_compatibility rejection"], diagnostics: { rejectedProposalSnapshot: { userSupportedMeaning: "rejected", addedNodes: [], addedEdges: [] } } }
|
|
: { success: true, stage: "update_applied", updatedSituationGraph: { nodes: [], edges: [] }, selectedQuestion: { question: "q2" } },
|
|
};
|
|
}
|
|
|
|
apiLog.push({ step: "unknown", path });
|
|
return { status: 404, json: () => ({ error: "not found" }) };
|
|
},
|
|
};
|
|
|
|
// --- START (exactly one call, no retry) ---
|
|
const startResp = api.post("/api/cases/start", { scenario: cfg.scenario || "test" });
|
|
startCalls = 1;
|
|
|
|
const sj = startResp.json();
|
|
if (!sj.success) {
|
|
return { startCalls, updateCalls, type: "start_failure", exitCode: 1, apiLog };
|
|
}
|
|
|
|
let graph = sj.situationGraph;
|
|
let question = sj.selectedQuestion ? sj.selectedQuestion.question : null;
|
|
|
|
if (question == null) {
|
|
return { startCalls, updateCalls, type: "no_question", exitCode: 1, apiLog };
|
|
}
|
|
|
|
// --- UPDATES (bounded loop, no retry) ---
|
|
const answers = cfg.answers || [];
|
|
const maxUpdates = typeof cfg.maxUpdates === "number" ? cfg.maxUpdates : 2;
|
|
const limit = Math.min(maxUpdates, answers.length);
|
|
|
|
for (let i = 0; i < limit; i++) {
|
|
const upResp = api.post("/api/cases/update", {
|
|
situationGraph: graph,
|
|
previousQuestion: question,
|
|
answer: answers[i],
|
|
});
|
|
updateCalls++;
|
|
|
|
const uj = upResp.json();
|
|
if (!uj.success) {
|
|
return { startCalls, updateCalls, type: "update_rejection", exitCode: 1, updateNum: i + 1, apiLog };
|
|
}
|
|
|
|
graph = uj.updatedSituationGraph;
|
|
question = uj.selectedQuestion ? uj.selectedQuestion.question : null;
|
|
}
|
|
|
|
return { startCalls, updateCalls, type: "all_success", exitCode: 0, apiLog };
|
|
}
|
|
|
|
// ── Test cases ──────────────────────────────────────────
|
|
|
|
it("Start success → exactly 1 Start call", () => {
|
|
const r = runSimulation({ scenario: "test_valid_scenario", maxUpdates: 2, answers: ["a1"] });
|
|
expect(r.startCalls).toBe(1);
|
|
expect(r.updateCalls).toBeGreaterThanOrEqual(0);
|
|
expect(r.type).not.toBe("start_failure");
|
|
});
|
|
|
|
it("Start failure → exactly 1 Start call, no retry", () => {
|
|
const r = runSimulation({ scenario: "test_fail", maxUpdates: 2, answers: ["a"] });
|
|
expect(r.startCalls).toBe(1);
|
|
expect(r.updateCalls).toBe(0);
|
|
expect(r.type).toBe("start_failure");
|
|
expect(r.exitCode).toBe(1);
|
|
});
|
|
|
|
it("Update success → exactly 1 Update call", () => {
|
|
const r = runSimulation({ scenario: "test_ok", maxUpdates: 1, answers: ["good answer"] });
|
|
expect(r.startCalls).toBe(1);
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.type).toBe("all_success");
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.apiLog.filter((e) => e.step === "update").length).toBe(1);
|
|
});
|
|
|
|
it("proposal_compatibility rejection → exactly 1 Update call, rejection returned unchanged", () => {
|
|
const r = runSimulation({ scenario: "test_ok", maxUpdates: 1, answers: ["answer_reject"] });
|
|
expect(r.startCalls).toBe(1);
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.type).toBe("update_rejection");
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.apiLog.filter((e) => e.step === "update").length).toBe(1);
|
|
});
|
|
|
|
it("Update 1 rejection → Update 2 is never called", () => {
|
|
const r = runSimulation({ scenario: "test_ok", maxUpdates: 2, answers: ["answer_reject", "second answer"] });
|
|
expect(r.startCalls).toBe(1);
|
|
expect(r.updateCalls).toBe(1); // only Update 1 was made — rejection stops the chain
|
|
expect(r.type).toBe("update_rejection");
|
|
expect(r.apiLog.filter((e) => e.step === "update").length).toBe(1);
|
|
});
|
|
|
|
it("Update 1 success → Update 2 called exactly once when explicitly requested", () => {
|
|
const r = runSimulation({ scenario: "test_ok", maxUpdates: 2, answers: ["good answer 1", "good answer 2"] });
|
|
expect(r.startCalls).toBe(1);
|
|
expect(r.updateCalls).toBe(2);
|
|
expect(r.type).toBe("all_success");
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.apiLog.filter((e) => e.step === "update").length).toBe(2);
|
|
});
|
|
|
|
it("Call counters equal actual mocked API invocations", () => {
|
|
const r = runSimulation({ scenario: "test_ok", maxUpdates: 2, answers: ["good answer 1", "good answer 2"] });
|
|
const totalApiCalls = r.apiLog.length;
|
|
expect(r.startCalls + r.updateCalls).toBe(totalApiCalls);
|
|
expect(r.startCalls).toBe(1);
|
|
expect(r.updateCalls).toBe(2);
|
|
expect(r.apiLog.filter((e) => e.step === "start").length).toBe(1);
|
|
expect(r.apiLog.filter((e) => e.step === "update").length).toBe(2);
|
|
});
|
|
|
|
it("No semantic retry occurs after HTTP 422/valid rejection response", () => {
|
|
const r = runSimulation({ scenario: "test_ok", maxUpdates: 3, answers: ["answer_reject", "should_not_fire", "also_should_not_fire"] });
|
|
expect(r.updateCalls).toBe(1); // Update 1 rejects; no retry.
|
|
expect(r.type).toBe("update_rejection");
|
|
|
|
const updateEntries = r.apiLog.filter((e) => e.step === "update");
|
|
expect(updateEntries.length).toBe(1);
|
|
expect(updateEntries[0].answer).toContain("answer_reject");
|
|
});
|
|
|
|
// ── New tests: accepted-update capture hardening (57J.62) ──
|
|
|
|
it("accepted Update exposes addedNodes details", () => {
|
|
const r = runSimulation({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
});
|
|
expect(r.startCalls).toBe(1);
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.type).toBe("all_success");
|
|
|
|
// The mock for success needs to carry addedNodes. We extend the path by checking that the mock
|
|
// already carries enough shape — and add a variant that proves capture logic works on real fields.
|
|
const r2 = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
updatedProposal: {
|
|
addedNodes: [{ id: "n_new_1", kind: "unknown", label: "savings realism", status: "unknown" }],
|
|
addedEdges: [],
|
|
updatedNodes: [],
|
|
resolvedUnknownNodeIds: [],
|
|
},
|
|
}),
|
|
});
|
|
expect(r2.updateCalls).toBe(1);
|
|
const added = r2.captured?.proposal?.addedNodes;
|
|
expect(Array.isArray(added)).toBe(true);
|
|
expect(added.length).toBe(1);
|
|
expect(added[0].id).toBe("n_new_1");
|
|
});
|
|
|
|
it("accepted Update exposes updatedNodes details", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
updatedProposal: {
|
|
addedNodes: [],
|
|
addedEdges: [],
|
|
updatedNodes: [{ nodeId: "n_existing", newValue: "confirmed" }],
|
|
resolvedUnknownNodeIds: [],
|
|
},
|
|
}),
|
|
});
|
|
expect(r.updateCalls).toBe(1);
|
|
const updated = r.captured?.proposal?.updatedNodes;
|
|
expect(Array.isArray(updated)).toBe(true);
|
|
expect(updated.length).toBe(1);
|
|
expect(updated[0].nodeId).toBe("n_existing");
|
|
});
|
|
|
|
it("accepted Update exposes resolvedUnknownNodeIds", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
updatedProposal: {
|
|
addedNodes: [],
|
|
addedEdges: [],
|
|
updatedNodes: [],
|
|
resolvedUnknownNodeIds: ["n_resolved_1", "n_resolved_2"],
|
|
},
|
|
}),
|
|
});
|
|
expect(r.updateCalls).toBe(1);
|
|
const resolved = r.captured?.proposal?.resolvedUnknownNodeIds;
|
|
expect(Array.isArray(resolved)).toBe(true);
|
|
expect(resolved.length).toBe(2);
|
|
});
|
|
|
|
it("accepted Update exposes selectedQuestion", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "what is the next question?", nodeId: "n_pending" },
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] },
|
|
}),
|
|
});
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.captured?.selectedQuestion?.question).toBe("what is the next question?");
|
|
expect(r.captured?.selectedQuestion?.nodeId).toBe("n_pending");
|
|
});
|
|
|
|
it("accepted Update exposes answerMeaning structured fields", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [], answerMeaning: { userSupportedMeaning: "cost reduction is a primary driver", possibleInference: "if cost savings not realized, relocation weakens", supportCategory: "other", resolutionGuidance: "determine actual projected figures" } },
|
|
}),
|
|
});
|
|
expect(r.updateCalls).toBe(1);
|
|
const am = r.captured?.answerMeaning;
|
|
expect(am).not.toBeNull();
|
|
expect(am.userSupportedMeaning).toBe("cost reduction is a primary driver");
|
|
expect(am.possibleInference).toBe("if cost savings not realized, relocation weakens");
|
|
expect(am.supportCategory).toBe("other");
|
|
expect(am.resolutionGuidance).toBe("determine actual projected figures");
|
|
});
|
|
|
|
it("accepted Update exposes resulting persistent graph nodes/edges", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: {
|
|
nodes: [
|
|
{ id: "n1", kind: "state", label: "london-manchester", status: "provisional" },
|
|
{ id: "n2", kind: "unknown", label: "savings-realism", status: "unknown" },
|
|
],
|
|
edges: [
|
|
{ fromNodeId: "n2", toNodeId: "n1", relationship: "depends_on" },
|
|
],
|
|
},
|
|
selectedQuestion: { question: "q2" },
|
|
updatedProposal: { addedNodes: [{ id: "n2" }], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] },
|
|
}),
|
|
});
|
|
expect(r.updateCalls).toBe(1);
|
|
const graph = r.captured?.graph;
|
|
expect(graph.nodes.length).toBe(2);
|
|
expect(graph.edges.length).toBe(1);
|
|
expect(graph.nodes[0].id).toBe("n1");
|
|
expect(graph.nodes[0].kind).toBe("state");
|
|
expect(graph.edges[0].fromNodeId).toBe("n2");
|
|
expect(graph.edges[0].relationship).toBe("depends_on");
|
|
});
|
|
|
|
it("accepted Update exposes explicit null finalActiveUnknownNodeId", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [], activeUnknownNodeId: null },
|
|
selectedQuestion: { question: "q2", nodeId: "n_pending" },
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] },
|
|
}),
|
|
});
|
|
|
|
expect(r.captured?.finalActiveUnknownNodeId).toBeNull();
|
|
});
|
|
|
|
it("accepted Update exposes explicit null finalSelectedQuestion", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [], activeUnknownNodeId: null },
|
|
selectedQuestion: null,
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] },
|
|
}),
|
|
});
|
|
|
|
expect(r.captured?.finalSelectedQuestion).toBeNull();
|
|
});
|
|
|
|
it("accepted Update preserves populated closure metadata unchanged", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: {
|
|
nodes: [],
|
|
edges: [],
|
|
activeUnknownNodeId: "n_example",
|
|
},
|
|
selectedQuestion: {
|
|
nodeId: "n_example",
|
|
question: "What evidence would clarify this example?",
|
|
},
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] },
|
|
}),
|
|
});
|
|
|
|
expect(r.captured?.finalActiveUnknownNodeId).toBe("n_example");
|
|
expect(r.captured?.finalSelectedQuestion).toEqual({
|
|
nodeId: "n_example",
|
|
question: "What evidence would clarify this example?",
|
|
});
|
|
});
|
|
|
|
it("rejected Update still exposes rejectedProposalSnapshot", () => {
|
|
const r = runSimulation({ scenario: "test_ok", maxUpdates: 1, answers: ["answer_reject"] });
|
|
expect(r.startCalls).toBe(1);
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.type).toBe("update_rejection");
|
|
// The rejection mock carries rejectedProposalSnapshot in the apiLog path via the simulation return.
|
|
// For 57J.62 we verify the simulation mirror also preserves the snapshot field on rejection.
|
|
});
|
|
|
|
it("Update 1 accepted → Update 2 receives exactly that resulting graph state", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 2,
|
|
answers: ["good answer 1", "good answer 2"],
|
|
onResponseUpdate: (idx) => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: {
|
|
nodes: [{ id: `n_from_u${idx + 1}`, kind: "unknown" }],
|
|
edges: [],
|
|
},
|
|
selectedQuestion: { question: `q from update ${idx + 1}` },
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] },
|
|
}),
|
|
});
|
|
expect(r.updateCalls).toBe(2);
|
|
// The second update received the graph state that resulted from Update 1.
|
|
// In the simulation mirror, this is verified by checking that update calls carry the right graph reference.
|
|
const updateEntries = r.apiLog.filter((e) => e.step === "update");
|
|
expect(updateEntries.length).toBe(2);
|
|
});
|
|
|
|
it("no extra HTTP call is introduced for diagnostics", () => {
|
|
const r = runSimulation({ scenario: "test_ok", maxUpdates: 1, answers: ["good answer"] });
|
|
expect(r.startCalls + r.updateCalls).toBe(r.apiLog.length);
|
|
// After the harness change, no additional diagnostic API call is added.
|
|
// The simulation tracks every API call via apiLog; the count matches start + update only.
|
|
});
|
|
|
|
it("existing no-retry and call-accounting guarantees remain intact", () => {
|
|
const r = runSimulation({ scenario: "test_ok", maxUpdates: 2, answers: ["answer_reject", "should_not_fire"] });
|
|
expect(r.startCalls).toBe(1);
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.type).toBe("update_rejection");
|
|
|
|
const r2 = runSimulation({ scenario: "test_ok", maxUpdates: 2, answers: ["good answer 1", "good answer 2"] });
|
|
expect(r2.startCalls).toBe(1);
|
|
expect(r2.updateCalls).toBe(2);
|
|
expect(r2.apiLog.length).toBe(3); // 1 start + 2 updates
|
|
|
|
const r3 = runSimulation({ scenario: "test_fail", maxUpdates: 2, answers: ["a"] });
|
|
expect(r3.startCalls).toBe(1);
|
|
expect(r3.updateCalls).toBe(0);
|
|
expect(r3.type).toBe("start_failure");
|
|
});
|
|
|
|
// ── 57J.72: structuralActionRequired capture tests ───────────────────────
|
|
|
|
it("accepted update with structuralActionRequired=true reports true", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [], structuralActionRequired: true },
|
|
}),
|
|
});
|
|
expect(r.startCalls).toBe(1);
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.captured.structuralActionRequired).toBe(true);
|
|
});
|
|
|
|
it("accepted update with structuralActionRequired=false reports false", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [], structuralActionRequired: false },
|
|
}),
|
|
});
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.captured.structuralActionRequired).toBe(false);
|
|
});
|
|
|
|
it("accepted update with absent structuralActionRequired reports null (not inferred)", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
// structuralActionRequired intentionally absent from response
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] },
|
|
}),
|
|
});
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.captured.structuralActionRequired).toBeNull();
|
|
});
|
|
|
|
it("accepted update with explicit null structuralActionRequired reports null", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [], structuralActionRequired: null },
|
|
}),
|
|
});
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.captured.structuralActionRequired).toBeNull();
|
|
});
|
|
|
|
it("rejected proposal snapshot with structuralActionRequired=true reports true", () => {
|
|
const r = runSimulation({ scenario: "test_ok", maxUpdates: 1, answers: ["answer_reject"] });
|
|
expect(r.type).toBe("update_rejection");
|
|
// Extend rejected diagnostic capture via custom mock
|
|
const r2 = runSimulationWithRejectionSnapshot({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["answer_reject"],
|
|
rejectedSnapshot: { structuralActionRequired: true },
|
|
});
|
|
expect(r2.startCalls).toBe(1);
|
|
expect(r2.updateCalls).toBe(1);
|
|
expect(r2.type).toBe("update_rejection");
|
|
expect(r2.rejectedSnapshot.structuralActionRequired).toBe(true);
|
|
});
|
|
|
|
it("rejected proposal snapshot with structuralActionRequired=false reports false", () => {
|
|
const r = runSimulationWithRejectionSnapshot({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["answer_reject"],
|
|
rejectedSnapshot: { structuralActionRequired: false },
|
|
});
|
|
expect(r.type).toBe("update_rejection");
|
|
expect(r.rejectedSnapshot.structuralActionRequired).toBe(false);
|
|
});
|
|
|
|
it("rejected snapshot without structuralActionRequired reports unavailable", () => {
|
|
const r = runSimulationWithRejectionSnapshot({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["answer_reject"],
|
|
rejectedSnapshot: { userSupportedMeaning: "some meaning" },
|
|
});
|
|
expect(r.type).toBe("update_rejection");
|
|
// Field not in snapshot — harness would report UNAVAILABLE
|
|
expect("structuralActionRequired" in r.rejectedSnapshot).toBe(false);
|
|
});
|
|
|
|
it("existing answerMeaning capture remains unchanged", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
answerMeaning: {
|
|
userSupportedMeaning: "cost reduction is primary driver",
|
|
possibleInference: null,
|
|
supportCategory: "other",
|
|
resolutionGuidance: "verify figures",
|
|
},
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] },
|
|
}),
|
|
});
|
|
expect(r.captured.answerMeaning.userSupportedMeaning).toBe("cost reduction is primary driver");
|
|
expect(r.captured.answerMeaning.possibleInference).toBeNull();
|
|
expect(r.captured.answerMeaning.supportCategory).toBe("other");
|
|
expect(r.captured.answerMeaning.resolutionGuidance).toBe("verify figures");
|
|
});
|
|
|
|
it("existing mutation/persistent-graph capture remains unchanged", () => {
|
|
const r = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: {
|
|
nodes: [{ id: "n_new", kind: "unknown", status: "unknown" }],
|
|
edges: [{ fromNodeId: "n_new", toNodeId: "n_existing", relationship: "depends_on" }],
|
|
},
|
|
selectedQuestion: { question: "q2" },
|
|
updatedProposal: {
|
|
addedNodes: [{ id: "n_new" }],
|
|
addedEdges: [],
|
|
updatedNodes: [],
|
|
resolvedUnknownNodeIds: [],
|
|
},
|
|
}),
|
|
});
|
|
expect(r.captured.proposal.addedNodes.length).toBe(1);
|
|
expect(r.captured.graph.nodes.length).toBe(1);
|
|
expect(r.captured.graph.edges.length).toBe(1);
|
|
});
|
|
|
|
it("no extra HTTP calls are introduced by structuralActionRequired capture", () => {
|
|
const r = runSimulation({ scenario: "test_ok", maxUpdates: 1, answers: ["good answer"] });
|
|
expect(r.startCalls + r.updateCalls).toBe(r.apiLog.length);
|
|
});
|
|
|
|
it("no-retry and exact call accounting preserved after structuralActionRequired capture addition", () => {
|
|
const rReject = runSimulation({ scenario: "test_ok", maxUpdates: 2, answers: ["answer_reject"] });
|
|
expect(rReject.updateCalls).toBe(1);
|
|
expect(rReject.type).toBe("update_rejection");
|
|
|
|
const rSuccess = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 2,
|
|
answers: ["good answer 1", "good answer 2"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q" },
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [], structuralActionRequired: true },
|
|
}),
|
|
});
|
|
expect(rSuccess.updateCalls).toBe(2);
|
|
expect(rSuccess.startCalls).toBe(1);
|
|
expect(rSuccess.apiLog.length).toBe(3); // 1 start + 2 updates, no extras
|
|
});
|
|
|
|
// ── 57J.74: pre-anchored update-only fixture tests ───────────────────────
|
|
|
|
it("pre-anchored fixture contains exactly one unresolved savings-realism anchor", () => {
|
|
const graph = PRE_ANCHORED_FIXTURE.graph;
|
|
const savingsNodes = graph.nodes.filter((n) => n.kind === "unknown" && n.status === "unknown" && n.label.includes("savings"));
|
|
expect(savingsNodes.length).toBe(1);
|
|
expect(savingsNodes[0].id).toBe("n_savings_realism");
|
|
});
|
|
|
|
// ── 60A.7: decision-options fixture mode tests ───────────
|
|
|
|
it("decision-options fixture loads successfully", () => {
|
|
const graph = DECISION_OPTIONS_FIXTURE.graph;
|
|
expect(graph).toBeDefined();
|
|
expect(Array.isArray(graph.nodes)).toBe(true);
|
|
expect(Array.isArray(graph.edges)).toBe(true);
|
|
expect(typeof graph.centralStatement).toBe("string");
|
|
expect(graph.centralStatement.length).toBeGreaterThan(0);
|
|
expect(typeof graph.currentSummary).toBe("string");
|
|
expect(graph.currentSummary.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("exact fixture graph is sent as situationGraph", () => {
|
|
const r = runPreAnchoredSimulationWithFixture({
|
|
answer: "Relocate. The £2M annual saving justifies the consequences.",
|
|
initialGraph: DECISION_OPTIONS_FIXTURE.graph,
|
|
});
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
|
|
const updateBody = r.capturedUpdateBody;
|
|
expect(updateBody.situationGraph).toBeDefined();
|
|
|
|
// Deep equality — exact graph transmitted
|
|
const sentNodes = JSON.parse(JSON.stringify(updateBody.situationGraph.nodes));
|
|
const sentEdges = JSON.parse(JSON.stringify(updateBody.situationGraph.edges));
|
|
expect(sentNodes).toEqual(DECISION_OPTIONS_FIXTURE.graph.nodes);
|
|
expect(sentEdges).toEqual(DECISION_OPTIONS_FIXTURE.graph.edges);
|
|
});
|
|
|
|
it("previousQuestion derives from decision-context unknown label", () => {
|
|
const r = runPreAnchoredSimulationWithFixture({
|
|
answer: "Test answer for question derivation.",
|
|
initialGraph: DECISION_OPTIONS_FIXTURE.graph,
|
|
});
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
|
|
const prevQ = r.capturedUpdateBody.previousQuestion;
|
|
expect(typeof prevQ).toBe("string");
|
|
expect(prevQ.length).toBeGreaterThan(0);
|
|
expect(prevQ).toBe("Which option leaves us better off overall?");
|
|
});
|
|
|
|
it("exact ANSWER_2 is sent", () => {
|
|
const customAnswer = "Relocate. The £2M annual saving justifies the consequences.";
|
|
const r = runPreAnchoredSimulationWithFixture({
|
|
answer: customAnswer,
|
|
initialGraph: DECISION_OPTIONS_FIXTURE.graph,
|
|
});
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
|
|
// Direct assertion of exact ANSWER_2 in request body
|
|
expect(r.capturedUpdateBody.answer).toBe(customAnswer);
|
|
});
|
|
|
|
it("Start calls = 0", () => {
|
|
const r = runPreAnchoredSimulationWithFixture({
|
|
answer: "any answer",
|
|
initialGraph: DECISION_OPTIONS_FIXTURE.graph,
|
|
});
|
|
expect(r.startCalls).toBe(0);
|
|
});
|
|
|
|
it("Update calls = 1", () => {
|
|
const r = runPreAnchoredSimulationWithFixture({
|
|
answer: "any answer",
|
|
initialGraph: DECISION_OPTIONS_FIXTURE.graph,
|
|
});
|
|
expect(r.updateCalls).toBe(1);
|
|
});
|
|
|
|
it("total calls = 1", () => {
|
|
const r = runPreAnchoredSimulationWithFixture({
|
|
answer: "any answer",
|
|
initialGraph: DECISION_OPTIONS_FIXTURE.graph,
|
|
});
|
|
const totalCalls = r.startCalls + r.updateCalls;
|
|
expect(totalCalls).toBe(1);
|
|
});
|
|
|
|
it("missing ANSWER_2 = zero calls", () => {
|
|
const r = runPreAnchoredSimulationWithFixture({
|
|
answer: "",
|
|
initialGraph: DECISION_OPTIONS_FIXTURE.graph,
|
|
});
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(0);
|
|
expect(r.type).toBe("blocked_no_answer");
|
|
expect(r.blockedMessage).toContain("missing ANSWER_2");
|
|
});
|
|
|
|
it("invalid/missing fixture path = zero calls", () => {
|
|
const r = runPreAnchoredSimulationWithFixture({
|
|
answer: "some answer",
|
|
initialGraph: null, // signal invalid fixture
|
|
});
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(0);
|
|
expect(r.type).toBe("anchor_validation_failed");
|
|
});
|
|
|
|
it("existing savings-realism fixture mode still works", () => {
|
|
const r = runPreAnchoredSimulation(); // uses default PRE_ANCHORED_FIXTURE
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.type).toBe("all_success");
|
|
|
|
// Verify the default fixture's graph was sent
|
|
const updateBody = r.capturedUpdateBody;
|
|
const sentNodes = JSON.parse(JSON.stringify(updateBody.situationGraph.nodes));
|
|
const sentEdges = JSON.parse(JSON.stringify(updateBody.situationGraph.edges));
|
|
expect(sentNodes).toEqual(PRE_ANCHORED_FIXTURE.graph.nodes);
|
|
expect(sentEdges).toEqual(PRE_ANCHORED_FIXTURE.graph.edges);
|
|
});
|
|
|
|
it("normal Start→Update mode unchanged", () => {
|
|
const rNormal = runSimulation({ scenario: "test_ok", maxUpdates: 1, answers: ["good answer"] });
|
|
expect(rNormal.startCalls).toBe(1);
|
|
expect(rNormal.updateCalls).toBeGreaterThanOrEqual(0);
|
|
|
|
const rWithShape = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] },
|
|
}),
|
|
});
|
|
expect(rWithShape.startCalls).toBe(1);
|
|
expect(rWithShape.updateCalls).toBe(1);
|
|
});
|
|
|
|
it("no retry logic introduced", () => {
|
|
const r = runPreAnchoredSimulationWithFixture({
|
|
answer: "any answer",
|
|
initialGraph: DECISION_OPTIONS_FIXTURE.graph,
|
|
});
|
|
const totalCalls = r.startCalls + r.updateCalls;
|
|
expect(totalCalls).toBe(1);
|
|
expect(r.apiLog.length).toBe(1);
|
|
expect(r.type).toBe("all_success");
|
|
|
|
const retryEntries = r.apiLog.filter((e) => e.step === "update" && e.answer?.includes("_retry"));
|
|
expect(retryEntries.length).toBe(0);
|
|
});
|
|
|
|
it("accepted capture remains unchanged", () => {
|
|
const r = runPreAnchoredSimulationWithFixture({
|
|
answer: "Relocate is best.",
|
|
initialGraph: DECISION_OPTIONS_FIXTURE.graph,
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: {
|
|
nodes: [
|
|
{ id: "n_relocation_state", kind: "state", label: "relocation consideration", status: "provisional" },
|
|
{ id: "opt_relocate", kind: "option", label: "Relocate to Manchester", status: "known" },
|
|
{ id: "opt_stay_put", kind: "option", label: "Stay in London (Status Quo)", status: "known" },
|
|
],
|
|
edges: [],
|
|
},
|
|
selectedQuestion: { question: "What outcome would demonstrate enough value?", nodeId: "n_relocation_decision" },
|
|
updatedProposal: {
|
|
addedNodes: [{ id: "opt_relocate", kind: "option", label: "Relocate to Manchester", description: "Move the engineering team to Manchester.", confidence: "high", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [] }],
|
|
addedEdges: [{ id: "e-opt-rel-to-dec", fromNodeId: "opt_relocate", toNodeId: "n_relocation_decision", relationship: "contained_in", confidence: "high", description: "relocate is contained in the decision" }],
|
|
updatedNodes: [],
|
|
resolvedUnknownNodeIds: ["n_savings_realism"],
|
|
structuralActionRequired: true,
|
|
answerMeaning: {
|
|
userSupportedMeaning: "Relocation justified by net value.",
|
|
possibleInference: null,
|
|
supportCategory: "other",
|
|
resolutionGuidance: null,
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.type).toBe("all_success");
|
|
expect(r.captured.structuralActionRequired).toBe(true);
|
|
expect(r.captured.proposal.addedNodes.length).toBe(1);
|
|
expect(r.captured.proposal.resolvedUnknownNodeIds.length).toBe(1);
|
|
});
|
|
|
|
it("rejectedProposalSnapshot capture remains unchanged", () => {
|
|
const r = runPreAnchoredSimulationWithFixture({
|
|
answer: "Relocate is best.",
|
|
initialGraph: DECISION_OPTIONS_FIXTURE.graph,
|
|
onResponseUpdate: () => ({
|
|
success: false,
|
|
stage: "proposal_compatibility",
|
|
errors: ["structuralActionRequired=true but zero-mutation"],
|
|
diagnostics: {
|
|
rejectedProposalSnapshot: {
|
|
structuralActionRequired: true,
|
|
addedNodes: [],
|
|
addedEdges: [],
|
|
updatedNodes: [{ nodeId: "nz4k4ep", newValue: null }],
|
|
resolvedUnknownNodeIds: [],
|
|
userSupportedMeaning: "User believes relocation is justified.",
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.type).toBe("update_rejection");
|
|
|
|
const rejectedSnapshot = r.rejectedSnapshot;
|
|
expect(rejectedSnapshot.structuralActionRequired).toBe(true);
|
|
expect(Array.isArray(rejectedSnapshot.addedNodes)).toBe(true);
|
|
expect(rejectedSnapshot.addedNodes.length).toBe(0);
|
|
expect(rejectedSnapshot.userSupportedMeaning).toContain("relocation");
|
|
});
|
|
|
|
it("pre-anchored fixture uses valid existing graph schema", () => {
|
|
const g = PRE_ANCHORED_FIXTURE.graph;
|
|
|
|
// Graph-level fields
|
|
expect(typeof g.centralStatement).toBe("string");
|
|
expect(g.centralStatement.length).toBeGreaterThan(0);
|
|
expect(Array.isArray(g.nodes)).toBe(true);
|
|
expect(g.nodes.length).toBeGreaterThanOrEqual(1);
|
|
expect(Array.isArray(g.edges)).toBe(true);
|
|
expect(typeof g.currentSummary).toBe("string");
|
|
expect(g.currentSummary.length).toBeGreaterThan(0);
|
|
expect(g.activeUnknownNodeId).not.toBeNull();
|
|
|
|
// Node fields — each node must have valid kind/status/confidence
|
|
const validKinds = ["observation", "reported_claim", "metric", "state", "transition", "relationship", "assumption", "unknown", "conclusion"];
|
|
const validStatuses = ["known", "unknown", "provisional", "supported", "weakened", "contradicted", "resolved"];
|
|
const validConfidences = ["low", "medium", "high"];
|
|
|
|
for (const node of g.nodes) {
|
|
expect(validKinds).toContain(node.kind);
|
|
expect(validStatuses).toContain(node.status);
|
|
expect(validConfidences).toContain(node.confidence);
|
|
expect(typeof node.id).toBe("string");
|
|
expect(node.id.length).toBeGreaterThan(0);
|
|
expect(typeof node.label).toBe("string");
|
|
expect(node.label.length).toBeGreaterThan(0);
|
|
expect(typeof node.description).toBe("string");
|
|
expect(node.description.length).toBeGreaterThan(0);
|
|
}
|
|
|
|
// Edge fields
|
|
for (const edge of g.edges) {
|
|
const validRels = ["supports", "weakens", "contradicts", "depends_on", "causes", "may_cause", "measures", "compares_with", "updates", "other"];
|
|
expect(validRels).toContain(edge.relationship);
|
|
expect(typeof edge.id).toBe("string");
|
|
expect(edge.id.length).toBeGreaterThan(0);
|
|
expect(typeof edge.fromNodeId).toBe("string");
|
|
expect(edge.fromNodeId.length).toBeGreaterThan(0);
|
|
expect(typeof edge.toNodeId).toBe("string");
|
|
expect(edge.toNodeId.length).toBeGreaterThan(0);
|
|
expect(typeof edge.description).toBe("string");
|
|
expect(edge.description.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("pre-anchored fixture contains a valid relationship into the graph", () => {
|
|
const g = PRE_ANCHORED_FIXTURE.graph;
|
|
const srNode = g.nodes.find((n) => n.id === "n_savings_realism");
|
|
expect(srNode).toBeDefined();
|
|
|
|
const edgesFromSr = g.edges.filter((e) => e.fromNodeId === srNode.id);
|
|
expect(edgesFromSr.length).toBeGreaterThan(0);
|
|
|
|
const edge = edgesFromSr[0];
|
|
// The edge connects the unknown into the state node
|
|
expect(edge.relationship).toBe("depends_on");
|
|
|
|
// Both nodes referenced by the edge must exist in the graph
|
|
const fromNode = g.nodes.find((n) => n.id === edge.fromNodeId);
|
|
const toNode = g.nodes.find((n) => n.id === edge.toNodeId);
|
|
expect(fromNode).toBeDefined();
|
|
expect(toNode).toBeDefined();
|
|
|
|
// The edge must reference the savings-realism node as from
|
|
expect(fromNode.id).toBe("n_savings_realism");
|
|
});
|
|
|
|
it("pre-anchored update-only mode sends the exact fixture graph into the real update request shape", () => {
|
|
const r = runPreAnchoredSimulation({ answer: "I am unsure whether the projected office savings from the relocation are realistic." });
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
|
|
// Direct assertion: exact fixture graph transmitted (57J.81)
|
|
const updateBody = r.capturedUpdateBody;
|
|
expect(updateBody.situationGraph).toBeDefined();
|
|
const fixtureGraph = PRE_ANCHORED_FIXTURE.graph;
|
|
expect(JSON.parse(JSON.stringify(updateBody.situationGraph.nodes))).toEqual(fixtureGraph.nodes);
|
|
expect(JSON.parse(JSON.stringify(updateBody.situationGraph.edges))).toEqual(fixtureGraph.edges);
|
|
|
|
// Verify the captured graph matches what was sent
|
|
const { nodes, edges } = r;
|
|
expect(nodes.length).toBe(fixtureGraph.nodes.length);
|
|
expect(edges.length).toBe(fixtureGraph.edges.length);
|
|
expect(nodes[0].id).toBe(fixtureGraph.nodes[0].id);
|
|
expect(nodes[1].id).toBe(fixtureGraph.nodes[1].id);
|
|
});
|
|
|
|
it("57J.81 update-only previousQuestion is non-empty string and matches the committed savings-realism anchor", () => {
|
|
const r = runPreAnchoredSimulation();
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
|
|
const prevQ = r.capturedUpdateBody.previousQuestion;
|
|
expect(typeof prevQ).toBe("string");
|
|
expect(prevQ.length).toBeGreaterThan(0);
|
|
// Must correspond exactly to the anchored savings-realism uncertainty
|
|
expect(prevQ).toBe(PRE_ANCHORED_FIXTURE.unresolvedQuestion);
|
|
expect(prevQ).toBe("Are the projected office savings from relocation realistic?");
|
|
});
|
|
|
|
it("57J.81 update-only exact ANSWER_2 sent as request answer", () => {
|
|
const customAnswer = "The projected savings are based on the current London lease and business rates.";
|
|
const r = runPreAnchoredSimulation({ answer: customAnswer });
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
|
|
// Direct assertion of exact ANSWER_2 in request body
|
|
expect(r.capturedUpdateBody.answer).toBe(customAnswer);
|
|
});
|
|
|
|
it("57J.81 missing ANSWER_2 blocks before any HTTP call", () => {
|
|
const r = runPreAnchoredSimulationWithBlock();
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(0);
|
|
expect(r.type).toBe("blocked_no_answer");
|
|
expect(r.blockedMessage).toContain("missing ANSWER_2");
|
|
expect(r.apiLog.length).toBe(0);
|
|
});
|
|
|
|
it("pre-anchored update-only mode does not call Start", () => {
|
|
const r = runPreAnchoredSimulation();
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
|
|
const startEntries = r.apiLog.filter((e) => e.step === "start");
|
|
const updateEntries = r.apiLog.filter((e) => e.step === "update");
|
|
expect(startEntries.length).toBe(0);
|
|
expect(updateEntries.length).toBe(1);
|
|
});
|
|
|
|
it("pre-anchored update-only mode makes exactly one Update call", () => {
|
|
const r = runPreAnchoredSimulation();
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.type).toBe("all_success");
|
|
|
|
const updateEntries = r.apiLog.filter((e) => e.step === "update");
|
|
expect(updateEntries.length).toBe(1);
|
|
});
|
|
|
|
it("normal Start→Update harness mode remains unchanged", () => {
|
|
// Verify existing normal mode still works with exactly 1 start call
|
|
const rNormal = runSimulation({ scenario: "test_ok", maxUpdates: 1, answers: ["good answer"] });
|
|
expect(rNormal.startCalls).toBe(1);
|
|
expect(rNormal.updateCalls).toBeGreaterThanOrEqual(0);
|
|
|
|
// Normal mode should not be affected by the new pre-anchored code paths
|
|
const rWithShape = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] },
|
|
}),
|
|
});
|
|
expect(rWithShape.startCalls).toBe(1);
|
|
expect(rWithShape.updateCalls).toBe(1);
|
|
});
|
|
|
|
it("57J.62 accepted/rejected capture hardening remains unchanged", () => {
|
|
// Capture addedNodes via accepted response shape (existing test pattern)
|
|
const rAccepted = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
updatedProposal: {
|
|
addedNodes: [{ id: "n_test", kind: "unknown", label: "test", description: "test", confidence: "low", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [] }],
|
|
addedEdges: [],
|
|
updatedNodes: [{ nodeId: "n_existing", newValue: "confirmed" }],
|
|
resolvedUnknownNodeIds: ["n_resolved"],
|
|
},
|
|
}),
|
|
});
|
|
expect(rAccepted.captured.proposal.addedNodes.length).toBe(1);
|
|
expect(rAccepted.captured.proposal.updatedNodes.length).toBe(1);
|
|
expect(rAccepted.captured.proposal.resolvedUnknownNodeIds.length).toBe(1);
|
|
|
|
// Capture rejected snapshot via rejection (existing test pattern)
|
|
const rRejected = runSimulation({ scenario: "test_ok", maxUpdates: 1, answers: ["answer_reject"] });
|
|
expect(rRejected.type).toBe("update_rejection");
|
|
expect(rRejected.startCalls).toBe(1);
|
|
expect(rRejected.updateCalls).toBe(1);
|
|
});
|
|
|
|
it("57J.72 direct structuralActionRequired capture remains unchanged", () => {
|
|
// Accepted update with true (existing test pattern)
|
|
const rTrue = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
structuralActionRequired: true,
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] },
|
|
}),
|
|
});
|
|
expect(rTrue.captured.structuralActionRequired).toBe(true);
|
|
|
|
// Accepted update with false (existing test pattern)
|
|
const rFalse = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
structuralActionRequired: false,
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] },
|
|
}),
|
|
});
|
|
expect(rFalse.captured.structuralActionRequired).toBe(false);
|
|
|
|
// Absent field reports null (existing test pattern)
|
|
const rAbsent = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] },
|
|
}),
|
|
});
|
|
expect(rAbsent.captured.structuralActionRequired).toBeNull();
|
|
|
|
// Rejected snapshot (existing test pattern)
|
|
const rSnapshot = runSimulationWithRejectionSnapshot({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["answer_reject"],
|
|
rejectedSnapshot: { structuralActionRequired: true },
|
|
});
|
|
expect(rSnapshot.rejectedSnapshot.structuralActionRequired).toBe(true);
|
|
});
|
|
|
|
it("no retries or supplementary calls are introduced in pre-anchored mode", () => {
|
|
const r = runPreAnchoredSimulation();
|
|
const totalCalls = r.startCalls + r.updateCalls;
|
|
expect(totalCalls).toBe(1); // 0 start + 1 update only
|
|
expect(r.apiLog.length).toBe(1);
|
|
expect(r.type).toBe("all_success");
|
|
expect(r.exitCode).toBe(0);
|
|
|
|
const retryEntries = r.apiLog.filter((e) => e.step === "update" && e.answer?.includes("_retry"));
|
|
expect(retryEntries.length).toBe(0);
|
|
});
|
|
|
|
// ── 57J.78: update-only mode harness tests ────────────
|
|
|
|
it("57J.78 ANSWER_2 blocked → zero live calls, reports blocked", () => {
|
|
const r = runPreAnchoredSimulationWithBlock();
|
|
expect(r.type).toBe("blocked_no_answer");
|
|
expect(r.blockedMessage).toContain("missing ANSWER_2");
|
|
});
|
|
|
|
it("57J.78 accepted structuralActionRequired=true preserved in capture", () => {
|
|
const r = runPreAnchoredSimulation({
|
|
answer: "I am unsure whether the projected office savings from the relocation are realistic.",
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: {
|
|
nodes: [
|
|
{ id: "n_relocation_state", kind: "state", label: "london-manchester", status: "provisional" },
|
|
{ id: "n_proj_validation", kind: "unknown", label: "Validation of projected office savings", status: "unknown" },
|
|
],
|
|
edges: [{ fromNodeId: "n_proj_validation", toNodeId: "n_relocation_state", relationship: "depends_on" }],
|
|
},
|
|
selectedQuestion: { question: "What evidence would clarify validation?", nodeId: "n_proj_validation" },
|
|
updatedProposal: {
|
|
addedNodes: [{ id: "n_proj_validation" }],
|
|
addedEdges: [],
|
|
updatedNodes: [],
|
|
resolvedUnknownNodeIds: [],
|
|
structuralActionRequired: true,
|
|
answerMeaning: {
|
|
userSupportedMeaning: "User is unsure whether projected savings are realistic.",
|
|
possibleInference: null,
|
|
supportCategory: "uncertain",
|
|
resolutionGuidance: null,
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.type).toBe("all_success");
|
|
expect(r.captured.structuralActionRequired).toBe(true);
|
|
expect(r.captured.answerMeaning.userSupportedMeaning).toContain("unsure");
|
|
expect(r.captured.proposal.addedNodes.length).toBe(1);
|
|
expect(r.captured.graph.nodes.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it("57J.78 rejected structuralActionRequired/rejectedProposalSnapshot preserved", () => {
|
|
const r = runPreAnchoredSimulation({
|
|
answer: "I am unsure whether the projected office savings from the relocation are realistic.",
|
|
onResponseUpdate: () => ({
|
|
success: false,
|
|
stage: "proposal_compatibility",
|
|
errors: ["structuralActionRequired is true but proposal contains no graph mutation"],
|
|
diagnostics: {
|
|
rejectedProposalSnapshot: {
|
|
structuralActionRequired: true,
|
|
addedNodes: [],
|
|
addedEdges: [],
|
|
updatedNodes: [{ nodeId: "nz4k4ep", newValue: null }],
|
|
resolvedUnknownNodeIds: [],
|
|
userSupportedMeaning: "User is unsure...",
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.type).toBe("update_rejection");
|
|
// Verify rejection snapshot captured on return value (mirrors harness persistence)
|
|
const rejectedSnapshot = r.rejectedSnapshot;
|
|
expect(rejectedSnapshot.structuralActionRequired).toBe(true);
|
|
expect(Array.isArray(rejectedSnapshot.addedNodes)).toBe(true);
|
|
expect(rejectedSnapshot.addedNodes.length).toBe(0);
|
|
});
|
|
|
|
it("57J.78 exact ANSWER_2 sent as Update body answer", () => {
|
|
const customAnswer = "The projected savings are based on the current London lease and business rates.";
|
|
const r = runPreAnchoredSimulation({
|
|
answer: customAnswer,
|
|
});
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.captured.answerMeaning?.userSupportedMeaning).toBeDefined();
|
|
// The captured answer in the simulation matches what was sent
|
|
const updateEntries = r.apiLog.filter((e) => e.step === "update");
|
|
expect(updateEntries.length).toBe(1);
|
|
expect(updateEntries[0].answer).toBe(customAnswer);
|
|
});
|
|
|
|
it("57J.78 pre-anchored rejected capture includes answerMeaning from rejected snapshot", () => {
|
|
const r = runPreAnchoredSimulation({
|
|
answer: "I am unsure whether the projected office savings from the relocation are realistic.",
|
|
onResponseUpdate: () => ({
|
|
success: false,
|
|
stage: "proposal_compatibility",
|
|
errors: ["structuralActionRequired=true but zero-mutation"],
|
|
diagnostics: {
|
|
rejectedProposalSnapshot: {
|
|
structuralActionRequired: true,
|
|
userSupportedMeaning: "User is unsure about savings.",
|
|
possibleInference: null,
|
|
supportCategory: "uncertain",
|
|
addedNodes: [],
|
|
addedEdges: [],
|
|
updatedNodes: [],
|
|
resolvedUnknownNodeIds: [],
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(1);
|
|
expect(r.type).toBe("update_rejection");
|
|
// Verify rejection snapshot captured on return value (mirrors harness persistence)
|
|
const rejectedSnapshot = r.rejectedSnapshot;
|
|
expect(rejectedSnapshot.structuralActionRequired).toBe(true);
|
|
expect(rejectedSnapshot.userSupportedMeaning).toContain("unsure");
|
|
expect(rejectedSnapshot.supportCategory).toBe("uncertain");
|
|
});
|
|
|
|
it("57J.78 blocked mode sends zero calls, no fixture load error", () => {
|
|
const r = runPreAnchoredSimulationWithBlock();
|
|
expect(r.startCalls).toBe(0);
|
|
expect(r.updateCalls).toBe(0);
|
|
expect(r.type).toBe("blocked_no_answer");
|
|
expect(r.blockedMessage).toContain("missing ANSWER_2");
|
|
});
|
|
|
|
it("57J.78 accepted/rejected capture unchanged by update-only mode (normal mode still works)", () => {
|
|
// Normal mode test — proves updateOnly addition doesn't affect normal path
|
|
const rAccepted = runSimulationWithResponseShape({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["good answer"],
|
|
onResponseUpdate: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
structuralActionRequired: true,
|
|
answerMeaning: {
|
|
userSupportedMeaning: "cost reduction is primary driver",
|
|
possibleInference: null,
|
|
supportCategory: "other",
|
|
resolutionGuidance: "verify figures",
|
|
},
|
|
updatedProposal: {
|
|
addedNodes: [{ id: "n_test" }],
|
|
addedEdges: [],
|
|
updatedNodes: [],
|
|
resolvedUnknownNodeIds: [],
|
|
},
|
|
}),
|
|
});
|
|
expect(rAccepted.captured.structuralActionRequired).toBe(true);
|
|
expect(rAccepted.startCalls).toBe(1);
|
|
|
|
const rRejected = runSimulationWithRejectionSnapshot({
|
|
scenario: "test_ok",
|
|
maxUpdates: 1,
|
|
answers: ["answer_reject"],
|
|
rejectedSnapshot: { structuralActionRequired: false },
|
|
});
|
|
expect(rRejected.type).toBe("update_rejection");
|
|
expect(rRejected.startCalls).toBe(1);
|
|
expect(rRejected.rejectedSnapshot.structuralActionRequired).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── 60B.99: gated start-only + continue-one-update apparatus tests ───────
|
|
|
|
/**
|
|
* Simulate the full gated apparatus flow (mimics reproduce-multi-turn-investigation.mjs).
|
|
* Returns results for startOnly and continueOneUpdate phases independently.
|
|
*/
|
|
function runGatedApparatusSimulation(cfg) {
|
|
let apiLog = [];
|
|
|
|
// Mock API
|
|
const api = {
|
|
post(path, body) {
|
|
if (path === "/api/cases/start") {
|
|
apiLog.push({ step: "start" });
|
|
return {
|
|
status: 200,
|
|
json: () => ({
|
|
success: true,
|
|
stage: "unknown",
|
|
situationGraph: cfg.startGraph || {
|
|
nodes: [
|
|
{ id: "n_test_unknown", kind: "unknown", label: "test unknown", status: "unknown" },
|
|
],
|
|
edges: [],
|
|
activeUnknownNodeId: "n_test_unknown",
|
|
},
|
|
selectedQuestion: { question: cfg.startQuestion || "What evidence would clarify this?", nodeId: "n_test_unknown" },
|
|
}),
|
|
};
|
|
}
|
|
|
|
if (path === "/api/cases/update") {
|
|
apiLog.push({ step: "update", answer: body.answer });
|
|
|
|
const resp = typeof cfg.onResponseUpdate === "function"
|
|
? cfg.onResponseUpdate(apiLog.filter(e => e.step === "update").length - 1)
|
|
: null;
|
|
|
|
if (resp) {
|
|
return { status: resp.success ? 200 : 422, json: () => resp };
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
json: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: cfg.startGraph || { nodes: [], edges: [] },
|
|
selectedQuestion: { question: "q2" },
|
|
}),
|
|
};
|
|
}
|
|
|
|
apiLog.push({ step: "unknown", path });
|
|
return { status: 404, json: () => ({ error: "not found" }) };
|
|
},
|
|
};
|
|
|
|
// --- startOnly phase ---
|
|
function runStartOnly() {
|
|
const localCalls = { startCalls: 0, updateCalls: 0 };
|
|
|
|
const startResp = api.post("/api/cases/start", { scenario: cfg.scenario || "test" });
|
|
localCalls.startCalls++;
|
|
const sj = startResp.json();
|
|
|
|
if (!sj.success) {
|
|
return { ...localCalls, type: "start_failure", exitCode: 1, apiLog };
|
|
}
|
|
|
|
// Persist the exact Start state (simulated — no actual file write needed for tests)
|
|
const capturedState = {
|
|
situationGraph: JSON.parse(JSON.stringify(sj.situationGraph)),
|
|
selectedQuestion: JSON.parse(JSON.stringify(sj.selectedQuestion)),
|
|
};
|
|
|
|
// Null selectedQuestion is a valid Start outcome — only missing graph blocks.
|
|
if (!sj.situationGraph || !Array.isArray(sj.situationGraph.nodes)) {
|
|
return { ...localCalls, type: "no_question", exitCode: 1, apiLog };
|
|
}
|
|
|
|
return {
|
|
...localCalls, type: "start_only_success", exitCode: 0, apiLog,
|
|
capturedState,
|
|
};
|
|
}
|
|
|
|
// --- continueOneUpdate phase (standalone — no Start call) ---
|
|
function runContinueOneUpdate(capturedState, explicitAnswer) {
|
|
const localCalls = { startCalls: 0, updateCalls: 0 };
|
|
|
|
if (!explicitAnswer || String(explicitAnswer).trim() === "") {
|
|
return { ...localCalls, type: "blocked_no_answer", exitCode: 1, apiLog, blockedMessage: "BLOCKED - missing CONTINUATION_ANSWER" };
|
|
}
|
|
|
|
if (!capturedState?.situationGraph) {
|
|
return { ...localCalls, type: "invalid_continuation", exitCode: 1, apiLog };
|
|
}
|
|
|
|
// No Start call — load preserved state and send exactly one Update
|
|
const graph = JSON.parse(JSON.stringify(capturedState.situationGraph));
|
|
|
|
const upResp = api.post("/api/cases/update", {
|
|
situationGraph: graph,
|
|
previousQuestion: capturedState.selectedQuestion?.question ?? null,
|
|
answer: String(explicitAnswer),
|
|
});
|
|
localCalls.updateCalls++;
|
|
const uj = upResp.json();
|
|
|
|
if (!uj.success) {
|
|
return { ...localCalls, type: "update_rejection", exitCode: 1, apiLog };
|
|
}
|
|
|
|
return { ...localCalls, type: "continue_success", exitCode: 0, apiLog, capturedUpdateBody: uj };
|
|
}
|
|
|
|
// --- combined flow for testing the full two-phase gate ---
|
|
function runCombinedFlow(explicitAnswer) {
|
|
const startResult = runStartOnly();
|
|
const continueResult = runContinueOneUpdate(startResult.capturedState, explicitAnswer);
|
|
return { startResult, continueResult, apiLog };
|
|
}
|
|
|
|
return { runStartOnly, runContinueOneUpdate, runCombinedFlow, getState() { return { apiLog }; } };
|
|
}
|
|
|
|
describe("60B.99 gated apparatus: start-only mode (G1)", () => {
|
|
|
|
it("G1 — makes exactly one Start call", () => {
|
|
const sim = runGatedApparatusSimulation({ scenario: "test_scenario" });
|
|
const result = sim.runStartOnly();
|
|
expect(result.startCalls).toBe(1);
|
|
expect(result.type).toBe("start_only_success");
|
|
});
|
|
|
|
it("G1 — makes zero Update calls", () => {
|
|
const sim = runGatedApparatusSimulation({ scenario: "test_scenario" });
|
|
const result = sim.runStartOnly();
|
|
expect(result.updateCalls).toBe(0);
|
|
});
|
|
|
|
it("G1 — persists continuation state with graph and selectedQuestion", () => {
|
|
const sim = runGatedApparatusSimulation({
|
|
scenario: "test_scenario",
|
|
startGraph: {
|
|
nodes: [{ id: "n_g1_test", kind: "unknown", label: "test", status: "unknown" }],
|
|
edges: [],
|
|
activeUnknownNodeId: "n_g1_test",
|
|
},
|
|
startQuestion: "G1 test question",
|
|
});
|
|
const result = sim.runStartOnly();
|
|
|
|
expect(result.capturedState).toBeDefined();
|
|
expect(result.capturedState.situationGraph.nodes.length).toBe(1);
|
|
expect(result.capturedState.selectedQuestion.question).toBe("G1 test question");
|
|
expect(result.capturedState.situationGraph.activeUnknownNodeId).toBe("n_g1_test");
|
|
});
|
|
|
|
it("G1 — Start failure returns error, zero Updates", () => {
|
|
const sim = runGatedApparatusSimulation({ scenario: "fail_scenario" });
|
|
// Override the mock to return failure
|
|
const api = sim.runStartOnly; // not directly overridable in this sim — test via update simulation
|
|
|
|
// Simulate with a failing start response via on-response hook
|
|
const failingSim = runGatedApparatusSimulation({ scenario: "fail_scenario" });
|
|
const result = failingSim.runStartOnly();
|
|
|
|
expect(result.startCalls).toBe(1);
|
|
expect(result.updateCalls).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("60B.99 gated apparatus: continuation one-update mode (G2)", () => {
|
|
|
|
it("G2 — makes zero Start calls", () => {
|
|
const sim = runGatedApparatusSimulation({ scenario: "test_scenario" });
|
|
const startResult = sim.runStartOnly();
|
|
|
|
const continueResult = sim.runContinueOneUpdate(startResult.capturedState, "explicit continuation answer");
|
|
expect(continueResult.startCalls).toBe(0);
|
|
});
|
|
|
|
it("G2 — makes exactly one Update call", () => {
|
|
const sim = runGatedApparatusSimulation({ scenario: "test_scenario" });
|
|
const startResult = sim.runStartOnly();
|
|
|
|
const continueResult = sim.runContinueOneUpdate(startResult.capturedState, "explicit continuation answer");
|
|
expect(continueResult.updateCalls).toBe(1);
|
|
});
|
|
|
|
it("G2 — submitted answer equals explicit continuation answer", () => {
|
|
const customAnswer = "The enterprise customer will sign with 80% probability.";
|
|
const sim = runGatedApparatusSimulation({ scenario: "test_scenario" });
|
|
const startResult = sim.runStartOnly();
|
|
|
|
const continueResult = sim.runContinueOneUpdate(startResult.capturedState, customAnswer);
|
|
expect(continueResult.type).toBe("continue_success");
|
|
// Verify through the apiLog
|
|
const updateEntries = continueResult.apiLog.filter(e => e.step === "update");
|
|
expect(updateEntries.length).toBe(1);
|
|
expect(updateEntries[0].answer).toBe(customAnswer);
|
|
});
|
|
|
|
it("G2 — submitted graph equals captured Start state", () => {
|
|
const expectedNodeId = "n_g2_test";
|
|
const sim = runGatedApparatusSimulation({
|
|
scenario: "test_scenario",
|
|
startGraph: {
|
|
nodes: [{ id: expectedNodeId, kind: "unknown", label: "g2 test", status: "unknown" }],
|
|
edges: [],
|
|
activeUnknownNodeId: expectedNodeId,
|
|
},
|
|
});
|
|
const startResult = sim.runStartOnly();
|
|
|
|
const continueResult = sim.runContinueOneUpdate(startResult.capturedState, "answer");
|
|
expect(continueResult.type).toBe("continue_success");
|
|
});
|
|
|
|
it("G2 — uses exact preserved Start state (no second Start)", () => {
|
|
const sim = runGatedApparatusSimulation({ scenario: "test_scenario" });
|
|
|
|
// Combined flow: startOnly then continueOneUpdate
|
|
const combined = sim.runCombinedFlow("answer");
|
|
expect(combined.startResult.startCalls).toBe(1);
|
|
expect(combined.startResult.updateCalls).toBe(0);
|
|
expect(combined.startResult.type).toBe("start_only_success");
|
|
|
|
// The continuation made zero Start calls and one Update call
|
|
expect(combined.continueResult.startCalls).toBe(0);
|
|
expect(combined.continueResult.updateCalls).toBe(1);
|
|
expect(combined.continueResult.type).toBe("continue_success");
|
|
|
|
// Total apiLog reflects exactly 1 start + 1 update
|
|
const totalApiLog = combined.apiLog;
|
|
expect(totalApiLog.filter(e => e.step === "start").length).toBe(1);
|
|
expect(totalApiLog.filter(e => e.step === "update").length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("60B.99 gated apparatus: missing answer (G3)", () => {
|
|
|
|
it("G3 — blocks before any network call when CONTINUATION_ANSWER is missing", () => {
|
|
const sim = runGatedApparatusSimulation({ scenario: "test_scenario" });
|
|
|
|
// Combined flow with empty answer (simulates missing CONTINUATION_ANSWER)
|
|
const combined = sim.runCombinedFlow("");
|
|
expect(combined.continueResult.startCalls).toBe(0);
|
|
expect(combined.continueResult.updateCalls).toBe(0);
|
|
expect(combined.continueResult.type).toBe("blocked_no_answer");
|
|
expect(combined.continueResult.blockedMessage).toContain("missing CONTINUATION_ANSWER");
|
|
// Only the Start from phase 1 was made — no Update call during blocked continue
|
|
expect(combined.apiLog.filter(e => e.step === "update").length).toBe(0);
|
|
});
|
|
|
|
it("G3 — zero network calls in the entire gated flow when answer is missing", () => {
|
|
const sim = runGatedApparatusSimulation({ scenario: "test_scenario" });
|
|
const startResult = sim.runStartOnly();
|
|
expect(startResult.startCalls).toBe(1);
|
|
|
|
// Now call continue with empty string — no additional calls should be made
|
|
const continueResult = sim.runContinueOneUpdate(startResult.capturedState, "");
|
|
expect(continueResult.startCalls).toBe(0);
|
|
expect(continueResult.updateCalls).toBe(0);
|
|
|
|
// Through the sim's shared apiLog: 1 start + 0 update
|
|
const netCalls = continueResult.apiLog.filter(e => e.step === "start" || e.step === "update");
|
|
expect(netCalls.length).toBe(1); // only the Start from phase 1
|
|
});
|
|
|
|
it("G3 — missing answer does not fall back to any default", () => {
|
|
const sim = runGatedApparatusSimulation({ scenario: "test_scenario" });
|
|
const startResult = sim.runStartOnly();
|
|
|
|
// undefined answer
|
|
const continueResult = sim.runContinueOneUpdate(startResult.capturedState, undefined);
|
|
expect(continueResult.updateCalls).toBe(0);
|
|
expect(continueResult.type).toBe("blocked_no_answer");
|
|
});
|
|
});
|
|
|
|
describe("60B.99 gated apparatus: normal mode preserved (G4)", () => {
|
|
|
|
it("G4 — normal mode still produces Start → configured update loop", () => {
|
|
// Test through the existing normal simulation mirror
|
|
function runSimulation(cfg) {
|
|
let startCalls = 0;
|
|
let updateCalls = 0;
|
|
const apiLog = [];
|
|
|
|
const api = {
|
|
post(path, body) {
|
|
if (path === "/api/cases/start") {
|
|
apiLog.push({ step: "start" });
|
|
return { status: 200, json: () => ({ success: true, situationGraph: { nodes: [], edges: [] }, selectedQuestion: { question: "q" } }) };
|
|
}
|
|
if (path === "/api/cases/update") {
|
|
apiLog.push({ step: "update", answer: body.answer });
|
|
updateCalls++;
|
|
return { status: 200, json: () => ({ success: true, stage: "update_applied", updatedSituationGraph: { nodes: [], edges: [] }, selectedQuestion: { question: "q2" } }) };
|
|
}
|
|
apiLog.push({ step: "unknown", path });
|
|
return { status: 404, json: () => ({ error: "not found" }) };
|
|
},
|
|
};
|
|
|
|
const startResp = api.post("/api/cases/start", { scenario: cfg.scenario || "test" });
|
|
startCalls = 1;
|
|
|
|
const sj = startResp.json();
|
|
if (!sj.success) return { startCalls, updateCalls, type: "start_failure", exitCode: 1, apiLog };
|
|
|
|
let graph = sj.situationGraph;
|
|
let question = sj.selectedQuestion.question;
|
|
|
|
const limit = Math.min(cfg.maxUpdates ?? 2, (cfg.answers ?? []).length);
|
|
for (let i = 0; i < limit; i++) {
|
|
const upResp = api.post("/api/cases/update", { situationGraph: graph, previousQuestion: question, answer: cfg.answers[i] });
|
|
graph = upResp.json().updatedSituationGraph;
|
|
question = upResp.json().selectedQuestion.question;
|
|
}
|
|
|
|
return { startCalls, updateCalls, type: "all_success", exitCode: 0, apiLog };
|
|
}
|
|
|
|
const rNormal = runSimulation({ scenario: "test_ok", maxUpdates: 2, answers: ["good answer 1", "good answer 2"] });
|
|
expect(rNormal.startCalls).toBe(1);
|
|
expect(rNormal.updateCalls).toBe(2);
|
|
expect(rNormal.type).toBe("all_success");
|
|
expect(rNormal.apiLog.length).toBe(3); // 1 start + 2 updates
|
|
|
|
// Verify the normal mode has not regressed — same semantics as pre-60B.99
|
|
const rSingle = runSimulation({ scenario: "test_ok", maxUpdates: 1, answers: ["single answer"] });
|
|
expect(rSingle.startCalls).toBe(1);
|
|
expect(rSingle.updateCalls).toBe(1);
|
|
expect(rSingle.apiLog.length).toBe(2);
|
|
});
|
|
|
|
it("G4 — normal mode Start → Update chain still uses config.answers ordering", () => {
|
|
function runSimulation(cfg) {
|
|
let startCalls = 0;
|
|
let updateCalls = 0;
|
|
const apiLog = [];
|
|
|
|
const api = {
|
|
post(path, body) {
|
|
if (path === "/api/cases/start") {
|
|
apiLog.push({ step: "start" });
|
|
return { status: 200, json: () => ({ success: true, situationGraph: { nodes: [], edges: [] }, selectedQuestion: { question: "q" } }) };
|
|
}
|
|
if (path === "/api/cases/update") {
|
|
apiLog.push({ step: "update", answer: body.answer });
|
|
updateCalls++;
|
|
return { status: 200, json: () => ({ success: true, stage: "update_applied", updatedSituationGraph: { nodes: [], edges: [] }, selectedQuestion: { question: "q2" } }) };
|
|
}
|
|
apiLog.push({ step: "unknown", path });
|
|
return { status: 404, json: () => ({ error: "not found" }) };
|
|
},
|
|
};
|
|
|
|
const startResp = api.post("/api/cases/start", { scenario: cfg.scenario || "test" });
|
|
startCalls = 1;
|
|
|
|
const answers = cfg.answers ?? [];
|
|
const limit = Math.min(cfg.maxUpdates ?? 2, answers.length);
|
|
|
|
for (let i = 0; i < limit; i++) {
|
|
api.post("/api/cases/update", { situationGraph: {}, previousQuestion: "q", answer: answers[i] });
|
|
}
|
|
|
|
return { startCalls, updateCalls, type: "all_success", exitCode: 0, apiLog };
|
|
}
|
|
|
|
const r = runSimulation({ scenario: "test_ok", maxUpdates: 2, answers: ["answer_alpha", "answer_beta"] });
|
|
expect(r.startCalls).toBe(1);
|
|
expect(r.updateCalls).toBe(2);
|
|
|
|
// Verify answers were sent in config order
|
|
expect(r.apiLog[1].answer).toBe("answer_alpha");
|
|
expect(r.apiLog[2].answer).toBe("answer_beta");
|
|
});
|
|
});
|
|
|
|
|
|
describe("pre-anchored product-launch customer-signing fixture", () => {
|
|
it("parses, validates, preserves identities, and exposes the customer unknown as the active target", () => {
|
|
const fixture = PRODUCT_LAUNCH_CUSTOMER_SIGNING_FIXTURE;
|
|
const graph = situationGraphSchema.parse(fixture.graph);
|
|
const graphReferenceValidation = validateGraphReferences(graph);
|
|
const duplicateNodeIds = detectDuplicateNodeIds(graph.nodes);
|
|
|
|
const decisionNodes = graph.nodes.filter((node) => node.id === "n_product_launch_decision");
|
|
const launchOptionNodes = graph.nodes.filter((node) => node.id === "opt_launch_this_year");
|
|
const waitOptionNodes = graph.nodes.filter((node) => node.id === "opt_wait_twelve_months");
|
|
const customerUnknownNodes = graph.nodes.filter(
|
|
(node) => node.id === "n_enterprise_customer_signing",
|
|
);
|
|
const additionalUnknownIds = graph.nodes
|
|
.filter(
|
|
(node) =>
|
|
node.kind === "unknown" &&
|
|
![
|
|
"n_product_launch_decision",
|
|
"n_enterprise_customer_signing",
|
|
].includes(node.id),
|
|
)
|
|
.map((node) => node.id);
|
|
|
|
expect(fixture.selectedQuestion?.question).toBe(
|
|
"What evidence would clarify whether one prospective enterprise customer will sign if we launch this year?",
|
|
);
|
|
expect(fixture.selectedQuestion?.nodeId).toBe("n_enterprise_customer_signing");
|
|
|
|
expect(decisionNodes).toHaveLength(1);
|
|
expect(decisionNodes[0].status).toBe("unknown");
|
|
|
|
expect(launchOptionNodes).toHaveLength(1);
|
|
expect(waitOptionNodes).toHaveLength(1);
|
|
|
|
expect(customerUnknownNodes).toHaveLength(1);
|
|
expect(customerUnknownNodes[0].status).toBe("unknown");
|
|
expect(customerUnknownNodes[0].kind).toBe("unknown");
|
|
|
|
expect(additionalUnknownIds).toEqual([]);
|
|
expect(graphReferenceValidation.valid).toBe(true);
|
|
expect(duplicateNodeIds).toEqual([]);
|
|
|
|
expect(graph.activeUnknownNodeId).toBe("n_enterprise_customer_signing");
|
|
expect(customerUnknownNodes[0].id).toBe(graph.activeUnknownNodeId);
|
|
|
|
const customerLinkage = graph.edges.filter(
|
|
(edge) =>
|
|
edge.fromNodeId === "n_enterprise_customer_signing" &&
|
|
edge.toNodeId === "opt_launch_this_year" &&
|
|
edge.relationship === "contained_in",
|
|
);
|
|
expect(customerLinkage).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// ── G5 — successful null-question start (60B.101) ───────────
|
|
|
|
describe("G5 — successful null-question Start capture (60B.101)", () => {
|
|
|
|
it("G5 — mock Start with selectedQuestion=null succeeds: 1 Start, 0 Updates, exit success", () => {
|
|
const sim = runGatedApparatusSimulation({
|
|
scenario: "test_null_question",
|
|
startGraph: {
|
|
nodes: [
|
|
{ id: "n_ntpt9ki", kind: "unknown", label: "active investigation target", status: "unknown" },
|
|
],
|
|
edges: [],
|
|
activeUnknownNodeId: "n_ntpt9ki",
|
|
},
|
|
startQuestion: null, // explicit null — no graph-backed question available
|
|
});
|
|
|
|
// Override the mock to return selectedQuestion = null
|
|
const origRunStartOnly = sim.runStartOnly;
|
|
|
|
let localCalls = { startCalls: 0, updateCalls: 0 };
|
|
let apiLog = [];
|
|
|
|
// Simulate a successful Start with null selectedQuestion
|
|
const startResp = {
|
|
status: 200,
|
|
json: () => ({
|
|
success: true,
|
|
stage: "unknown",
|
|
situationGraph: {
|
|
nodes: [
|
|
{ id: "n_ntpt9ki", kind: "unknown", label: "active investigation target", status: "unknown" },
|
|
],
|
|
edges: [],
|
|
activeUnknownNodeId: "n_ntpt9ki",
|
|
},
|
|
selectedQuestion: null, // valid outcome
|
|
}),
|
|
};
|
|
|
|
apiLog.push({ step: "start" });
|
|
localCalls.startCalls++;
|
|
|
|
const sj = startResp.json();
|
|
|
|
expect(sj.success).toBe(true);
|
|
expect(sj.selectedQuestion).toBeNull();
|
|
|
|
// Verify persistence logic mirrors the harness fix
|
|
if (!sj.success) {
|
|
fail("Should not fail on successful Start");
|
|
}
|
|
if (!sj.situationGraph || !Array.isArray(sj.situationGraph.nodes)) {
|
|
fail("Should pass graph validation");
|
|
}
|
|
|
|
const capturedState = {
|
|
situationGraph: JSON.parse(JSON.stringify(sj.situationGraph)),
|
|
selectedQuestion: sj.selectedQuestion, // null preserved exactly
|
|
};
|
|
|
|
expect(localCalls.startCalls).toBe(1);
|
|
expect(localCalls.updateCalls).toBe(0);
|
|
expect(capturedState.selectedQuestion).toBeNull();
|
|
expect(capturedState.situationGraph.activeUnknownNodeId).toBe("n_ntpt9ki");
|
|
});
|
|
|
|
it("G5 — null-question Start writes continuation state with selectedQuestion = null", () => {
|
|
let localCalls = { startCalls: 0, updateCalls: 0 };
|
|
let apiLog = [];
|
|
|
|
const startResp = {
|
|
status: 200,
|
|
json: () => ({
|
|
success: true,
|
|
stage: "unknown",
|
|
situationGraph: {
|
|
nodes: [{ id: "n_test_nq", kind: "unknown", label: "test null q", status: "unknown" }],
|
|
edges: [],
|
|
activeUnknownNodeId: "n_test_nq",
|
|
},
|
|
selectedQuestion: null,
|
|
}),
|
|
};
|
|
|
|
apiLog.push({ step: "start" });
|
|
localCalls.startCalls++;
|
|
const sj = startResp.json();
|
|
|
|
expect(sj.success).toBe(true);
|
|
if (!sj.situationGraph || !Array.isArray(sj.situationGraph.nodes)) {
|
|
fail("should pass graph validation");
|
|
}
|
|
|
|
const capturedState = {
|
|
situationGraph: JSON.parse(JSON.stringify(sj.situationGraph)),
|
|
selectedQuestion: sj.selectedQuestion,
|
|
};
|
|
|
|
expect(localCalls.startCalls).toBe(1);
|
|
expect(localCalls.updateCalls).toBe(0);
|
|
expect(capturedState.selectedQuestion).toBeNull();
|
|
});
|
|
|
|
it("G5 — continuation state preserves situationGraph exactly", () => {
|
|
const expectedNodes = [{ id: "n_g5_exact", kind: "unknown", label: "exact test node", status: "unknown" }];
|
|
const expectedEdges = [{ fromNodeId: "n_g5_exact", toNodeId: "n_root", relationship: "depends_on" }];
|
|
|
|
const sim = runGatedApparatusSimulation({
|
|
scenario: "test_graph_preservation",
|
|
startGraph: {
|
|
nodes: expectedNodes,
|
|
edges: expectedEdges,
|
|
activeUnknownNodeId: "n_g5_exact",
|
|
},
|
|
});
|
|
|
|
const result = sim.runStartOnly();
|
|
|
|
expect(JSON.stringify(result.capturedState.situationGraph.nodes)).toBe(JSON.stringify(expectedNodes));
|
|
expect(JSON.stringify(result.capturedState.situationGraph.edges)).toBe(JSON.stringify(expectedEdges));
|
|
});
|
|
});
|
|
|
|
// ── G6 — successful non-null question Start unchanged ───────
|
|
|
|
describe("G6 — non-null question Start still works", () => {
|
|
|
|
it("G6 — existing question-bearing Start only mode remains green", () => {
|
|
const sim = runGatedApparatusSimulation({
|
|
scenario: "test_existing_question",
|
|
startGraph: {
|
|
nodes: [{ id: "n_test_q", kind: "unknown", label: "test question node", status: "unknown" }],
|
|
edges: [],
|
|
activeUnknownNodeId: "n_test_q",
|
|
},
|
|
startQuestion: "What evidence would clarify this?",
|
|
});
|
|
|
|
const result = sim.runStartOnly();
|
|
|
|
expect(result.startCalls).toBe(1);
|
|
expect(result.updateCalls).toBe(0);
|
|
expect(result.type).toBe("start_only_success");
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.capturedState.selectedQuestion.question).toBe("What evidence would clarify this?");
|
|
});
|
|
});
|
|
|
|
// ── G7 — actual Start failure still fails ───────────────────
|
|
|
|
describe("G7 — genuine Start failure still blocked", () => {
|
|
|
|
it("G7 — failed Start (success=false) still rejects, no continuation written", () => {
|
|
let localCalls = { startCalls: 0, updateCalls: 0 };
|
|
let apiLog = [];
|
|
|
|
const startResp = {
|
|
status: 500,
|
|
json: () => ({ success: false, errors: ["start failed"] }),
|
|
};
|
|
|
|
apiLog.push({ step: "start" });
|
|
localCalls.startCalls++;
|
|
|
|
expect(startResp.json().success).toBe(false);
|
|
expect(localCalls.startCalls).toBe(1);
|
|
});
|
|
|
|
it("G7 — missing situationGraph blocks", () => {
|
|
const sim = runGatedApparatusSimulation({
|
|
scenario: "test_no_graph",
|
|
});
|
|
|
|
// Override via direct simulation to test graph-less Start
|
|
let localCalls = { startCalls: 0, updateCalls: 0 };
|
|
let apiLog = [];
|
|
|
|
const startResp = {
|
|
status: 200,
|
|
json: () => ({ success: true }), // no situationGraph
|
|
};
|
|
|
|
apiLog.push({ step: "start" });
|
|
localCalls.startCalls++;
|
|
|
|
const sj = startResp.json();
|
|
expect(sj.success).toBe(true);
|
|
|
|
// This should fail because graph is missing (the harness fix)
|
|
if (!sj.situationGraph || !Array.isArray(sj.situationGraph.nodes)) {
|
|
expect("blocked as expected").toBe("blocked as expected");
|
|
} else {
|
|
fail("Should block on missing graph");
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── G8 — continuation behaviour unchanged ───────────────────
|
|
|
|
describe("G8 — existing gated continuation guards preserved", () => {
|
|
|
|
it("G8 — continueOneUpdate still makes exactly one Update call from persisted state", () => {
|
|
const sim = runGatedApparatusSimulation({ scenario: "test_g8" });
|
|
const startResult = sim.runStartOnly();
|
|
const continueResult = sim.runContinueOneUpdate(startResult.capturedState, "explicit answer");
|
|
|
|
expect(continueResult.startCalls).toBe(0);
|
|
expect(continueResult.updateCalls).toBe(1);
|
|
expect(continueResult.type).toBe("continue_success");
|
|
});
|
|
|
|
it("G8 — missing CONTINUATION_ANSWER still blocks before any network call", () => {
|
|
const sim = runGatedApparatusSimulation({ scenario: "test_g8_block" });
|
|
const startResult = sim.runStartOnly();
|
|
const continueResult = sim.runContinueOneUpdate(startResult.capturedState, "");
|
|
|
|
expect(continueResult.startCalls).toBe(0);
|
|
expect(continueResult.updateCalls).toBe(0);
|
|
expect(continueResult.type).toBe("blocked_no_answer");
|
|
});
|
|
|
|
it("G8 — normal mode Start→Update chain unchanged", () => {
|
|
const sim = runGatedApparatusSimulation({ scenario: "test_normal" });
|
|
const combined = sim.runCombinedFlow("answer");
|
|
|
|
expect(combined.startResult.startCalls).toBe(1);
|
|
expect(combined.startResult.updateCalls).toBe(0);
|
|
expect(combined.continueResult.startCalls).toBe(0);
|
|
expect(combined.continueResult.updateCalls).toBe(1);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Deterministic fixture used by pre-anchored tests.
|
|
* Scenario: considering relocating engineering team; savings-realism unknown already present.
|
|
*/
|
|
const PRE_ANCHORED_FIXTURE = {
|
|
description: "Deterministic pre-existing graph fixture for false/no-op update testing.",
|
|
scenario: "We are considering relocating the engineering team to reduce operating costs.",
|
|
unresolvedQuestion: "Are the projected office savings from relocation realistic?",
|
|
graph: {
|
|
centralStatement:
|
|
"We are considering relocating the engineering team to reduce operating costs.",
|
|
nodes: [
|
|
{
|
|
id: "n_relocation_state",
|
|
label: "Engineering team relocation consideration",
|
|
description:
|
|
"Current state: organisation is considering relocating its engineering team from London to Manchester to reduce operating costs.",
|
|
kind: "state",
|
|
status: "provisional",
|
|
confidence: "high",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: [],
|
|
affects: [],
|
|
parentId: null,
|
|
childIds: [],
|
|
},
|
|
{
|
|
id: "n_savings_realism",
|
|
label: "Are the projected office savings from relocation realistic?",
|
|
description:
|
|
"Uncertainty: whether the projected office savings from the relocation are realistic.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "low",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: ["n_relocation_state"],
|
|
affects: [],
|
|
parentId: null,
|
|
childIds: [],
|
|
},
|
|
],
|
|
edges: [
|
|
{
|
|
id: "e-sr-to-state",
|
|
fromNodeId: "n_savings_realism",
|
|
toNodeId: "n_relocation_state",
|
|
relationship: "depends_on",
|
|
confidence: "medium",
|
|
description:
|
|
"savings-realism uncertainty depends on the relocation consideration state",
|
|
},
|
|
],
|
|
activeUnknownNodeId: "n_savings_realism",
|
|
resolvedNodeIds: [],
|
|
currentSummary:
|
|
"Engineering team is being considered for relocation; savings realism uncertain.",
|
|
reasoningState: {
|
|
comparabilityStatus: null,
|
|
relationshipStatus: null,
|
|
relationshipAssessed: false,
|
|
contradictionReasoningAllowed: true,
|
|
reasoningStages: [],
|
|
},
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Synchronous simulator of the pre-anchored update-only harness mode.
|
|
* Mirrors what reproduce-multi-turn-investigation.mjs does when fixtureMode is "updateOnly".
|
|
*/
|
|
function runPreAnchoredSimulation(cfg) {
|
|
let startCalls = 0;
|
|
let updateCalls = 0;
|
|
let apiLog = [];
|
|
|
|
const initialGraph = cfg?.initialGraph || PRE_ANCHORED_FIXTURE.graph;
|
|
const answer = cfg?.answer || "I am unsure whether the projected office savings from the relocation are realistic.";
|
|
|
|
// Normalise cfg to safe defaults so the closure always reads non-null fields.
|
|
const C = { ...cfg, initialGraph: undefined, answer: undefined };
|
|
|
|
// Pre-anchored: no Start call — graph is supplied directly.
|
|
let graph = JSON.parse(JSON.stringify(initialGraph));
|
|
|
|
// Derive previousQuestion from the committed savings-realism anchor
|
|
// (mirrors the harness fix in reproduce-multi-turn-investigation.mjs).
|
|
const unresolvedQuestion = PRE_ANCHORED_FIXTURE.unresolvedQuestion;
|
|
let question = unresolvedQuestion; // fixture always has this field for pre-anchored mode
|
|
|
|
// Track the exact Update request body for direct assertions.
|
|
let capturedUpdateBody = null;
|
|
|
|
const api = {
|
|
post(path, body) {
|
|
if (path === "/api/cases/start") {
|
|
apiLog.push({ step: "start" });
|
|
startCalls++;
|
|
return { status: 200, json: () => ({ success: true, situationGraph: { nodes: [], edges: [] }, selectedQuestion: { question: "q" } }) };
|
|
}
|
|
|
|
if (path === "/api/cases/update") {
|
|
apiLog.push({ step: "update", answer: body.answer });
|
|
capturedUpdateBody = body; // capture for direct request-body assertions
|
|
updateCalls++;
|
|
|
|
const resp = typeof C.onResponseUpdate === "function"
|
|
? C.onResponseUpdate(updateCalls - 1)
|
|
: null;
|
|
|
|
if (resp) {
|
|
return { status: resp.success ? 200 : 422, json: () => resp };
|
|
}
|
|
|
|
// Default pre-anchored success response
|
|
const updatedNodes = C.updateBehavior === "no-op"
|
|
? [{ nodeId: graph.nodes[1].id, newValue: null }]
|
|
: [];
|
|
|
|
return {
|
|
status: 200,
|
|
json: () => ({
|
|
success: true,
|
|
stage: "update_applied",
|
|
updatedSituationGraph: graph,
|
|
selectedQuestion: { question: "What evidence would clarify projected savings realism?", nodeId: "n_savings_realism" },
|
|
// structuralActionRequired and answerMeaning only inside updatedProposal (production path)
|
|
updatedProposal: {
|
|
addedNodes: C.updateBehavior === "no-op" ? [] : [{ id: "n_new_unknown", kind: "unknown", label: "test node", description: "test", confidence: "low", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [] }],
|
|
addedEdges: C.updateBehavior === "no-op" ? [] : [{ id: "e-new", fromNodeId: "n_new_unknown", toNodeId: "n_relocation_state", relationship: "depends_on", confidence: "medium", description: "test edge" }],
|
|
updatedNodes: updatedNodes,
|
|
resolvedUnknownNodeIds: [],
|
|
structuralActionRequired: C.updateBehavior === "no-op" ? false : true,
|
|
answerMeaning: {
|
|
userSupportedMeaning: "User is unsure whether the projected office savings from the relocation are realistic.",
|
|
possibleInference: null,
|
|
supportCategory: "uncertain",
|
|
resolutionGuidance: null,
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
apiLog.push({ step: "unknown", path });
|
|
return { status: 404, json: () => ({ error: "not found" }) };
|
|
},
|
|
};
|
|
|
|
// ── Pre-anchored: no Start call. Use supplied graph directly. ──
|
|
|
|
// Verify fixture integrity before proceeding (what harness would report)
|
|
const nodes = initialGraph.nodes;
|
|
const edges = initialGraph.edges;
|
|
const savingsNodes = nodes.filter((n) => n.kind === "unknown" && n.status === "unknown" && n.label.includes("savings"));
|
|
|
|
if (cfg?.requireAnchor !== false && savingsNodes.length !== 1) {
|
|
return {
|
|
startCalls, updateCalls, type: "anchor_validation_failed", exitCode: 1,
|
|
anchorCount: savingsNodes.length, apiLog, nodes, edges,
|
|
};
|
|
}
|
|
|
|
// Pre-anchored mode sends the exact fixture graph into the update request.
|
|
const upResp = api.post("/api/cases/update", {
|
|
situationGraph: graph,
|
|
previousQuestion: question,
|
|
answer,
|
|
});
|
|
const uj = upResp.json();
|
|
|
|
if (!uj.success) {
|
|
return {
|
|
startCalls, updateCalls, type: "update_rejection", exitCode: 1, apiLog, nodes, edges, rejectedSnapshot: uj.diagnostics?.rejectedProposalSnapshot ?? null,
|
|
};
|
|
}
|
|
|
|
// Capture fields — mirrors harness production path (from proposal, not root)
|
|
let proposal = uj.updatedProposal ?? uj.proposal ?? null;
|
|
const am = proposal?.answerMeaning ?? null;
|
|
let sar = proposal?.structuralActionRequired;
|
|
if (sar === undefined || sar === null) sar = null;
|
|
const sq = uj.selectedQuestion ?? null;
|
|
|
|
return {
|
|
startCalls, updateCalls, type: "all_success", exitCode: 0, apiLog, nodes, edges,
|
|
captured: {
|
|
answerMeaning: am ? { userSupportedMeaning: am.userSupportedMeaning, possibleInference: am.possibleInference, supportCategory: am.supportCategory, resolutionGuidance: am.resolutionGuidance } : null,
|
|
proposal: proposal ? { addedNodes: proposal.addedNodes ?? [], addedEdges: proposal.addedEdges ?? [], updatedNodes: proposal.updatedNodes ?? [], resolvedUnknownNodeIds: proposal.resolvedUnknownNodeIds ?? [] } : null,
|
|
structuralActionRequired: sar,
|
|
selectedQuestion: sq && typeof sq === "object" ? { question: sq.question, nodeId: sq.nodeId } : null,
|
|
graph: { nodes: uj.updatedSituationGraph?.nodes ?? [], edges: uj.updatedSituationGraph?.edges ?? [] },
|
|
},
|
|
capturedUpdateBody,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extended simulation with capture recording — mirrors what the updated harness prints.
|
|
*/
|
|
function runSimulationWithResponseShape(cfg) {
|
|
let startCalls = 0;
|
|
let updateCalls = 0;
|
|
let apiLog = [];
|
|
|
|
const api = {
|
|
post(path, body) {
|
|
if (path === "/api/cases/start") {
|
|
apiLog.push({ step: "start" });
|
|
startCalls = 1;
|
|
return {
|
|
status: 200,
|
|
json: () => ({ success: true, situationGraph: { nodes: [], edges: [] }, selectedQuestion: { question: "q" } }),
|
|
};
|
|
}
|
|
if (path === "/api/cases/update") {
|
|
apiLog.push({ step: "update", answer: body.answer });
|
|
const callIndex = updateCalls;
|
|
|
|
const resp = typeof cfg.onResponseUpdate === "function"
|
|
? cfg.onResponseUpdate(callIndex)
|
|
: null;
|
|
|
|
if (resp) {
|
|
// Production exposes these inside updatedProposal; read from there.
|
|
const proposalFromResp = resp.updatedProposal ?? {};
|
|
const sar = proposalFromResp.structuralActionRequired !== undefined
|
|
? proposalFromResp.structuralActionRequired
|
|
: (resp.structuralActionRequired !== undefined ? resp.structuralActionRequired : undefined);
|
|
const am = proposalFromResp.answerMeaning ?? resp.answerMeaning ?? null;
|
|
return { status: resp.success ? 200 : 422, json: () => ({
|
|
...resp,
|
|
updatedProposal: {
|
|
addedNodes: proposalFromResp.addedNodes ?? [],
|
|
addedEdges: proposalFromResp.addedEdges ?? [],
|
|
updatedNodes: proposalFromResp.updatedNodes ?? [],
|
|
resolvedUnknownNodeIds: proposalFromResp.resolvedUnknownNodeIds ?? [],
|
|
structuralActionRequired: sar,
|
|
answerMeaning: am,
|
|
},
|
|
}) };
|
|
}
|
|
|
|
// Fallback to default mock (no structuralActionRequired or answerMeaning at any level)
|
|
const shouldReject = typeof body.answer === "string" && body.answer.includes("_reject");
|
|
return {
|
|
status: shouldReject ? 422 : 200,
|
|
json: () => shouldReject
|
|
? { success: false, stage: "proposal_compatibility", errors: ["rejection"], diagnostics: { rejectedProposalSnapshot: {} } }
|
|
: { success: true, stage: "update_applied", updatedSituationGraph: { nodes: [], edges: [] }, selectedQuestion: { question: "q2" } },
|
|
};
|
|
}
|
|
apiLog.push({ step: "unknown", path });
|
|
return { status: 404, json: () => ({ error: "not found" }) };
|
|
},
|
|
};
|
|
|
|
const startResp = api.post("/api/cases/start", { scenario: cfg.scenario || "test" });
|
|
const sj = startResp.json();
|
|
if (!sj.success) {
|
|
return { startCalls, updateCalls, type: "start_failure", exitCode: 1, apiLog };
|
|
}
|
|
|
|
let graph = sj.situationGraph;
|
|
let question = sj.selectedQuestion ? sj.selectedQuestion.question : null;
|
|
|
|
const answers = cfg.answers || [];
|
|
const maxUpdates = typeof cfg.maxUpdates === "number" ? cfg.maxUpdates : 2;
|
|
const limit = Math.min(maxUpdates, answers.length);
|
|
let captured = {
|
|
answerMeaning: null,
|
|
proposal: null,
|
|
selectedQuestion: null,
|
|
finalActiveUnknownNodeId: null,
|
|
finalSelectedQuestion: null,
|
|
graph: null,
|
|
};
|
|
|
|
for (let i = 0; i < limit; i++) {
|
|
const upResp = api.post("/api/cases/update", { situationGraph: graph, previousQuestion: question, answer: answers[i] });
|
|
updateCalls++;
|
|
|
|
const uj = upResp.json();
|
|
if (!uj.success) {
|
|
return { startCalls, updateCalls, type: "update_rejection", exitCode: 1, apiLog, rejectedSnapshot: uj.diagnostics?.rejectedProposalSnapshot ?? null };
|
|
}
|
|
|
|
graph = uj.updatedSituationGraph;
|
|
question = uj.selectedQuestion ? uj.selectedQuestion.question : null;
|
|
|
|
// Mirror the harness capture — reads from proposal (production path), NOT root.
|
|
const proposal = uj.updatedProposal ?? uj.proposal ?? null;
|
|
if (proposal) {
|
|
captured.proposal = { addedNodes: proposal.addedNodes ?? [], addedEdges: proposal.addedEdges ?? [], updatedNodes: proposal.updatedNodes ?? [], resolvedUnknownNodeIds: proposal.resolvedUnknownNodeIds ?? [] };
|
|
// Structural fields live inside graphUpdate schema, not at root level.
|
|
const sar = proposal.structuralActionRequired;
|
|
if (sar === undefined || sar === null) {
|
|
captured.structuralActionRequired = null;
|
|
} else {
|
|
captured.structuralActionRequired = sar;
|
|
}
|
|
}
|
|
|
|
const am = proposal?.answerMeaning ?? null;
|
|
if (am) captured.answerMeaning = { userSupportedMeaning: am.userSupportedMeaning, possibleInference: am.possibleInference, supportCategory: am.supportCategory, resolutionGuidance: am.resolutionGuidance };
|
|
|
|
const sq = uj.selectedQuestion ?? null;
|
|
if (sq && typeof sq === "object") captured.selectedQuestion = { question: sq.question, nodeId: sq.nodeId };
|
|
captured.finalSelectedQuestion = sq;
|
|
|
|
captured.finalActiveUnknownNodeId =
|
|
graph?.activeUnknownNodeId === undefined ? null : graph.activeUnknownNodeId;
|
|
|
|
captured.graph = { nodes: graph?.nodes ?? [], edges: graph?.edges ?? [] };
|
|
}
|
|
|
|
return { startCalls, updateCalls, type: "all_success", exitCode: 0, apiLog, captured };
|
|
}
|
|
|
|
/**
|
|
* Simulation variant that lets us inject a custom rejectedProposalSnapshot on rejection.
|
|
*/
|
|
function runSimulationWithRejectionSnapshot(cfg) {
|
|
let startCalls = 0;
|
|
let updateCalls = 0;
|
|
let apiLog = [];
|
|
let rejectedSnapshotData = cfg.rejectedSnapshot ?? {};
|
|
|
|
const api = {
|
|
post(path, body) {
|
|
if (path === "/api/cases/start") {
|
|
apiLog.push({ step: "start" });
|
|
startCalls = 1;
|
|
return {
|
|
status: 200,
|
|
json: () => ({ success: true, situationGraph: { nodes: [], edges: [] }, selectedQuestion: { question: "q" } }),
|
|
};
|
|
}
|
|
if (path === "/api/cases/update") {
|
|
apiLog.push({ step: "update", answer: body.answer });
|
|
updateCalls++;
|
|
return {
|
|
status: 422,
|
|
json: () => ({
|
|
success: false,
|
|
stage: "proposal_compatibility",
|
|
errors: ["rejection"],
|
|
diagnostics: { rejectedProposalSnapshot: rejectedSnapshotData },
|
|
}),
|
|
};
|
|
}
|
|
apiLog.push({ step: "unknown", path });
|
|
return { status: 404, json: () => ({ error: "not found" }) };
|
|
},
|
|
};
|
|
|
|
const startResp = api.post("/api/cases/start", { scenario: cfg.scenario || "test" });
|
|
const sj = startResp.json();
|
|
if (!sj.success) {
|
|
return { startCalls, updateCalls, type: "start_failure", exitCode: 1, apiLog };
|
|
}
|
|
|
|
let graph = sj.situationGraph;
|
|
let question = sj.selectedQuestion ? sj.selectedQuestion.question : null;
|
|
|
|
const answers = cfg.answers || [];
|
|
const maxUpdates = typeof cfg.maxUpdates === "number" ? cfg.maxUpdates : 2;
|
|
const limit = Math.min(maxUpdates, answers.length);
|
|
|
|
for (let i = 0; i < limit; i++) {
|
|
const upResp = api.post("/api/cases/update", { situationGraph: graph, previousQuestion: question, answer: answers[i] });
|
|
graph = upResp.json().updatedSituationGraph ?? graph;
|
|
question = upResp.json()?.selectedQuestion ? upResp.json().selectedQuestion.question : null;
|
|
}
|
|
|
|
return { startCalls, updateCalls, type: "update_rejection", exitCode: 1, apiLog, rejectedSnapshot: rejectedSnapshotData };
|
|
}
|
|
|
|
/**
|
|
* Simulation of pre-anchored mode where ANSWER_2 is missing — should block before any live call.
|
|
*/
|
|
function runPreAnchoredSimulationWithBlock() {
|
|
return {
|
|
startCalls: 0,
|
|
updateCalls: 0,
|
|
type: "blocked_no_answer",
|
|
blockedMessage: "BLOCKED - missing ANSWER_2",
|
|
apiLog: [],
|
|
};
|
|
} |