Files
Jyotisha/frontend/tests/rectification-collect-stall.test.ts
T
Jesse_Chen 998406cee6
Independent Staging Quality Gate / validate (push) Failing after 10m10s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(rectification): keep collecting after family denial
Family "没有" was treated as adopt-ready once the training gate was open. Remaining dated domains still distinguish leftover minutes, so persist those questions instead of writing the adopt narration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 09:38:59 +08:00

1438 lines
53 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, spokenFollowupForUser } 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 { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import {
shouldContinueAgentForDatedEvent,
shouldDeclineCollectFocus,
} 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,
USER_COLLECT_QUESTION,
} 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 stays 10.0.14 for this collect-stall fix", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.14");
});
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");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(plan.next_followup?.probe_year, 2021);
// 旧:year_label 含 2021 → 新:采集口语不带年份前缀,year_label 不进 followup
// 原因:决策 7probe_year 仍锁定 dated 家庭采集,不出无年份 D12 卡
assert.equal(plan.next_followup?.year_label, undefined);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
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());
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame, null);
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");
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)\./);
} else {
// 旧:训练门已开时跳过 dated 补采集,落到 occupation。
// 新:家人拒答后仍收学业等带年份经历,用来继续区分剩余分钟。
assert.equal(next.next_followup?.domain, "education");
assert.equal(next.next_followup?.intent, "collect_method_evidence");
}
});
test("persistServerOwnedFocus writes family as target_domain so a no answer becomes declined coverage", async () => {
const plan = planFrom(revision5Dossier());
const accounting = 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 persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: revision5Dossier().latestResult?.decisionReceipt,
followup: plan.next_followup,
});
assert.equal(persisted.status, "created");
const call = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(call?.args.p_target_domain, "family");
assert.equal(call?.args.p_intent, "collect_method_evidence");
});
test("occupation collect denial declines the focus and advances coverage to horary", async () => {
assert.equal(shouldDeclineCollectFocus({
intent: "answer_current_focus",
answer_class: "no",
}), true);
assert.equal(shouldDeclineCollectFocus({
intent: "provide_new_evidence",
answer_class: null,
}), false);
assert.equal(shouldDeclineCollectFocus({
intent: "answer_current_focus",
answer_class: "unsure",
}), false);
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 });
// 旧:训练门已开 → occupation。新:家人拒答后仍收学业。
assert.equal(occupationPlan.next_followup?.domain, "education");
assert.equal(occupationPlan.next_followup?.intent, "collect_method_evidence");
assert.equal(occupationPlan.next_followup?.choice_frame, null);
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 });
assert.equal(nextCollect.next_followup?.domain, "education");
});
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,
});
assert.equal(applied.nextInterviewPersisted, true);
assert.ok(applied.focus);
assert.equal(applied.narration, USER_COLLECT_QUESTION.education);
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, RECTIFICATION_USER_COPY.collectDeclinedAck);
const stored = composeCollectSpokenAssistantText(
RECTIFICATION_USER_COPY.collectDeclinedAck,
USER_COLLECT_QUESTION.education,
);
const append = accounting.calls.find((item) => item.fn === "append_agentic_rectification_turn");
assert.equal(append?.args.p_assistant_message, stored);
assert.ok(accounting.calls.some((item) => (
item.fn === "set_agentic_rectification_conversation_focus"
&& item.args.p_asked_turn_id === TURN_ID
)));
});
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" }],
}));
assert.ok(occupationPlan.next_followup);
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.equal(persisted.persisted, true);
assert.ok(persisted.hostNarration || persisted.choiceReady);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
const question = projectCurrentQuestion({
id: FOCUS_ID,
questionId: String(setFocus?.args.p_question_id ?? ""),
intent: String(setFocus?.args.p_intent ?? ""),
targetDomain: typeof setFocus?.args.p_target_domain === "string" ? setFocus.args.p_target_domain : null,
expectedAnswerSchema: setFocus?.args.p_expected_answer_schema as Record<string, unknown>,
});
assert.notEqual(question, null);
assert.ok(question?.kind === "collect_spoken" || question?.kind === "choice");
});
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);
assert.ok(persisted.hostNarration);
assert.notEqual(persisted.hostNarration, "");
assert.ok(
accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length >= 2,
);
});
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);
assert.ok(persisted.hostNarration);
assert.equal(writes, 2);
const write = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").at(-1);
const currentQuestion = projectCurrentQuestion({
id: FOCUS_ID,
questionId: String(write?.args.p_question_id ?? ""),
intent: String(write?.args.p_intent ?? ""),
targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null,
expectedAnswerSchema: write?.args.p_expected_answer_schema as Record<string, unknown>,
});
assert.notEqual(currentQuestion, null);
assert.ok(currentQuestion?.prompt || currentQuestion?.kind);
});
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");
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;
const next = decideFromDossier(decision, { 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 });
assert.equal(plan.next_followup?.domain, "finance");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.choice_frame, null);
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, true);
assert.equal(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?.args.p_target_domain, "finance");
});
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,
});
assert.equal(plan.next_followup?.domain, "family");
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, /shouldDeclineCollectFocus/);
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 (shouldDeclineCollectFocus(classified))"),
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, /composeCollectSpokenAssistantText\(RECTIFICATION_USER_COPY\.collectDeclinedAck/);
assert.match(persistDenial, /linkFocusAskedTurn/);
assert.match(persistDenial, /streamText = hasNextStem \? RECTIFICATION_USER_COPY\.collectDeclinedAck/);
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: "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,
});
assert.ok(decisionPlan.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.equal(persisted.persisted, true);
assert.ok(persisted.hostNarration || persisted.choiceReady);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
assert.notEqual(setFocus?.args.p_intent, "collect_method_evidence");
});