diff --git a/scripts/reproduce-multi-turn-investigation.mjs b/scripts/reproduce-multi-turn-investigation.mjs index fbc0f4a..7faa00b 100644 --- a/scripts/reproduce-multi-turn-investigation.mjs +++ b/scripts/reproduce-multi-turn-investigation.mjs @@ -88,6 +88,14 @@ async function main() { console.log(`\ndiagnostics.rejectedProposalSnapshot: ${JSON.stringify(updateResult.json.diagnostics.rejectedProposalSnapshot, null, 2)}`); } + // ── Capture structuralActionRequired from rejected snapshot if present ── + const rejectedSnapshot = updateResult.json?.diagnostics?.rejectedProposalSnapshot ?? null; + if (rejectedSnapshot && "structuralActionRequired" in rejectedSnapshot) { + console.log(`structuralActionRequired (from rejected proposal snapshot): ${JSON.stringify(rejectedSnapshot.structuralActionRequired)}`); + } else { + console.log(`structuralActionRequired: UNAVAILABLE`); + } + reportCallAccounting(); console.log("\n*** UPDATE REJECTED — STOPPING (no retry). ***"); process.exitCode = 1; @@ -120,6 +128,14 @@ async function main() { console.log(`addedEdges: ${JSON.stringify(proposal.addedEdges ?? [])}`); } + // ── Capture structuralActionRequired from accepted update ─ + const sar = updateResult.json.structuralActionRequired; + if (sar === undefined || sar === null) { + console.log(`structuralActionRequired: null`); + } else { + console.log(`structuralActionRequired: ${JSON.stringify(sar)}`); + } + // ── Capture selectedQuestion node reference ──────────── const sq = updateResult.json.selectedQuestion ?? null; if (sq && typeof sq === "object") { diff --git a/tests/reproduce-multi-turn-investigation.harness.test.js b/tests/reproduce-multi-turn-investigation.harness.test.js index ae34510..3e0a841 100644 --- a/tests/reproduce-multi-turn-investigation.harness.test.js +++ b/tests/reproduce-multi-turn-investigation.harness.test.js @@ -376,6 +376,199 @@ describe("reproduce-multi-turn-investigation harness: one-shot semantics", () => 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" }, + structuralActionRequired: true, + updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] }, + }), + }); + 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" }, + structuralActionRequired: false, + updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] }, + }), + }); + 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" }, + structuralActionRequired: null, + updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] }, + }), + }); + 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" }, + structuralActionRequired: true, + updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] }, + }), + }); + expect(rSuccess.updateCalls).toBe(2); + expect(rSuccess.startCalls).toBe(1); + expect(rSuccess.apiLog.length).toBe(3); // 1 start + 2 updates, no extras + }); }); /** @@ -442,7 +635,7 @@ function runSimulationWithResponseShape(cfg) { const uj = upResp.json(); if (!uj.success) { - return { startCalls, updateCalls, type: "update_rejection", exitCode: 1, apiLog }; + return { startCalls, updateCalls, type: "update_rejection", exitCode: 1, apiLog, rejectedSnapshot: uj.diagnostics?.rejectedProposalSnapshot ?? null }; } graph = uj.updatedSituationGraph; @@ -455,6 +648,14 @@ function runSimulationWithResponseShape(cfg) { const proposal = uj.updatedProposal ?? uj.proposal ?? null; if (proposal) captured.proposal = { addedNodes: proposal.addedNodes ?? [], addedEdges: proposal.addedEdges ?? [], updatedNodes: proposal.updatedNodes ?? [], resolvedUnknownNodeIds: proposal.resolvedUnknownNodeIds ?? [] }; + // Mirror the harness structuralActionRequired capture for accepted updates + const sar = uj.structuralActionRequired; + if (sar === undefined || sar === null) { + captured.structuralActionRequired = null; + } else { + captured.structuralActionRequired = sar; + } + const sq = uj.selectedQuestion ?? null; if (sq && typeof sq === "object") captured.selectedQuestion = { question: sq.question, nodeId: sq.nodeId }; @@ -462,4 +663,63 @@ function runSimulationWithResponseShape(cfg) { } 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 }; } \ No newline at end of file