test(harness): support gated live investigation continuation
This commit is contained in:
@@ -1449,6 +1449,391 @@ describe("reproduce-multi-turn-investigation harness: one-shot semantics", () =>
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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)),
|
||||
};
|
||||
|
||||
if (!sj.selectedQuestion?.question) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user