Files
Jyotisha/frontend/tests/rectification-answer-choice.test.ts
T
Jesse_Chen f542c02490 refactor(chat): share composer, scroll follow and jump control across both chat surfaces
The rectification session rendered its own textarea (no length ceiling,
no count), its own rAF scroll follow with a sticky-scroll helper, and its
own jump chip styled differently from the main chat's inline Tailwind
button, while the main chat re-ran scrollTo on every streamed token and
added a smooth scroll on settle. ChatComposer now accepts a controlled
value so the rectification surface reuses it without touching the main
draft store; useConversationScrollAnchor owns the follow through a
ResizeObserver, one frame per content change, for both surfaces; a
single JumpToLatestButton replaces both chips; the 720px transcript
override is gone. DESIGN.md records the shared composer, the jump
control, the reading width and the two shadow tokens.

BUG-477 BUG-478

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:29:01 +00:00

1275 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { conversationAnchorThreshold, nextAnchorState } from "../src/hooks/use-conversation-scroll-anchor.ts";
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import {
spokenCollectFallbackFollowup,
spokenFollowupForUser,
type MethodFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.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 { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { RECTIFICATION_TERMINATION_COPY } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { containsBoundarySemantics } from "../src/lib/rectification-agentic/user-copy.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" as const, supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no" as const, supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure" as const, 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" as const, supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no" as const, supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure" as const, 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" as const, supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no" as const, 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({
decisionReceipt: { 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 adoptionInference() {
return applyHoldoutAnswer(buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 18 },
{ 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: "relationship", year: 2024, precision: "day" },
],
probes: [RELOCATION_2015_PROBE],
}), "yes");
}
function adoptionDossier() {
const inference = adoptionInference();
return dossierFixture({
evidenceCount: 6,
evidence: [
...fourEventRows(),
{
id: "occupation-note",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "occupation_note",
domain: "occupation",
occurred_from: null,
occurred_to: null,
date_precision: "unknown",
summary: "长期从事技术工作",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-28T07:38:00.000Z",
},
],
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "05:00",
decisionReceipt: {
acceptance_allowed: true,
selection_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
inference_state: inference,
},
}),
conversationSummary: conversationSummaryFixture({
declinedSkippedTopics: [{ target_domain: "family", status: "skipped" }],
activeFocus: activeFocusFixture({
intent: "distinguish_candidates",
targetDomain: "relocation",
targetKind: "home_change",
expectedAnswerSchema: {
choice: {
prompt: RELOCATION_2015_PROBE.question,
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,
},
}),
}),
});
}
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", () => {
// Former locks exercised `rectification-sticky-scroll.ts` (`isNearBottom`, `shouldFollowLatest`,
// `shouldShowJumpToLatest` with a 360px choice-card band). That module was the second scroll
// implementation (BUG-478); the shared anchor keeps the same 96px near-bottom rule, and the
// jump control shows exactly when the reader is not anchored.
assert.equal(conversationAnchorThreshold, 96);
assert.equal(nextAnchorState(true, 1_000 - 100 - 400, true), false);
assert.equal(nextAnchorState(true, 1_000 - 520 - 400, false), true);
assert.equal(nextAnchorState(false, 600, false), false);
assert.equal(nextAnchorState(false, 40, false), 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("last structured choice emits the adoption range and persists the same narration", async () => {
const accounting = persistChoiceAccounting(adoptionDossier());
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: "A",
expectedRevision: adoptionInference().revision,
});
// Formerly offer_provisional_range: that depended on a 16/14 unresolved fixture being adoptable.
assert.equal(applied.nextAction.type, "ready_to_adopt");
assert.equal(applied.nextAction.can_adopt, true);
assert.match(applied.narration, /05:\d{2}/);
assert.match(applied.narration, /代表分钟/);
assert.equal(containsBoundarySemantics(applied.narration), true);
assert.match(applied.narration, /可以从下面选一个先用着/);
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.match(String(turn?.args.p_assistant_message ?? ""), /可以从下面选一个先用着/);
});
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.match(applied.narration, /已记录你的选择/);
assert.match(applied.narration, /05:0005:10|眼下更像 05:00/);
assert.doesNotMatch(applied.narration, /家里如果有结婚、添丁或住院|家里有没有结婚、添丁或住院/);
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"), true);
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);
const withoutFocus = {
...closed,
conversationSummary: {
...closed.conversationSummary,
activeFocus: null,
},
};
assert.equal(choiceCardFromCaseDossier(withoutFocus), 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(schema.choice?.prompt, "2014 年前后,有没有升学、转学或换学习环境?");
assert.equal(applied.nextInterviewPersisted, true);
assert.equal(applied.nextChoiceReady, true);
assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false);
const persistedPrompt = schema.choice?.prompt;
assert.ok(persistedPrompt);
assert.equal(applied.narration, persistedPrompt);
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(turn?.args.p_assistant_message, persistedPrompt);
const refreshed = parseV9CaseDossier(twoProbeDossier());
assert.ok(refreshed);
const answered = twoProbeInference();
const nextDossier = {
...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",
}],
},
},
},
conversationSummary: {
...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(nextDossier);
assert.ok(card);
assert.equal(card.focus_id, NEXT_FOCUS_ID);
assert.match(card.prompt, /2014/);
assert.doesNotMatch(card.prompt, /2015/);
});
test("deferFollowup scores the choice without persisting the next interview or turn", async () => {
const accounting = persistChoiceAccounting(twoProbeDossier());
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,
deferFollowup: true,
});
assert.equal(applied.applied, true);
assert.equal(applied.answerClass, "no");
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
assert.equal(persist?.args.p_option_id, "C");
assert.ok(persist?.args.p_inference);
assert.equal(
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
false,
);
assert.equal(
accounting.calls.some((call) => call.fn === "append_agentic_rectification_turn"),
false,
);
assert.equal(applied.nextInterviewPersisted, false);
assert.equal(applied.narrationPersisted, false);
});
test("answering the last discriminator persists a year-locked family collect focus, not a yearless D24 card", async () => {
const accounting = persistChoiceAccounting(familyCollectDossier(), {
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: familyCollectInference().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, "collect_method_evidence");
const schema = setFocus.args.p_expected_answer_schema as { choice?: unknown; prompt?: string; collect?: boolean };
assert.equal(schema.choice, undefined);
assert.equal(schema.collect, true);
assert.match(schema.prompt ?? "", /2021/);
assert.match(schema.prompt ?? "", /家人|结婚|添丁|住院/);
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");
assert.match(applied.narration, /已记录你的选择/);
assert.equal(applied.narration.split(RECTIFICATION_TERMINATION_COPY).length - 1, 1);
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({
decisionReceipt: { 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({
decisionReceipt: { 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("failed card persist still leaves a spoken collect next step", () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
assert.match(source, /if \(followup\?\.choice_frame\)/);
assert.match(source, /spokenCollectFallbackFollowup/);
assert.match(source, /spokenFollowupForUser\(spokenFollowup\)/);
assert.match(source, /nonConvergingRangeNarration/);
assert.match(source, /persist next focus failed case=/);
assert.doesNotMatch(source, /if \(fallback\.status === "created"/);
const applied = source.slice(source.indexOf("async function persistApplied"));
const loadAt = applied.indexOf("loadV9CaseCompute");
const decideAt = applied.indexOf("publicNextAction(decideAfterInferenceChange");
assert.ok(loadAt >= 0 && decideAt >= 0 && loadAt < decideAt);
assert.match(applied.slice(decideAt, decideAt + 280), /birthDate/);
assert.match(
applied.slice(applied.indexOf("persistNextInterviewAfterChoice"), applied.indexOf("persistNextInterviewAfterChoice") + 360),
/birthDate/,
);
const nextInterview = source.slice(
source.indexOf("async function persistNextInterviewAfterChoice"),
source.indexOf("async function persistFocusAfterChoice"),
);
assert.doesNotMatch(nextInterview, /loadV9CaseCompute/);
});
test("spoken collect focus keeps current_question after refresh", () => {
const snapshot = candidateSnapshotFixture({
decisionReceipt: { inference_state: inferenceState() },
});
const dossier = parseV9CaseDossier(dossierFixture({
latestResult: snapshot,
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "collect_method_evidence",
targetDomain: "family",
expectedAnswerSchema: {
prompt: "2021 年前后,家里有没有结婚、添丁或住院这类记得住时间的事?不记得具体日子也可以先说有没有。",
collect: true,
},
}),
}),
}));
assert.ok(dossier);
const projection = projectTurnDecision(dossier);
const currentQuestion = projection.current_question as {
prompt?: string;
kind?: string;
unrenderable?: boolean;
} | null;
assert.equal(currentQuestion?.unrenderable, undefined);
assert.equal(currentQuestion?.kind, "collect_spoken");
assert.match(currentQuestion?.prompt ?? "", /2021/);
assert.equal(projection.current_question && typeof projection.current_question === "object", true);
assert.equal(projection.current_probe, null);
assert.equal((projection.inference as { next_probe?: unknown } | null)?.next_probe, null);
});
test("turn_decision hides current_probe unless a valid current_question exists", () => {
const withFocus = projectTurnDecision(parseV9CaseDossier(choiceDossier())!);
assert.ok(withFocus.current_question);
assert.equal((withFocus.current_question as { kind?: string } | null)?.kind, "choice");
assert.ok(withFocus.current_probe);
assert.ok((withFocus.inference as { next_probe?: unknown } | null)?.next_probe);
assert.equal((withFocus.question_contract as { version?: string }).version, "probe-question-v1");
const snapshot = candidateSnapshotFixture({
decisionReceipt: { 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 resolvedModel", 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\)/);
// Former locks: `回到最新` and `followTailRef.current` — the rectification-only chip and follow
// flag (BUG-478). The shared control and anchor are locked instead.
assert.match(chat, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
assert.match(chat, /conversationAnchor\.anchored/);
});
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/);
});
test("choice followup narration uses the server-owned prompt", () => {
const prompt = "2014 年前后,有没有升学、转学或换学习环境?";
const followup: MethodFollowup = {
method_id: "d5_education",
intent: "distinguish_candidates",
ask_theme: "education_style",
domain: "education",
kind_hint: null,
user_prompt_hint: "server-owned choice",
must_not_label: false,
choice_frame: {
question_id: "education.2014",
method_id: "d5_education",
period: "2014 年前后",
prompt,
varga: null,
why: "用于区分候选时间",
option_a_hint: "明确发生且时间吻合",
option_b_hint: "发生过但程度较弱",
neither_label: "明确没有发生",
unsure_label: "这段记不清楚",
option_a_answer_class: "yes",
option_b_answer_class: "weak_yes",
option_c_answer_class: "no",
option_d_answer_class: "unsure",
choice_mode: "A/B/C/D",
stop_label: "先这样,先看当前范围",
stop_message: "先这样",
scoring: true,
},
source: "event_probe",
};
assert.equal(spokenFollowupForUser(followup), prompt);
});
test("degraded spoken collect strips discriminator identity", () => {
const followup: MethodFollowup = {
method_id: "dasha_events",
intent: "distinguish_candidates",
ask_theme: "dated_event",
domain: "education",
kind_hint: null,
user_prompt_hint: "ask",
must_not_label: false,
choice_frame: null,
source: "event_probe",
semantic_key: "education:2016",
candidate_split_hash: "education.2016",
probe_year: 2016,
year_label: "2016 年前后",
probe_id: "probe:education:2016",
};
const spokenFollowup = spokenCollectFallbackFollowup(followup);
const spoken = spokenFollowupForUser(spokenFollowup);
assert.equal(spokenFollowup.intent, "collect_method_evidence");
assert.equal(spokenFollowup.choice_frame, null);
assert.equal(spokenFollowup.semantic_key, undefined);
assert.equal(spokenFollowup.candidate_split_hash, undefined);
assert.equal(spokenFollowup.probe_year, undefined);
assert.equal(spokenFollowup.year_label, undefined);
assert.equal(spokenFollowup.probe_id, undefined);
assert.equal(stableFollowupQuestionId(spokenFollowup).includes("education:2016"), false);
assert.doesNotMatch(spoken ?? "", /2016/);
assert.equal(spoken, spokenFollowupForUser(spokenFollowup));
});