Files
Jyotisha/frontend/tests/rectification-answer-choice.test.ts
T
Jesse_Chen 3198fb6b2b
Independent Staging Quality Gate / validate (push) Successful in 10m14s
Independent Staging Quality Gate / publish (push) Successful in 11m5s
perf(frontend): split chat streaming, load Inter, isolate admin CSS
Settled messages no longer rebuild on every token, Inter is actually
requested, and admin routes drop the 33 KB chat stylesheet. Root
force-dynamic is gone so public shells can prerender without changing
the no-store Cache-Control contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 03:39:43 +08:00

976 lines
37 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" 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();
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);
Object.assign(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();
Object.assign(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",
}],
},
},
} });
Object.assign(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/);
});