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 { CHOICE_ACTION, STOP_ACTION, composeChoiceNarration, quoteIsFromAssistantQuestion, userVisibleChoiceLine, } from "../src/lib/rectification-agentic/v9/choice-action.ts"; import { isNearBottom, shouldFollowLatest } 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 } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CASE_ID, FOCUS_ID, SESSION_ID, TURN_ID, USER_ID, activeFocusFixture, candidateSnapshotFixture, conversationSummaryFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const QUESTION_ID = "question-1"; 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: "education.2016", candidate_split_hash: "05:00|05:10", domain: "education", year: 2016, 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: "不记得 / 不确定", }, probe_id: "p-cd", semantic_key: "education.2016", }, }), }), }); } 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("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: "是,大概就在那段时间", role: "primary" as const }, { key: "B" as const, label: "有类似,但年份不对或不够重大", role: "primary" as const }, { key: "C" as const, label: "没有明显发生", role: "primary" as const }, { key: "D" as const, label: "不记得 / 不确定", 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("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, questionId: QUESTION_ID, probeId: "p-cd", optionId: "A", expectedRevision: inferenceState().revision, }); assert.equal(applied.applied, true); assert.equal(applied.optionId, "A"); assert.equal(applied.sourceQuote, "是,大概就在那段时间"); assert.equal(applied.derivedContext.sourceType, "structured_probe_answer"); assert.deepEqual(applied.derivedContext.referencedDateRange, { start: "2016-01-01", end: "2016-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.ok(fns.includes("append_agentic_rectification_turn")); 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); }); 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, 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, 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, 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"); }); 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"); assert.ok(turnDecisionByteLength(projection) <= TURN_DECISION_MAX_BYTES); assert.ok(!("birth_context" in projection)); assert.ok(!("baseline_birth_snapshot" in projection)); }); 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("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"/); });