Files
Jyotisha/frontend/tests/rectification-answer-choice.test.ts
T
Jesse_Chen 68f0759eef
Independent Staging Quality Gate / validate (push) Successful in 13m56s
Independent Staging Quality Gate / publish (push) Successful in 16m46s
fix(rectification): persist the next interview after a choice tap
Closing a discriminator used to leave GET without a card after refresh.
Write the next dated question in the same request, skip childhood career
and move probes, and do not continue a read-only turn when that question
is already persisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:30:09 +08:00

935 lines
36 KiB
TypeScript

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<typeof twoProbeDossier>,
extra: Parameters<typeof fakeAccounting>[0] = {},
) {
return choiceAccounting({
get_agentic_rectification_case_dossier: () => dossier,
get_agentic_rectification_case_compute: () => computeFixture(),
...extra,
});
}
function choiceAccounting(overrides: Parameters<typeof fakeAccounting>[0] = {}) {
const receipts = new Map<string, Record<string, unknown>>();
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("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/);
});