import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts"; import { CHOICE_ACTION, STOP_ACTION, composeChoiceNarration, isStructuredChoiceUserText, quoteIsFromAssistantQuestion, shouldContinueAfterStructuredChoice, userVisibleChoiceLine, } from "../src/lib/rectification-agentic/v9/choice-action.ts"; import { isNearBottom, shouldFollowLatest, shouldShowJumpToLatest } from "../src/lib/rectification-sticky-scroll.ts"; import { projectTurnDecision, TURN_DECISION_MAX_BYTES, turnDecisionByteLength } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { mapModelFinishToErrorCode, userFacingRunFailure, isIncompleteRunBanner, } from "../src/lib/rectification-agentic/v9/run-diagnostic.ts"; import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; import { parseV9CaseDossier, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CASE_ID, EVIDENCE_ID, FOCUS_ID, SESSION_ID, TURN_ID, USER_ID, activeFocusFixture, candidateSnapshotFixture, computeFixture, conversationSummaryFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const QUESTION_ID = "question-1"; const NEXT_FOCUS_ID = "acacacac-acac-4cac-8cac-acacacacacac"; const EDUCATION_ID = "44444444-4444-4444-8444-444444444445"; const RELATIONSHIP_ID = "44444444-4444-4444-8444-444444444446"; const CAREER_EXIT_ID = "44444444-4444-4444-8444-444444444447"; const RELATIONSHIP_END_ID = "44444444-4444-4444-8444-444444444448"; const STYLE_OPTIONS = [ { label: "明确发生且时间吻合", answer_class: "yes" as const }, { label: "发生过但程度较弱", answer_class: "weak_yes" as const }, { label: "明确没有发生", answer_class: "no" as const }, { label: "这段记不清楚", answer_class: "unsure" as const }, ]; const RELOCATION_2015_PROBE = { id: "p-reloc-2015", semantic_key: "relocation.2015.05.dasha_boundary", candidate_split_hash: "reloc-2015-split", domain: "relocation", year: 2015, month: 5, question: "2015 年 5 月前后有没有搬家或长期住到外地?", candidate_ids: ["05:00", "05:10"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, { answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.87, source: "dasha_boundary", }; const EDUCATION_2014_PROBE = { id: "p-edu-2014", semantic_key: "education.2014", candidate_split_hash: "edu-2014-split", domain: "education", year: 2014, question: "2014 年前后有没有升学、转学或换学习环境?", candidate_ids: ["05:00", "05:10"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, { answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.64, source: "dasha_activation", }; const FAMILY_2021_COLLECT = { year: 2021, year_label: "2021 年前后", domain: "family", event_family: "家人结婚、添丁或住院", source: "age_band", tracks: ["vimshottari", "narayana"], tracks_agree: false, unique_minute_claim: false, user_meaning: "时间范围锁定 2021 年前后;领域锁定 family;语义目标是家人结婚、添丁或住院。", role: "collect", phase: "evidence_collection", information_gain: 0, semantic_key: "family.2021", candidate_split_hash: "family:2021", candidate_ids: [], expected_outcomes: [], style_options: STYLE_OPTIONS, choice_kind: "existence", }; const YEARLESS_D24_PROBE = { id: "contrast:varga.d24.05:00/05:10", semantic_key: "varga.d24.05:00/05:10", candidate_split_hash: "varga.d24.05:00/05:10", domain: "education", year: 0, question: "引擎给出的区分机会绑定 D24。", candidate_ids: ["05:00", "05:10"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, { answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] }, ], information_gain: 2.5, source: "varga_contrast", }; function eventProbeFromConflict(probe: typeof RELOCATION_2015_PROBE | typeof EDUCATION_2014_PROBE) { return { year: probe.year, year_label: `${probe.year} 年前后`, ...("month" in probe && probe.month ? { month: probe.month, year_label: `${probe.year} 年 ${probe.month} 月前后` } : {}), domain: probe.domain, event_family: probe.domain === "education" ? "升学、转学或换学习环境" : "搬家或长期住到外地", source: probe.source, tracks: ["vimshottari", "narayana"], tracks_agree: false, unique_minute_claim: false, user_meaning: probe.question, role: "distinguish", phase: "candidate_discriminator", information_gain: probe.information_gain, semantic_key: probe.semantic_key, candidate_split_hash: probe.candidate_split_hash, candidate_ids: probe.candidate_ids, expected_outcomes: probe.expected_outcomes, style_options: STYLE_OPTIONS, choice_kind: "existence", }; } function inferenceState() { return buildInferenceState({ range_start: "04:50", range_end: "05:10", candidates: [ { id: "05:00", time: "05:00", relative_support: 10 }, { id: "05:10", time: "05:10", relative_support: 10 }, ], events: [ { id: "e1", domain: "education", year: 2016, precision: "month" }, { id: "e2", domain: "career", year: 2018, precision: "year" }, { id: "e3", domain: "relationship", year: 2021, precision: "year" }, { id: "e4", domain: "family", year: 2023, precision: "year" }, ], probes: [{ id: "p-cd", semantic_key: "career.2015", candidate_split_hash: "05:00|05:10", domain: "career", year: 2015, question: "2016 年前后有没有高考或重要考试发挥失常?", candidate_ids: ["05:00", "05:10"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, { answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.4, source: "dasha_boundary", }], }); } function choiceDossier() { const inference = inferenceState(); const snapshot = candidateSnapshotFixture(); Object.assign(snapshot.decision_receipt, { inference_state: inference }); return dossierFixture({ latestResult: snapshot, conversationSummary: conversationSummaryFixture({ activeFocus: activeFocusFixture({ expectedAnswerSchema: { choice: { prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?", option_a: "是,大概就在那段时间", option_b: "有类似,但年份不对或不够重大", option_c: "没有明显发生", option_d: "不记得 / 不确定", options: [ { key: "A", label: "是,大概就在那段时间", answer_class: "yes" }, { key: "B", label: "有类似,但年份不对或不够重大", answer_class: "weak_yes" }, { key: "C", label: "没有明显发生", answer_class: "no" }, { key: "D", label: "不记得 / 不确定", answer_class: "unsure" }, ], }, probe_id: "p-cd", semantic_key: "career.2015", }, }), }), }); } function twoProbeInference() { return buildInferenceState({ range_start: "04:45", range_end: "05:15", candidates: [ { id: "05:00", time: "05:00", relative_support: 16 }, { id: "05:10", time: "05:10", relative_support: 14 }, ], events: [ { id: "e1", domain: "career", year: 2020, precision: "month" }, { id: "e2", domain: "career", year: 2024, precision: "day" }, { id: "e3", domain: "relationship", year: 2024, precision: "month" }, { id: "e4", domain: "career", year: 2026, precision: "day" }, { id: "e5", domain: "relationship", year: 2024, precision: "day" }, ], probes: [RELOCATION_2015_PROBE, EDUCATION_2014_PROBE], }); } function fourEventRows() { return [ { id: EVIDENCE_ID, source_turn_id: TURN_ID, subject: "self", event_kind: "career_entry", domain: "career", occurred_from: "2020-04-01", occurred_to: "2020-04-01", date_precision: "month", summary: "2020 年 4 月开始实习", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-08-28T07:35:45.000Z", }, { id: EDUCATION_ID, source_turn_id: TURN_ID, subject: "self", event_kind: "career_entry", domain: "career", occurred_from: "2024-04-07", occurred_to: null, date_precision: "day", summary: "2024 年 4 月 7 日正式入职", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-08-28T07:36:35.000Z", }, { id: RELATIONSHIP_ID, source_turn_id: TURN_ID, subject: "self", event_kind: "relationship_start", domain: "relationship", occurred_from: "2024-05-01", occurred_to: "2024-05-01", date_precision: "month", summary: "2024 年 5 月认识一位女生", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-08-28T07:37:02.000Z", }, { id: CAREER_EXIT_ID, source_turn_id: TURN_ID, subject: "self", event_kind: "career_exit", domain: "career", occurred_from: "2026-08-11", occurred_to: null, date_precision: "day", summary: "2026 年 8 月 11 日离职", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-08-28T07:36:35.000Z", }, { id: RELATIONSHIP_END_ID, source_turn_id: TURN_ID, subject: "self", event_kind: "relationship_end", domain: "relationship", occurred_from: "2024-08-08", occurred_to: "2024-08-08", date_precision: "day", summary: "2024 年 8 月 8 日与对方分手", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-08-28T07:37:02.000Z", }, ]; } function twoProbeDossier() { const inference = twoProbeInference(); const snapshot = candidateSnapshotFixture({ decisionReceipt: { inference_state: inference, discriminating_event_probes: [ eventProbeFromConflict(RELOCATION_2015_PROBE), eventProbeFromConflict(EDUCATION_2014_PROBE), ], }, }); return dossierFixture({ evidenceCount: 5, evidence: fourEventRows(), latestResult: snapshot, conversationSummary: conversationSummaryFixture({ activeFocus: activeFocusFixture({ intent: "distinguish_candidates", targetDomain: "relocation", targetKind: "home_change", expectedAnswerSchema: { choice: { prompt: "2015 年 5 月前后,有没有搬家或长期住到外地?", option_a: "明确发生且时间吻合", option_b: "发生过但程度较弱", option_c: "明确没有发生", option_d: "这段记不清楚", options: STYLE_OPTIONS.map((option, index) => ({ key: (["A", "B", "C", "D"] as const)[index]!, label: option.label, answer_class: option.answer_class, })), }, probe_id: RELOCATION_2015_PROBE.id, semantic_key: RELOCATION_2015_PROBE.semantic_key, candidate_split_hash: RELOCATION_2015_PROBE.candidate_split_hash, }, }), }), turns: [{ id: TURN_ID, role: "assistant", text: "接下来有一个问题需要您点选一下,麻烦看一下下方选项。", status: "completed", created_at: "2026-08-28T07:36:54.000Z", completed_at: "2026-08-28T07:37:34.000Z", }], }); } function familyCollectInference() { return buildInferenceState({ range_start: "04:45", range_end: "05:15", candidates: [ { id: "05:00", time: "05:00", relative_support: 16 }, { id: "05:10", time: "05:10", relative_support: 14 }, ], events: [ { id: "e1", domain: "career", year: 2020, precision: "month" }, { id: "e2", domain: "career", year: 2024, precision: "day" }, { id: "e3", domain: "relationship", year: 2024, precision: "month" }, { id: "e4", domain: "career", year: 2026, precision: "day" }, { id: "e5", domain: "relationship", year: 2024, precision: "day" }, ], probes: [RELOCATION_2015_PROBE, YEARLESS_D24_PROBE], }); } function familyCollectDossier() { const inference = familyCollectInference(); const snapshot = candidateSnapshotFixture({ decisionReceipt: { inference_state: inference, discriminating_event_probes: [eventProbeFromConflict(RELOCATION_2015_PROBE)], evidence_collection_probes: [FAMILY_2021_COLLECT], }, }); return dossierFixture({ evidenceCount: 5, evidence: fourEventRows(), latestResult: snapshot, conversationSummary: conversationSummaryFixture({ activeFocus: activeFocusFixture({ intent: "distinguish_candidates", targetDomain: "relocation", targetKind: "home_change", expectedAnswerSchema: { choice: { prompt: "2015 年 5 月前后,有没有搬家或长期住到外地?", option_a: "明确发生且时间吻合", option_b: "发生过但程度较弱", option_c: "明确没有发生", option_d: "这段记不清楚", options: STYLE_OPTIONS.map((option, index) => ({ key: (["A", "B", "C", "D"] as const)[index]!, label: option.label, answer_class: option.answer_class, })), }, probe_id: RELOCATION_2015_PROBE.id, semantic_key: RELOCATION_2015_PROBE.semantic_key, }, }), }), }); } function persistChoiceAccounting( dossier: ReturnType, extra: Parameters[0] = {}, ) { return choiceAccounting({ get_agentic_rectification_case_dossier: () => dossier, get_agentic_rectification_case_compute: () => computeFixture(), ...extra, }); } function choiceAccounting(overrides: Parameters[0] = {}) { const receipts = new Map>(); return fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => choiceDossier(), apply_agentic_rectification_choice_action: (_fn, args) => { const existing = receipts.get(String(args.p_action_id)); if (existing) return { ...existing, idempotent: true }; const receipt = { action_id: args.p_action_id, status: "applied", idempotent: false, question_id: args.p_question_id, option_id: args.p_option_id, probe_id: args.p_inference && typeof args.p_inference === "object" ? (args.p_inference as { probe_id?: string }).probe_id ?? "p-cd" : "p-cd", revision: Number(args.p_expected_revision) + 1, source_quote: args.p_source_quote, derived_context: args.p_derived_context, narration: args.p_narration, focus_status: args.p_focus_status, }; receipts.set(String(args.p_action_id), receipt); return receipt; }, append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), ...overrides, }); } test("does not force-follow when the reader has scrolled upward", () => { assert.equal(isNearBottom(1_000, 100, 400), false); assert.equal(shouldFollowLatest(false), false); assert.equal(isNearBottom(1_000, 520, 400), true); assert.equal(shouldFollowLatest(true), true); }); test("jump-to-latest stays hidden while a choice card still sits in the overlay band", () => { assert.equal(shouldShowJumpToLatest(1_000, 100, 400), true); assert.equal(shouldShowJumpToLatest(1_000, 520, 400), false); assert.equal(shouldShowJumpToLatest(1_000, 500, 400, true), false); assert.equal(shouldShowJumpToLatest(1_000, 200, 400, true), true); }); test("choice quotes come from the option label, not the assistant question year", () => { const question = "2016 年前后,有没有明显高考或重要考试发挥失常?"; assert.equal(quoteIsFromAssistantQuestion("2016年", question), true); assert.equal(quoteIsFromAssistantQuestion("是,大概就在那段时间", question), false); const card = { question_id: QUESTION_ID, method_id: "d5", prompt: question, why: "", varga: null, choice_mode: "A/B/C/D" as const, options: [ { key: "A" as const, label: "是,大概就在那段时间", answer_class: "yes" as const, role: "primary" as const }, { key: "B" as const, label: "有类似,但年份不对或不够重大", answer_class: "weak_yes" as const, role: "primary" as const }, { key: "C" as const, label: "没有明显发生", answer_class: "no" as const, role: "primary" as const }, { key: "D" as const, label: "不记得 / 不确定", answer_class: "unsure" as const, role: "secondary" as const }, ], stop_label: "先这样,先看当前范围", stop_message: "先这样", scoring: true, probe_id: "p-cd", case_revision: 1, focus_id: FOCUS_ID, }; assert.equal(userVisibleChoiceLine(card, "A"), "A. 是,大概就在那段时间"); }); test("synthetic choice taps are not kept as chat user lines", () => { assert.equal(isStructuredChoiceUserText("A. 发挥明显失常或压力很大"), true); assert.equal(isStructuredChoiceUserText("先这样"), true); assert.equal(isStructuredChoiceUserText("先这样,先看当前范围"), true); assert.equal(isStructuredChoiceUserText("盘外核对(不计分):A. 明确发生且时间吻合"), true); assert.equal(isStructuredChoiceUserText("2016年我上了大学"), false); assert.equal(isStructuredChoiceUserText("没有,那年很顺利"), false); }); test("clicking A applies the choice without invoking a language model", async () => { const accounting = choiceAccounting(); const applied = await applyRectificationChoice(accounting.client, { userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, actionId: ACTION_ID, action: CHOICE_ACTION, focusId: FOCUS_ID, questionId: QUESTION_ID, probeId: "p-cd", optionId: "A", expectedRevision: inferenceState().revision, }); assert.equal(applied.applied, true); assert.equal(applied.focusId, FOCUS_ID); assert.equal(applied.optionId, "A"); assert.equal(applied.sourceQuote, "是,大概就在那段时间"); assert.equal(applied.derivedContext.sourceType, "structured_probe_answer"); assert.deepEqual(applied.derivedContext.referencedDateRange, { start: "2015-01-01", end: "2015-12-31", }); assert.equal(applied.narration, composeChoiceNarration({ optionId: "A", scoring: true, appliedInference: true, })); const fns = accounting.calls.map((call) => call.fn); assert.ok(fns.includes("apply_agentic_rectification_choice_action")); assert.equal(fns.includes("append_agentic_rectification_turn"), false); assert.equal(fns.includes("record_agentic_rectification_evidence_batch"), false); assert.equal(fns.some((fn) => fn === "create_agentic_rectification_run_attempt" || fn.includes("stream")), false); const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action"); assert.equal(persist?.args.p_focus_id, FOCUS_ID); assert.equal(persist?.args.p_focus_status, "resolved"); assert.equal(persist?.args.p_source_quote, "是,大概就在那段时间"); assert.notEqual(persist?.args.p_source_quote, "2016年"); assert.ok(persist?.args.p_inference); assert.equal(applied.nextAction.type === "ask_candidate_discriminator" || applied.nextAction.type === "ask_holdout_validation" || applied.nextAction.type === "offer_provisional_range" || applied.nextAction.type === "complete_with_range" || applied.nextAction.type === "ready_to_adopt" || applied.nextAction.type === "ask_fact_collection", true); }); test("answering a discriminator persists the next dated card so GET still has a tap target", async () => { const closed = parseV9CaseDossier(twoProbeDossier()); assert.ok(closed); closed.conversationSummary.activeFocus = null; assert.equal(choiceCardFromCaseDossier(closed), null); const accounting = persistChoiceAccounting(twoProbeDossier(), { set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: { id: NEXT_FOCUS_ID, case_id: CASE_ID, question_id: args.p_question_id, intent: args.p_intent, target_evidence_id: args.p_target_evidence_id, target_domain: args.p_target_domain, target_kind: args.p_target_kind, expected_answer_schema: args.p_expected_answer_schema, status: "active", asked_at: "2026-08-28T07:37:50.000Z", resolved_at: null, }, idempotent: false, }), }); const applied = await applyRectificationChoice(accounting.client, { userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, actionId: ACTION_ID, action: CHOICE_ACTION, focusId: FOCUS_ID, questionId: QUESTION_ID, probeId: RELOCATION_2015_PROBE.id, optionId: "C", expectedRevision: twoProbeInference().revision, }); const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus"); assert.ok(setFocus, JSON.stringify(accounting.calls.map((call) => call.fn))); assert.equal(setFocus.args.p_intent, "distinguish_candidates"); const schema = setFocus.args.p_expected_answer_schema as { semantic_key?: string; choice?: { prompt?: string } }; assert.equal(schema.semantic_key, EDUCATION_2014_PROBE.semantic_key); assert.match(schema.choice?.prompt ?? "", /2014/); assert.doesNotMatch(schema.choice?.prompt ?? "", /2015/); assert.equal(applied.nextInterviewPersisted, true); assert.equal(applied.nextChoiceReady, true); assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false); assert.equal(applied.narration, "接下来请点选下面这一问。"); const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn"); assert.equal(turn?.args.p_assistant_message, "接下来请点选下面这一问。"); const refreshed = parseV9CaseDossier(twoProbeDossier()); assert.ok(refreshed); const answered = twoProbeInference(); refreshed.latestResult = { ...refreshed.latestResult!, decisionReceipt: { ...(refreshed.latestResult?.decisionReceipt ?? {}), inference_state: { ...answered, answered_probes: [{ probe_id: RELOCATION_2015_PROBE.id, semantic_key: RELOCATION_2015_PROBE.semantic_key, candidate_split_hash: RELOCATION_2015_PROBE.candidate_split_hash, answer_class: "no", classified_from: "choice", }], }, }, }; refreshed.conversationSummary.activeFocus = { id: NEXT_FOCUS_ID, caseId: CASE_ID, questionId: String(setFocus.args.p_question_id), intent: String(setFocus.args.p_intent), targetEvidenceId: null, targetDomain: typeof setFocus.args.p_target_domain === "string" ? setFocus.args.p_target_domain : "education", targetKind: null, expectedAnswerSchema: schema, status: "active", askedAt: "2026-08-28T07:37:50.000Z", resolvedAt: null, }; const card = choiceCardFromCaseDossier(refreshed); assert.ok(card); assert.equal(card.focus_id, NEXT_FOCUS_ID); assert.match(card.prompt, /2014/); assert.doesNotMatch(card.prompt, /2015/); }); test("answering the last discriminator persists a year-locked family collect, not a yearless D24 card", async () => { const accounting = persistChoiceAccounting(familyCollectDossier(), { set_agentic_rectification_conversation_focus: () => { throw new Error("yearless D24 must not persist a scoring focus"); }, }); const applied = await applyRectificationChoice(accounting.client, { userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, actionId: ACTION_ID, action: CHOICE_ACTION, focusId: FOCUS_ID, questionId: QUESTION_ID, probeId: RELOCATION_2015_PROBE.id, optionId: "C", expectedRevision: familyCollectInference().revision, }); assert.equal( accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"), false, JSON.stringify(accounting.calls.map((call) => call.fn)), ); assert.equal(applied.nextInterviewPersisted, true); assert.equal(applied.nextChoiceReady, false); assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false); assert.match(applied.narration, /2021/); assert.match(applied.narration, /家人|结婚|添丁|住院/); assert.doesNotMatch(applied.narration, /点选/); assert.doesNotMatch(applied.narration, /D24/); const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn"); assert.match(String(turn?.args.p_assistant_message ?? ""), /2021/); }); test("replaying the same actionId does not duplicate the applied receipt", async () => { const accounting = choiceAccounting(); const command = { userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, actionId: ACTION_ID, action: CHOICE_ACTION, focusId: FOCUS_ID, questionId: QUESTION_ID, probeId: "p-cd", optionId: "A" as const, expectedRevision: inferenceState().revision, }; const first = await applyRectificationChoice(accounting.client, command); const second = await applyRectificationChoice(accounting.client, command); assert.equal(first.idempotent, false); assert.equal(second.idempotent, true); assert.equal( accounting.calls.filter((call) => call.fn === "apply_agentic_rectification_choice_action").length, 2, ); const secondCall = accounting.calls.filter((call) => call.fn === "apply_agentic_rectification_choice_action").at(-1); assert.equal(secondCall?.args.p_action_id, ACTION_ID); }); test("keeps the applied answer when narration persistence fails", async () => { const accounting = choiceAccounting({ append_agentic_rectification_turn: () => { throw new Error("turn_write_failed"); }, }); const applied = await applyRectificationChoice(accounting.client, { userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, actionId: ACTION_ID, action: CHOICE_ACTION, focusId: FOCUS_ID, questionId: QUESTION_ID, probeId: "p-cd", optionId: "A", expectedRevision: inferenceState().revision, }); assert.equal(applied.applied, true); assert.equal(applied.status, "applied"); assert.equal(applied.narrationPersisted, false); assert.match(applied.narration, /已记录你的选择/); }); test("stop_and_review does not write an inference transition", async () => { const accounting = choiceAccounting(); const applied = await applyRectificationChoice(accounting.client, { userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, actionId: ACTION_ID, action: STOP_ACTION, focusId: FOCUS_ID, questionId: QUESTION_ID, optionId: "stop", expectedRevision: inferenceState().revision, }); assert.equal(applied.optionId, "stop"); const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action"); assert.equal(persist?.args.p_inference, null); assert.equal(persist?.args.p_focus_status, "skipped"); assert.ok(accounting.calls.some((call) => call.fn === "append_agentic_rectification_turn")); }); test("turn_decision stays inside the configured byte budget", () => { const dossier = parseV9CaseDossier(choiceDossier()); assert.ok(dossier); const projection = projectTurnDecision(dossier); assert.equal(projection.projection, "turn_decision"); const currentQuestion = projection.current_question as { prompt?: string } | null; assert.equal(currentQuestion?.prompt, "2016 年前后,有没有明显高考或重要考试发挥失常?"); assert.ok(turnDecisionByteLength(projection) <= TURN_DECISION_MAX_BYTES); assert.ok(!("birth_context" in projection)); assert.ok(!("baseline_birth_snapshot" in projection)); }); test("unrenderable focus schema stays visible as current_question, not null", () => { const snapshot = candidateSnapshotFixture(); Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() }); const dossier = parseV9CaseDossier(dossierFixture({ latestResult: snapshot, conversationSummary: conversationSummaryFixture({ activeFocus: activeFocusFixture({ expectedAnswerSchema: { choice: { prompt: "坏题" } }, }), }), })); assert.ok(dossier); const projection = projectTurnDecision(dossier); const currentQuestion = projection.current_question as { unrenderable?: boolean; reason?: string; prompt?: string | null; } | null; assert.equal(currentQuestion?.unrenderable, true); assert.equal(currentQuestion?.reason, "invalid_choice_schema"); assert.equal(currentQuestion?.prompt, null); assert.equal(projection.current_probe, null); }); test("collection focus without choice copy is not an unrenderable current_question", () => { const snapshot = candidateSnapshotFixture(); Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() }); const dossier = parseV9CaseDossier(dossierFixture({ latestResult: snapshot, conversationSummary: conversationSummaryFixture({ activeFocus: activeFocusFixture({ intent: "clarify_event_date", expectedAnswerSchema: { required: ["month"] }, }), }), })); assert.ok(dossier); const projection = projectTurnDecision(dossier); assert.equal(projection.current_question, null); }); test("turn_decision hides current_probe unless a valid current_question exists", () => { const withFocus = projectTurnDecision(parseV9CaseDossier(choiceDossier())!); assert.ok(withFocus.current_question); assert.ok(withFocus.current_probe); assert.equal((withFocus.question_contract as { version?: string }).version, "probe-question-v1"); const snapshot = candidateSnapshotFixture(); Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() }); const withoutFocus = projectTurnDecision(parseV9CaseDossier(dossierFixture({ latestResult: snapshot, conversationSummary: conversationSummaryFixture({ activeFocus: null }), }))!); assert.equal(withoutFocus.current_question, null); assert.equal(withoutFocus.current_probe, null); assert.equal((withoutFocus.inference as { next_probe?: unknown } | null)?.next_probe, null); }); test("truncated and timed-out runs return concrete finish reasons", () => { assert.equal(mapModelFinishToErrorCode({ finishReason: "length", aborted: false, timedOut: false, answerText: "部分回答", stepCount: 1, maxSteps: 8, }), "answer_truncated"); assert.equal(mapModelFinishToErrorCode({ finishReason: "stop", aborted: true, timedOut: true, answerText: "", stepCount: 1, maxSteps: 8, }), "run_timeout"); assert.equal(userFacingRunFailure("answer_truncated"), "模型输出达到上限,状态已记录。"); assert.equal(userFacingRunFailure("run_timeout"), "服务端运行超时,状态已记录。"); assert.equal(isIncompleteRunBanner("本轮处理未完成,请稍后再试"), true); assert.equal(isIncompleteRunBanner("已记录你的选择,并更新了候选比较。"), false); }); test("structured choice only continues through the agent when another question is required", () => { for (const type of [ "ask_fact_collection", "ask_candidate_discriminator", "ask_holdout_validation", ]) { assert.equal(shouldContinueAfterStructuredChoice({ type }), true); } for (const type of [ "offer_provisional_range", "complete_with_range", "ready_to_adopt", ]) { assert.equal(shouldContinueAfterStructuredChoice({ type }), false); } assert.equal(shouldContinueAfterStructuredChoice(null), false); assert.equal( shouldContinueAfterStructuredChoice({ type: "ask_candidate_discriminator" }, { nextInterviewPersisted: true }), false, ); }); test("structured choice narration never persists a fake loading state", () => { for (const narration of [ composeChoiceNarration({ optionId: "A", scoring: true, appliedInference: true }), composeChoiceNarration({ optionId: "A", scoring: true, appliedInference: false }), composeChoiceNarration({ optionId: "A", scoring: false, appliedInference: false }), ]) { assert.doesNotMatch(narration, /正在准备下一步/); } }); test("the public agent route treats structured choice as a non-model command", () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const start = route.indexOf("if (isStructuredChoice)"); const end = route.indexOf("const selectedModel", start); assert.ok(start >= 0 && end > start); const block = route.slice(start, end); assert.match(block, /applyRectificationChoice\(accounting/); assert.doesNotMatch(block, /runV9AgentTurn/); assert.doesNotMatch(block, /getRectificationV9Agent/); assert.doesNotMatch(block, /authorizeUsage/); assert.match(route, /"answer_choice"/); assert.match(route, /"stop_and_review"/); assert.match(route, /export const maxDuration = 240/); const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); assert.match(chat, /isPersistedFocusId\(focusId\)/); assert.match(chat, /focusId,/); assert.match(chat, /shouldContinueAfterStructuredChoice\(payload\?\.nextAction, payload\)/); assert.match(chat, /send\("read_only", ""\)/); assert.match(chat, /willContinue/); assert.match(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\)/); assert.match(chat, /回到最新/); assert.match(chat, /followTailRef\.current/); }); test("rectification attempt timeout stays under the agent route budget", () => { const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); const regenerate = readFileSync( new URL("../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts", import.meta.url), "utf8", ); assert.match(agentRun, /RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000/); assert.match(agentRun, /RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S = 240/); assert.match(regenerate, /export const maxDuration = 240/); assert.ok(210_000 < 240_000); assert.match(agentRun, /RETRYABLE_ERROR_CODES = new Set\(\[/); const retryable = agentRun.slice( agentRun.indexOf("const RETRYABLE_ERROR_CODES"), agentRun.indexOf("function streamFinishReason"), ); assert.match(agentRun, /state_invariant_failed/); assert.doesNotMatch(retryable, /state_invariant_failed/); assert.doesNotMatch(agentRun, /stale_question/); assert.doesNotMatch(agentRun, /revision_conflict/); }); test("derived questionId is an audit label, not the choice identity", async () => { const accounting = choiceAccounting(); const applied = await applyRectificationChoice(accounting.client, { userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, actionId: ACTION_ID, action: CHOICE_ACTION, focusId: FOCUS_ID, questionId: "derived-from-stem:not-the-db-row", probeId: "p-cd", optionId: "A", expectedRevision: inferenceState().revision, }); assert.equal(applied.applied, true); assert.equal(applied.focusId, FOCUS_ID); const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action"); assert.equal(persist?.args.p_focus_id, FOCUS_ID); assert.equal(persist?.args.p_question_id, QUESTION_ID); }); test("a mismatched focusId is stale_question even when questionId matches", async () => { await assert.rejects( () => applyRectificationChoice(choiceAccounting().client, { userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, actionId: ACTION_ID, action: CHOICE_ACTION, focusId: "cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd", questionId: QUESTION_ID, probeId: "p-cd", optionId: "A", expectedRevision: inferenceState().revision, }), (error: unknown) => { assert.ok(error instanceof RectificationToolServiceError); assert.match(error.message, /stale_question/); return true; }, ); }); test("choice identity SQL keys stale_question to inactive focus, not question_id", () => { const migration = readFileSync( new URL("../supabase/migrations/20260826020000_rectification_choice_focus_identity.sql", import.meta.url), "utf8", ); assert.match(migration, /v_focus\.status is distinct from 'active'/); assert.doesNotMatch(migration, /v_focus\.question_id is distinct from/); assert.doesNotMatch(migration, /p_question_id is distinct from v_focus\.question_id/); const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); assert.match(route, /focusId: z\.string\(\)\.uuid\(\)/); assert.match(route, /选择题请求缺少 actionId、focusId 或 expectedRevision/); });