Files
Jyotisha/frontend/tests/rectification-collect-stall.test.ts
T
Jesse_ChenandCursor f870d3d757 fix(rectification): keep dated precision cards after empty dated discriminator pool (BUG-651)
Staging gate 2558 failed because leftover tests still expected D9/D10 or leftover collect after the pool emptied. Discriminate sessions still deliver; dated precision cards still ask.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 15:22:55 +08:00

1503 lines
57 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 { applyAnswerToState, buildInferenceState, candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
publicCanAdopt,
publicNextAction,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
rectificationFollowupCatalog,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { focusStatusForAnswer } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import {
shouldContinueAgentForDatedEvent,
collectFocusCloseStatus,
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
import {
applyCollectFocusDenial,
persistCollectDenialTurn,
persistNextInterviewAfterChoice,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import {
ATTEMPT_ID,
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const EXISTENCE_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 CANDIDATES = [
{ id: "04:47", time: "04:47", relative_support: 8 },
{ id: "04:51", time: "04:51", relative_support: 9 },
{ id: "04:53", time: "04:53", relative_support: 9 },
{ id: "04:59", time: "04:59", relative_support: 10 },
{ id: "05:00", time: "05:00", relative_support: 18 },
{ id: "05:07", time: "05:07", relative_support: 16 },
{ id: "05:12", time: "05:12", relative_support: 11 },
{ id: "05:14", time: "05:14", relative_support: 7 },
{ id: "05:15", time: "05:15", relative_support: 6 },
] as const;
const EVIDENCE = [
{
id: "e-career-entry",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
},
{
id: "e-career-exit",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-10-01",
occurredTo: null,
eventKind: "career_exit",
},
{
id: "e-rel-end",
status: "confirmed",
domain: "relationship",
datePrecision: "day",
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
},
{
id: "e-rel-start",
status: "confirmed",
domain: "relationship",
datePrecision: "month",
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
},
] as const;
const LIVE_CASE_EVIDENCE = [
...EVIDENCE,
{
id: "e-education",
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
eventKind: "education_start",
},
] as const;
const FAMILY_2021_COLLECT = {
year: 2021,
year_label: "2021 年前后",
domain: "family" as const,
event_family: "家人结婚、添丁或住院",
source: "age_band" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: false,
unique_minute_claim: false as const,
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
role: "collect" as const,
phase: "evidence_collection" as const,
information_gain: 0,
semantic_key: "family.2021",
candidate_split_hash: "family:2021",
candidate_ids: [] as const,
expected_outcomes: [] as const,
choice_kind: "existence" as const,
};
function vargaExistence(input: {
layer: string;
gain: number;
domain: string;
question: string;
supports: readonly string[];
conflicts: readonly string[];
}): ConflictProbe {
const key = `varga.${input.layer}.${input.supports.join("/")}`;
return {
id: `contrast:${key}`,
semantic_key: key,
candidate_split_hash: key,
domain: input.domain,
year: 0,
question: input.question,
candidate_ids: [...new Set([...input.supports, ...input.conflicts])],
expected_outcomes: [
{ answer_class: "yes", supports: input.conflicts, conflicts: input.supports },
{ answer_class: "weak_yes", supports: input.conflicts, conflicts: [] },
{ answer_class: "no", supports: input.supports, conflicts: input.conflicts },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: input.gain,
source: "varga_contrast",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const D9: ConflictProbe = {
id: "contrast:varga.d9.巨蟹座/狮子座",
semantic_key: "varga.d9.巨蟹座/狮子座",
candidate_split_hash: "varga.d9.巨蟹座/狮子座",
domain: "relationship",
year: 0,
question: "亲密关系里更接近下面哪一种相处方式?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.1,
source: "varga_contrast",
choice_kind: "varga_style",
};
const D10: ConflictProbe = {
id: "contrast:varga.d10.天秤座/天蝎座",
semantic_key: "varga.d10.天秤座/天蝎座",
candidate_split_hash: "varga.d10.天秤座/天蝎座",
domain: "career",
year: 0,
question: "平时做事更接近下面哪一种职责风格?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.05,
source: "varga_contrast",
choice_kind: "varga_style",
};
const CAREER_2023: ConflictProbe = {
id: "probe:career.2023.dasha_boundary",
semantic_key: "career.2023.dasha_boundary",
candidate_split_hash: "career.2023",
domain: "career",
year: 2023,
question: "2023 年前后有没有入职或换工作?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.72,
source: "dasha_boundary",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
const CAREER_2023_ACTIVATION: ConflictProbe = {
id: "probe:career.2023.dasha_activation",
semantic_key: "career.2023.dasha_activation",
candidate_split_hash: "career.2023.activation",
domain: "career",
year: 2023,
question: "2023 年前后大运有没有启动?",
candidate_ids: CANDIDATES.map((candidate) => candidate.time),
expected_outcomes: [
{
answer_class: "yes",
supports: ["05:15"],
conflicts: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"],
},
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{
answer_class: "no",
supports: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"],
conflicts: ["05:15"],
},
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.56,
source: "dasha_activation",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
const RELOCATION_2015: ConflictProbe = {
id: "probe:relocation.2015.dasha_boundary",
semantic_key: "relocation.2015.dasha_boundary",
candidate_split_hash: "relocation.2015",
domain: "relocation",
year: 2015,
question: "2015 年前后有没有搬家或长期住到外地?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.61,
source: "dasha_boundary",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
const D12 = vargaExistence({
layer: "d12",
gain: 1.89,
domain: "family",
question: "家里有没有结婚、添丁或住院这类事?",
supports: ["05:00", "05:07", "05:12"],
conflicts: ["04:47", "04:51", "04:53", "04:59"],
});
const D24 = vargaExistence({
layer: "d24",
gain: 2.5,
domain: "education",
question: "有没有学业或考试发挥明显失常、压力特别大的时候?",
supports: ["05:00"],
conflicts: ["05:07"],
});
const D7 = vargaExistence({
layer: "d7",
gain: 1.35,
domain: "family",
question: "有没有子女或子嗣相关的家里变化?",
supports: ["05:00", "05:07"],
conflicts: ["04:47"],
});
const D4 = vargaExistence({
layer: "d4",
gain: 0.99,
domain: "relocation",
question: "有没有搬家或长期住到外地?",
supports: ["05:00"],
conflicts: ["05:07"],
});
const D5 = vargaExistence({
layer: "d5",
gain: 0.5,
domain: "education",
question: "有没有记得住年份的升学或考试?",
supports: ["05:00"],
conflicts: ["05:07"],
});
const DATED_RELOCATION_2016: ConflictProbe = {
id: "probe:relocation.2016.dasha_boundary",
semantic_key: "relocation.2016.dasha_boundary",
candidate_split_hash: "relocation.2016",
domain: "relocation",
year: 2016,
question: "2016 年前后有没有搬家或长期住到外地?",
candidate_ids: [
"04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15",
],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:47", "04:51", "04:53", "04:59"], conflicts: ["05:00", "05:07", "05:12", "05:14", "05:15"] },
{ answer_class: "no", supports: ["05:00", "05:07", "05:12", "05:14", "05:15"], conflicts: ["04:47", "04:51", "04:53", "04:59"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.8,
source: "dasha_boundary",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
function revision5State(extraProbes: readonly ConflictProbe[] = []) {
const probes = [
D9,
D10,
CAREER_2023,
RELOCATION_2015,
D24,
D12,
D7,
D4,
D5,
...extraProbes,
];
let state = buildInferenceState({
range_start: "04:47",
range_end: "05:15",
candidates: CANDIDATES,
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" },
],
probes,
});
const answers: ReadonlyArray<{ id: string; answer: "yes" | "weak_yes" | "no" }> = [
{ id: D9.id, answer: "weak_yes" },
{ id: D10.id, answer: "weak_yes" },
{ id: CAREER_2023.id, answer: "no" },
{ id: RELOCATION_2015.id, answer: "no" },
];
for (const item of answers) {
state = applyAnswerToState(state, item.id, item.answer);
}
return state;
}
function revision5Dossier(
state = revision5State(),
extra?: { declinedTopics?: ReadonlyArray<Record<string, unknown>>; activeFocus?: DecisionDossier["conversationSummary"]["activeFocus"] },
): DecisionDossier {
return {
evidence: EVIDENCE,
conversationSummary: {
activeFocus: extra?.activeFocus ?? null,
declinedSkippedTopics: extra?.declinedTopics ?? [],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
candidates: state.candidates.map((item) => ({
candidateId: item.id,
time: item.time,
rank: item.rank,
relativeSupport: Math.round(item.posterior_score),
})),
representativeTime: state.representative_time,
evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never),
decisionReceipt: {
inference_state: state,
evidence_collection_probes: [FAMILY_2021_COLLECT],
},
},
case: { acceptedTime: null },
};
}
function planFrom(
dossier: DecisionDossier,
extra: Partial<Parameters<typeof buildMethodFollowupPlan>[0]> = {},
) {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
return buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "discriminate_candidates",
...catalog,
candidatesSeparated: false,
...extra,
});
}
function rpcDossier(decision: DecisionDossier, activeFocus?: Record<string, unknown> | null) {
return dossierFixture({
evidence: decision.evidence.map((item) => ({
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind ?? "event",
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.summary ?? `${item.occurredFrom} ${item.eventKind ?? "event"}`,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-08-29T00:00:00.000Z",
})),
latestResult: candidateSnapshotFixture({
candidates: decision.latestResult?.candidates?.map((item, index) => ({
candidate_id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
time: item.time,
rank: item.rank ?? index + 1,
relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)),
tied_minute_count: item.tiedMinuteCount ?? 1,
})),
representativeTime: decision.latestResult?.representativeTime ?? null,
evidenceLedgerFingerprint: decision.latestResult?.evidenceLedgerFingerprint,
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: activeFocus ?? null,
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-08-29T00:00:00.000Z",
},
});
}
test("skill version is 10.0.23 after the collect-semantics bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.23");
});
test("revision 5 with uncovered relatives asks the dated family collect, not a yearless D12 card", () => {
const state = revision5State();
assert.equal(state.revision, 5);
assert.equal(state.answered_probes.length, 4);
assert.equal(state.events.find((item) => item.id === "e-rel-start")?.usage, "holdout");
const unanswered = state.probes.filter((probe) => (
!state.answered_probes.some((item) => item.probe_id === probe.id)
));
assert.equal(unanswered.some((item) => item.semantic_key.startsWith("varga.d12")), true);
assert.equal(unanswered.find((item) => item.semantic_key.startsWith("varga.d12"))?.information_gain, 1.89);
const plan = planFrom(revision5Dossier(state));
assert.equal(plan.methods.find((item) => item.method_id === "relatives")?.status, "uncovered");
// 原值: leftover 家人采集 domain=family / probe_year=2021
// 新值: 训练门已开,不按领域轮盘问家人;无年份 D12 仍丢掉
// 原因: leftover dated collect 关闭(BUG-648);撤回年龄带年份(BUG-642)
assert.notEqual(plan.next_followup?.domain, "family");
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /^varga\.d12/);
assert.equal(plan.dropped_probes.some((item) => (
item.semantic_key.startsWith("varga.d12") && item.reason === "yearless_ungrounded_contrast"
)), true);
});
test("denying the dated family collect declines relatives and leaves the D12 card unasked", () => {
const plan = planFrom(revision5Dossier());
// 原值: 下一问是家人采集
// 新值: leftover 带年份家人轮转关闭
// 原因: 训练门已开(BUG-648
assert.notEqual(plan.next_followup?.domain, "family");
assert.equal(focusStatusForAnswer("no", "C"), "declined");
const next = planFrom(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}));
assert.equal(next.methods.find((item) => item.method_id === "relatives")?.status, "covered");
assert.notEqual(next.next_followup?.domain, "family");
assert.notEqual(next.next_followup?.domain, "education");
if (next.next_followup?.choice_frame) {
assert.equal(next.next_followup.intent, "distinguish_candidates");
assert.doesNotMatch(next.next_followup.semantic_key ?? "", /^varga\.d(12|24|7|4|5)\./);
}
});
test("persistServerOwnedFocus writes family as target_domain so a no answer becomes declined coverage", async () => {
const leftover = planFrom(revision5Dossier());
const leftoverAccounting = fakeAccounting({
...receiptHandlers,
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: 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-29T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const leftoverPersisted = await persistServerOwnedFocus({
accounting: leftoverAccounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: revision5Dossier().latestResult?.decisionReceipt,
followup: leftover.next_followup,
});
// 原值: persist 家人采集 p_target_domain=family
// 新值: revision5 训练门已开,leftover 家人轮转关闭,persist skipped
// 原因: 只有下一问真是家人锚点/泛问才写 family;邀请写 otherBUG-648
assert.equal(leftoverPersisted.status, "skipped");
const invitePlan = buildMethodFollowupPlan({
evidence: [EVIDENCE[0]],
sessionOutcome: "collect_evidence",
});
const inviteAccounting = fakeAccounting({
...receiptHandlers,
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: 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-29T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const invitePersisted = await persistServerOwnedFocus({
accounting: inviteAccounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: invitePlan.next_followup,
});
assert.equal(invitePersisted.status, "created");
const call = inviteAccounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(call?.args.p_target_domain, "other");
assert.equal(call?.args.p_target_kind, "invite_more");
assert.equal(call?.args.p_intent, "collect_method_evidence");
});
test("occupation collect denial declines the focus and advances coverage to horary", async () => {
assert.equal(collectFocusCloseStatus({
intent: "answer_current_focus",
answer_class: "no",
}), "declined");
assert.equal(collectFocusCloseStatus({
intent: "provide_new_evidence",
answer_class: null,
}), null);
assert.equal(collectFocusCloseStatus({
intent: "answer_current_focus",
answer_class: "unsure",
}), "skipped");
const askedYearless = [D24, D12, D7, D4, D5].map((item) => item.semantic_key);
const occupationPlan = planFrom(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}), { askedProbeKeys: askedYearless });
// 原值: 家人拒答后 leftover 学业采集
// 新值: leftover 带年份轮转关闭,不是 education
// 原因: 训练门开后不再按领域轮盘补问(BUG-648)
assert.notEqual(occupationPlan.next_followup?.domain, "education");
assert.notEqual(occupationPlan.next_followup?.domain, "family");
const occupationFocus = {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: "occupation:occupation",
intent: "collect_method_evidence",
target_evidence_id: null,
target_domain: "occupation",
target_kind: null,
expected_answer_schema: {
prompt: "你平时主要做什么工作?",
collect: true,
},
status: "active",
asked_at: "2026-08-29T00:00:00.000Z",
resolved_at: null,
};
let loads = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => {
loads += 1;
if (loads === 1) {
return rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}), occupationFocus);
}
return rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [
{ target_domain: "family", status: "declined" },
{ target_domain: "occupation", status: "declined" },
],
}));
},
get_agentic_rectification_case_compute: () => computeFixture(),
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
focus_id: args.p_focus_id,
status: args.p_status,
evidence_id: null,
idempotent: false,
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: "acacacac-acac-4cac-8cac-acacacacacac",
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-29T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const applied = await applyCollectFocusDenial(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
focusId: FOCUS_ID,
});
const resolve = accounting.calls.find((item) => item.fn === "resolve_agentic_rectification_conversation_focus");
assert.equal(resolve?.args.p_status, "declined");
assert.notEqual(resolve?.args.p_status, "resolved");
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
// 原值: p_target_domain === "horary"DB check 不含 horary,写库必然失败)。
// 新值: 不把 horary 落成口述采集焦点。
// 原因: appearance / marks / horary / nakshatra 不得成为 collect 焦点。
assert.notEqual(setFocus?.args.p_target_domain, "horary");
assert.ok(applied.narration);
const nextCollect = planFrom(revision5Dossier(revision5State(), {
declinedTopics: [
{ target_domain: "family", status: "declined" },
{ target_domain: "occupation", status: "declined" },
],
}), { askedProbeKeys: askedYearless });
// 原值: leftover 学业采集;后改为 horary
// 新值: discriminate 路径带年月池空不再问占问/性格题
// 原因: BUG-651 不得把性格题或 leftover 采集当下一道区分题
assert.notEqual(nextCollect.next_followup?.domain, "education");
assert.notEqual(nextCollect.next_followup?.domain, "family");
assert.notEqual(nextCollect.next_followup?.choice_kind, "varga_style");
});
test("collect denial persists the next stem on the turn and binds asked_turn_id", async () => {
const familyFocus = {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: "collect:family:collect_method_evidence",
intent: "collect_method_evidence",
target_evidence_id: null,
target_domain: "family",
target_kind: null,
expected_answer_schema: {
prompt: "2021 年前后,家里如果有结婚、添丁或住院这类事,记得大概哪年就行。",
collect: true,
},
status: "active",
asked_at: "2026-08-29T00:00:00.000Z",
resolved_at: null,
};
let loads = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => {
loads += 1;
if (loads === 1) {
return rpcDossier(revision5Dossier(revision5State()), familyFocus);
}
return rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}));
},
get_agentic_rectification_case_compute: () => computeFixture(),
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
focus_id: args.p_focus_id,
status: args.p_status,
evidence_id: null,
idempotent: false,
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: "acacacac-acac-4cac-8cac-acacacacacac",
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-29T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
}),
append_agentic_rectification_turn: () => ({
turn_id: TURN_ID,
idempotent: false,
}),
});
const applied = await applyCollectFocusDenial(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
focusId: FOCUS_ID,
});
// 原值: persist 学业题干并绑定 asked_turn_id
// 新值: leftover 带年份轮转关闭,旁白走 S3「再补什么」,不绑采集焦点
// 原因: 训练门开后拒答家人不再落到下一领域(BUG-648)
assert.equal(applied.nextInterviewPersisted, false);
assert.match(applied.narration, /范围已经收到|这次给出的范围|如果还记得/);
assert.doesNotMatch(applied.narration, /上学这边/);
assert.doesNotMatch(applied.narration, /2021 年前后/);
const finished = await persistCollectDenialTurn({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: ATTEMPT_ID,
userMessage: "没有",
applied,
});
assert.equal(finished.turnId, TURN_ID);
assert.equal(finished.streamText, applied.narration);
const append = accounting.calls.find((item) => item.fn === "append_agentic_rectification_turn");
assert.equal(append?.args.p_assistant_message, applied.narration);
assert.equal(
accounting.calls.some((item) => (
item.fn === "set_agentic_rectification_conversation_focus"
&& item.args.p_asked_turn_id === TURN_ID
)),
false,
);
});
test("message and opening turns persist the next followup so current_question is not null", async () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn"));
assert.doesNotMatch(afterRun, /if \(action === "message" \|\| action === "opening"\)/);
assert.match(afterRun, /persistNextInterviewIfIdle/);
assert.match(afterRun, /ensureNonTerminalTurnExit/);
assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle"));
const occupationPlan = planFrom(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}));
// 原值: 家人拒答后仍有 leftover 采集题
// 新值: leftover 带年份轮转关闭,current_question 可以为空
// 原因: S3 旁白不依赖采集焦点(BUG-648)
assert.notEqual(occupationPlan.next_followup?.domain, "family");
assert.notEqual(occupationPlan.next_followup?.domain, "education");
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
})),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: 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-29T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const persisted = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.ok(persisted.hostNarration);
assert.match(persisted.hostNarration, /范围已经收到|这次给出的范围|如果还记得/);
assert.equal(
accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"),
undefined,
);
});
function occupationCollectFocus(id = FOCUS_ID) {
return {
id,
case_id: CASE_ID,
question_id: "collect:occupation:collect_method_evidence",
intent: "collect_method_evidence",
target_evidence_id: null,
target_domain: "occupation",
target_kind: null,
expected_answer_schema: {
collect: true,
prompt: "你平时主要做什么工作?",
},
status: "active",
asked_at: "2026-08-30T00:00:00.000Z",
resolved_at: null,
};
}
function createdFocusFromArgs(args: Record<string, unknown>, id = FOCUS_ID) {
return {
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-30T00:00:00.000Z",
resolved_at: null,
};
}
function occupationDossier() {
return revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
}
const NAKSHATRA: ConflictProbe = {
id: "probe:nakshatra.ashlesha",
semantic_key: "nakshatra.ashlesha/magha",
candidate_split_hash: "nakshatra.ashlesha",
domain: "appearance",
year: 0,
question: "外表或体质更接近哪一种?",
candidate_ids: ["05:00", "05:07", "04:53"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00", "05:07", "04:53"], conflicts: [] },
{ answer_class: "weak_yes", supports: ["05:00", "05:07"], conflicts: [] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.4,
source: "nakshatra_boundary",
choice_kind: "varga_style",
};
function liveWindowScan() {
return {
scanned: true,
confirmation_allowed: false,
unique_minute_claim: false,
d9_lagna_count: 2,
d10_lagna_count: 2,
d4_lagna_count: 2,
d5_lagna_count: 2,
d7_lagna_count: 2,
d12_lagna_count: 2,
d24_lagna_count: 2,
d9_candidates_differ: true,
d10_candidates_differ: true,
d4_candidates_differ: true,
d5_candidates_differ: true,
d7_candidates_differ: true,
d12_candidates_differ: true,
d24_candidates_differ: true,
};
}
function liveCaseState(): InferenceState {
const times = CANDIDATES.map((candidate) => candidate.time);
const answered = [D9, D10, RELOCATION_2015, D24, D7];
const active = [
{ time: "05:00", score: 18, probability: 0.4 },
{ time: "05:07", score: 16, probability: 0.33 },
{ time: "04:53", score: 14, probability: 0.27 },
] as const;
const eliminated = times.filter((time) => !active.some((candidate) => candidate.time === time));
const orderedTimes = [...active.map((candidate) => candidate.time), ...eliminated];
return {
algorithm_version: revision5State().algorithm_version,
candidate_set_id: candidateSetId("04:47", "05:15", orderedTimes),
revision: 6,
phase: "discrimination",
result_status: "discriminating",
range_start: "04:47",
range_end: "05:15",
candidates: [
...active.map((candidate, index) => ({
id: candidate.time,
time: candidate.time,
cluster_range: [candidate.time, candidate.time] as const,
prior_score: candidate.score,
posterior_score: candidate.score,
probability: candidate.probability,
status: "active",
rank: index + 1,
strong_conflict_count: 0,
} as const)),
...eliminated.map((time, index) => ({
id: time,
time,
cluster_range: [time, time] as const,
prior_score: 4 - index,
posterior_score: 4 - index,
probability: 0,
status: "eliminated" as const,
rank: active.length + index + 1,
strong_conflict_count: 3,
})),
],
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month", usage: "training" },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month", usage: "training" },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day", usage: "training" },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month", usage: "training" },
{ id: "e-education", domain: "education", year: 2016, precision: "year", usage: "training" },
],
probes: [...answered, CAREER_2023_ACTIVATION, NAKSHATRA],
answered_probes: answered.map((probe) => ({
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: probe === D24 ? "unsure" : "no",
classified_from: "choice",
})),
rounds: [],
last_inference_round: null,
entropy: 1.08,
representative_time: "05:00",
credible_range: ["04:53", "05:07"],
holdout_passed: null,
};
}
function liveCaseDossier(): DecisionDossier {
const state = liveCaseState();
return {
evidence: LIVE_CASE_EVIDENCE,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: [{ target_domain: "family", status: "declined" }],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: evidenceLedgerFingerprint(LIVE_CASE_EVIDENCE as never),
candidates: state.candidates.map((candidate, index) => ({
candidateId: `77777777-7777-4777-8777-${String(index + 1).padStart(12, "0")}`,
time: candidate.time,
rank: candidate.rank,
relativeSupport: Math.round(candidate.posterior_score),
})),
representativeTime: state.representative_time,
decisionReceipt: {
acceptance_allowed: true,
accept_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
inference_state: state,
window_scan: liveWindowScan(),
},
},
case: { acceptedTime: null },
};
}
async function persistOccupationAfterChoice(accounting: ReturnType<typeof fakeAccounting>["client"]) {
const dossier = occupationDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
return persistNextInterviewAfterChoice({
accounting,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: revision5State(),
nextAction: publicNextAction(decision),
birthDate: "1997-08-08",
});
}
test("duplicate collect focus reloads the active question instead of returning null narration", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier(), occupationCollectFocus()),
set_agentic_rectification_conversation_focus: () => {
throw new Error("focus_idempotency_conflict");
},
});
const persisted = await persistOccupationAfterChoice(accounting.client);
// 原值: 冲突时至少重试写焦点 2 次并 reload 职业题
// 新值: leftover dated/职业轮转不再强制,S3 旁白,0 次写焦点
// 原因: 训练门开后不补 leftover 采集(BUG-648
assert.ok(persisted.hostNarration);
assert.match(persisted.hostNarration, /范围已经收到|这次给出的范围|如果还记得/);
assert.equal(
accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length,
0,
);
});
test("skipped collect focus reloads once and retries persistence", async () => {
let writes = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier()),
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes += 1;
if (writes === 1) throw new Error("temporary focus write failure");
return { focus: createdFocusFromArgs(args), idempotent: false };
},
});
const persisted = await persistOccupationAfterChoice(accounting.client);
// 原值: 第一次写失败后重试,writes=2current_question 非空
// 新值: leftover 采集关闭,不写焦点,S3 旁白
// 原因: 训练门开后不强制职业 leftover persistBUG-648
assert.ok(persisted.hostNarration);
assert.match(persisted.hostNarration, /范围已经收到|这次给出的范围|如果还记得/);
assert.equal(writes, 0);
});
test("nonterminal turn exit is already satisfied once dated coverage can adopt", async () => {
// 旧:内部 canAdopt 即 satisfied、不建题(采集期也会被拦住)。
// 新:只有 publicCanAdopt 才算有出口;五条证据 live case 对齐账本指纹后用户能采用。
// 保留:用户真能采用时不得再塞采集题。
const answerChoiceModule = await import("../src/lib/rectification-agentic/v9/answer-choice.ts") as Record<string, unknown>;
const ensureExit = answerChoiceModule.ensureNonTerminalTurnExit as undefined | ((input: {
accounting: ReturnType<typeof fakeAccounting>["client"];
userId: string;
caseId: string;
}) => Promise<{ hostNarration: string | null; persisted: boolean }>);
assert.equal(typeof ensureExit, "function");
// 旧:内部 canAdopt 即 satisfied。新:公开可采用才算出牌;本夹具把剩余带年份域拒答。
const decision = liveCaseDossier();
const current = rpcDossier(decision);
const fingerprint = evidenceLedgerFingerprint(
decision.evidence.map((item) => ({
id: item.id,
eventKind: item.eventKind ?? "event",
domain: item.domain,
occurredFrom: item.occurredFrom,
occurredTo: item.occurredTo,
datePrecision: item.datePrecision,
summary: item.summary ?? `${item.occurredFrom} ${item.eventKind ?? "event"}`,
status: item.status,
})) as never,
);
(current.latest_result as { evidence_ledger_fingerprint: string }).evidence_ledger_fingerprint = fingerprint;
(current.conversation_summary as { declined_skipped_topics: unknown[] }).declined_skipped_topics = [
{ target_domain: "family", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "finance", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "relocation", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "occupation", status: "declined", intent: "collect_method_evidence" },
];
const next = decideFromDossier({
...decision,
conversationSummary: {
...decision.conversationSummary,
declinedSkippedTopics: [
{ target_domain: "family", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "finance", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "relocation", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "occupation", status: "declined", intent: "collect_method_evidence" },
],
},
}, { birthDate: "1997-08-08" });
assert.equal(publicCanAdopt(next), true);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => current,
set_agentic_rectification_conversation_focus: () => {
throw new Error("adoptable offer must not persist another question");
},
});
const repaired = await ensureExit!({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(repaired.persisted, false);
assert.equal(repaired.hostNarration, null);
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
});
test("live five-evidence case keeps dated collect after family denial instead of adopting", async () => {
const dossier = liveCaseDossier();
const state = liveCaseState();
assert.equal(dossier.evidence.length, 5);
assert.equal(state.answered_probes.length, 5);
assert.deepEqual(state.candidates.filter((candidate) => candidate.status === "active").map((candidate) => candidate.time), [
"05:00", "05:07", "04:53",
]);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(decision.probe, null);
assert.equal(decision.canAdopt, true);
assert.equal(decision.canConfirmExactMinute, false);
assert.ok(decision.droppedProbes.some((probe) => (
probe.semantic_key === CAREER_2023_ACTIVATION.semantic_key
&& probe.reason === "no_split_among_active"
)), JSON.stringify(decision.droppedProbes));
const plan = planFrom(dossier, { sessionOutcome: decision.sessionOutcome });
// 原值: leftover 财务采集
// 新值: leftover 带年份轮转关闭,不是 finance
// 原因: 五件两类训练门已开(BUG-648)
assert.notEqual(plan.next_followup?.domain, "finance");
assert.notEqual(plan.next_followup?.domain, "family");
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: createdFocusFromArgs(args),
idempotent: false,
}),
});
const persisted = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: state,
nextAction: publicNextAction(decision),
birthDate: "1997-08-08",
});
assert.equal(persisted.persisted, false);
assert.notEqual(persisted.followup?.domain, "finance");
assert.match(persisted.hostNarration, /范围已经收到|这次给出的范围|如果还记得/);
assert.doesNotMatch(persisted.hostNarration, /我按你说的经历认真分析过了/);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(setFocus, undefined);
});
test("coverage incomplete still prefers a dated discriminator over a same-turn yearless varga card", () => {
const state = revision5State([DATED_RELOCATION_2016]);
const plan = planFrom(revision5Dossier(state));
assert.equal(plan.next_followup?.semantic_key, DATED_RELOCATION_2016.semantic_key);
assert.ok(plan.next_followup?.choice_frame);
assert.notEqual(plan.next_followup?.source, "method_coverage");
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d12/);
});
test("yearless varga in another domain stays behind uncovered-method spoken collect", () => {
const evidence = [
{
id: "e-career",
status: "confirmed",
domain: "career",
datePrecision: "year" as const,
occurredFrom: "2020-01-01",
occurredTo: null,
eventKind: "career_entry",
},
{
id: "e-rel",
status: "confirmed",
domain: "relationship",
datePrecision: "year" as const,
occurredFrom: "2024-01-01",
occurredTo: null,
eventKind: "relationship_end",
},
{
id: "e-edu",
status: "confirmed",
domain: "education",
datePrecision: "year" as const,
occurredFrom: "2016-01-01",
occurredTo: null,
eventKind: "education_start",
},
{
id: "e-job",
status: "confirmed",
domain: "occupation",
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
},
];
const plan = buildMethodFollowupPlan({
evidence,
contrastPacket: {
candidateSetVersion: "05:00-05:07",
vargaDifferences: [],
probes: [{
probeId: D24.id,
candidateSetVersion: "05:00-05:07",
question: D24.question,
expectedOutcomes: D24.expected_outcomes.map((row) => ({
outcomeId: row.answer_class,
supportsCandidateIds: [...row.supports],
conflictsCandidateIds: [...row.conflicts],
})),
candidateSplitHash: D24.candidate_split_hash,
informationGain: D24.information_gain,
sourceFeatures: [{ technique: "D24", calculationResultId: null }],
domain: "education",
year: null,
semanticKey: D24.semantic_key,
choiceKind: "existence",
styleOptions: EXISTENCE_OPTIONS.map((item) => ({
label: item.label,
answerClass: item.answer_class,
})),
}],
},
candidatesSeparated: false,
});
// 原值: leftover 家人采集压过无年份 D24
// 新值: leftover 带年份轮转关闭;方法层占问可问;不是家人轮盘,也不是 D24 卡
// 原因: 三件三类训练门已开(BUG-648)
assert.equal(plan.next_followup?.domain, "horary");
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/);
});
test("collect-focus classifier and resolve-focus copy do not treat explicit no as resolved", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const classifier = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), "utf8");
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
const fastPath = route.slice(
route.indexOf('if (action === "message")'),
route.indexOf("const requestTime"),
);
assert.match(fastPath, /isCollectFocusSchema/);
assert.match(fastPath, /collectFocusCloseStatus/);
assert.match(fastPath, /applyCollectFocusDenial/);
assert.match(classifier, /collect === true|isCollectFocusSchema/);
assert.doesNotMatch(classifier + fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/);
const resolveTool = tools.slice(
tools.indexOf('id: "rectification-resolve-focus"'),
tools.indexOf("inputSchema: z.object({", tools.indexOf('id: "rectification-resolve-focus"')),
);
assert.match(resolveTool, /declined/);
assert.match(resolveTool, /采集题|确实没有/);
assert.match(resolveTool, /resolved 只用于已落证据|已落证据/);
assert.match(resolveTool, /没有 active focus|没有当前.*focus/);
});
test("has_new_dated_event continues into the agent after applying the answer", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const classifier = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), "utf8");
const fastPath = route.slice(
route.indexOf('if (action === "message")'),
route.indexOf("const requestTime"),
);
assert.match(classifier, /has_new_dated_event/);
assert.match(classifier, /带大概时间的经历/);
assert.match(classifier, /intent 仍为 answer_current_focus/);
assert.match(classifier, /不要改成 provide_new_evidence/);
assert.doesNotMatch(classifier + fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/);
assert.doesNotMatch(classifier + fastPath, /(?:userMessage|user_message|message)\.(?:match|search|includes|startsWith|endsWith)\(/);
assert.match(fastPath, /shouldContinueAgentForDatedEvent/);
assert.match(fastPath, /deferFollowup:\s*continueToAgent/);
const choiceApply = fastPath.slice(
fastPath.indexOf("if (classified.intent === \"answer_current_focus\")"),
fastPath.indexOf("if (classified.intent === \"stop_rectification\")"),
);
assert.ok(choiceApply.indexOf("applyRectificationChoice") < choiceApply.indexOf("if (!continueToAgent)"));
assert.match(choiceApply, /return completedMessageResponse\(applied\.narration/);
assert.ok(choiceApply.indexOf("if (!continueToAgent)") < choiceApply.indexOf("return completedMessageResponse(applied.narration"));
const collectApply = fastPath.slice(
fastPath.indexOf("if (closeStatus)"),
fastPath.indexOf("} else {"),
);
assert.ok(collectApply.indexOf("applyCollectFocusDenial") < collectApply.indexOf("if (!continueToAgent)"));
assert.match(collectApply, /persistCollectDenialTurn/);
assert.match(collectApply, /completedMessageResponse\(finished\.streamText, requestId, caseId, finished\.turnId\)/);
assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistCollectDenialTurn"));
assert.ok(route.indexOf("if (action === \"message\")") < route.indexOf("runV9AgentTurn({"));
assert.match(route, /function completedMessageResponse\([\s\S]*?turnId\?: string \| null/);
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const persistDenial = answerChoice.slice(
answerChoice.indexOf("export async function persistCollectDenialTurn"),
answerChoice.indexOf("export function isStalePreAdoptFocus"),
);
assert.match(persistDenial, /const ack = input\.applied\.ack/);
assert.match(persistDenial, /composeCollectSpokenAssistantText\(ack,/);
assert.match(persistDenial, /linkFocusAskedTurn/);
// 旧:streamText 写死 collectDeclinedAck → 新:用 applied.ack,跳过走 collectSkippedAck
// 原因:BUG-605 「记不清」回执与「没有」不同
assert.match(persistDenial, /streamText = hasNextStem \? ack/);
const persistApplied = answerChoice.slice(answerChoice.indexOf("async function persistApplied"));
assert.match(persistApplied, /command\.deferFollowup !== true/);
assert.equal(shouldContinueAgentForDatedEvent({
intent: "answer_current_focus",
answer_class: "no",
has_new_dated_event: true,
}), true);
assert.equal(shouldContinueAgentForDatedEvent({
intent: "answer_current_focus",
answer_class: "no",
}), false);
});
test("collect denial with a new dated event does not persist the next interview before the agent", async () => {
const occupationFocus = {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: "occupation:occupation",
intent: "collect_method_evidence",
target_evidence_id: null,
target_domain: "occupation",
target_kind: null,
expected_answer_schema: {
prompt: "你平时主要做什么工作?",
collect: true,
},
status: "active",
asked_at: "2026-08-29T00:00:00.000Z",
resolved_at: null,
};
let loads = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => {
loads += 1;
if (loads === 1) {
return rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}), occupationFocus);
}
return rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [
{ target_domain: "family", status: "declined" },
{ target_domain: "occupation", status: "declined" },
],
}));
},
get_agentic_rectification_case_compute: () => computeFixture(),
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
focus_id: args.p_focus_id,
status: args.p_status,
evidence_id: null,
idempotent: false,
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: "acacacac-acac-4cac-8cac-acacacacacac",
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-29T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const applied = await applyCollectFocusDenial(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
focusId: FOCUS_ID,
deferFollowup: true,
});
const resolve = accounting.calls.find((item) => item.fn === "resolve_agentic_rectification_conversation_focus");
assert.equal(resolve?.args.p_status, "declined");
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
assert.equal(applied.nextInterviewPersisted, false);
assert.equal(applied.focus, null);
assert.equal(applied.narration, RECTIFICATION_USER_COPY.collectDeclinedAck);
});
test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once", async () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const idle = source.slice(
source.indexOf("export async function persistNextInterviewIfIdle"),
source.indexOf("async function persistApplied"),
);
assert.equal(idle.split("decideFromDossier").length - 1, 1);
assert.match(idle, /sessionOutcome:\s*decision\.sessionOutcome/);
assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/);
assert.match(idle, /publicNextAction\(decision\)/);
const covered = revision5Dossier(revision5State([DATED_RELOCATION_2016]), {
declinedTopics: [
{ target_domain: "family", status: "declined" },
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "finance", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "relocation", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "occupation", status: "declined" },
{ target_domain: "horary", status: "declined" },
],
});
const decision = decideFromDossier(covered);
assert.notEqual(decision.sessionOutcome, "collect_evidence");
const catalog = rectificationFollowupCatalog(covered.latestResult, covered.evidence);
const collectPlan = buildMethodFollowupPlan({
evidence: covered.evidence,
declinedTopics: covered.conversationSummary.declinedSkippedTopics,
sessionOutcome: "collect_evidence",
...catalog,
candidatesSeparated: false,
});
const decisionPlan = buildMethodFollowupPlan({
evidence: covered.evidence,
declinedTopics: covered.conversationSummary.declinedSkippedTopics,
sessionOutcome: decision.sessionOutcome,
...catalog,
candidatesSeparated: false,
});
if (decisionPlan.next_followup && collectPlan.next_followup) {
assert.notEqual(collectPlan.next_followup.intent, decisionPlan.next_followup.intent);
}
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(covered),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: 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-29T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const persisted = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.ok(persisted.hostNarration || persisted.choiceReady || persisted.persisted);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
if (setFocus) {
assert.notEqual(setFocus.args.p_intent, "collect_method_evidence");
}
});