Files
Jyotisha/frontend/tests/rectification-collect-direction-20260904.test.ts
T
Jesse_ChenandCursor 150d7ef189 fix(web): exit rectification when the probe pool is exhausted (BUG-558)
Stop falling through to "say another event" after dated and occupation collect are done. Deliver an adopt path or a gate sentence, and add a spoken-collect stop control.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 23:03:11 +08:00

793 lines
28 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
collectionProgressFromReceipt,
meetsAcceptanceEventQuality,
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
buildMethodFollowupPlan,
buildNextUserAction,
exhaustionSpokenCollectFollowup,
holdoutFollowupFor,
nextDatedCollectFollowup,
spokenFollowupForUser,
type MethodFollowupEvidence,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import {
persistNextInterviewAfterChoice,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { publicDecisionFields } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import {
evidenceLedgerFingerprint,
parseV9CaseDossier,
RectificationToolServiceError,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
CANDIDATE_ID,
CASE_ID,
FOCUS_ID,
SECOND_CANDIDATE_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
type ExecutableTool<T = unknown> = {
execute(input: unknown): Promise<T>;
};
const OOS_PROMPTS = [
{
domain: "family",
user_meaning: "校时还没用过家人这条线。有没有一件没提过、但记得大概时间的家人变化?",
used_for_scoring: false as const,
},
{
domain: "education",
user_meaning: "学业这条线还没单独核对。有没有记得大概时间的升学或考试?",
used_for_scoring: false as const,
},
{
domain: "finance",
user_meaning: "钱的方面还没单独核对。有没有记得大概时间的收入变化?",
used_for_scoring: false as const,
},
];
function dated(
domain: string,
year: string,
extra: { id?: string; eventKind?: string } = {},
): MethodFollowupEvidence & { id: string; eventKind?: string } {
const yearless = extra.eventKind === "occupation_note";
return {
id: extra.id ?? `e-${domain}-${year}`,
status: "confirmed",
domain,
datePrecision: yearless ? "unknown" : "year",
occurredFrom: yearless ? null : `${year}-03-01`,
occurredTo: null,
eventKind: extra.eventKind,
};
}
const TWO_SCOREABLE = [
dated("career", "2011", { eventKind: "career_entry" }),
dated("relationship", "2014", { eventKind: "relationship_start" }),
] as const;
const FOUR_SCOREABLE = [
...TWO_SCOREABLE,
dated("education", "2008", { eventKind: "education_start" }),
dated("finance", "2017", { eventKind: "income_change" }),
] as const;
const FAMILY_DECLINED = [{
target_domain: "family",
status: "declined",
intent: "collect_method_evidence",
}];
function collectPlan(
evidence: readonly MethodFollowupEvidence[],
extra: Partial<Parameters<typeof buildMethodFollowupPlan>[0]> = {},
) {
return buildMethodFollowupPlan({
evidence,
declinedTopics: FAMILY_DECLINED,
holdoutValidation: "not_started",
oosBlindPrompts: OOS_PROMPTS,
...extra,
});
}
test("method rotation asks relationship and career before dated collect", () => {
const careerOnly = buildMethodFollowupPlan({
evidence: [dated("career", "2011", { eventKind: "career_entry" })],
holdoutValidation: "not_started",
});
assert.equal(careerOnly.next_followup?.method_id, "d9_relationship");
assert.equal(careerOnly.next_followup?.domain, "relationship");
const relationshipOnly = buildMethodFollowupPlan({
evidence: [dated("relationship", "2014", { eventKind: "relationship_start" })],
holdoutValidation: "not_started",
});
assert.equal(relationshipOnly.next_followup?.method_id, "d10_career");
assert.equal(relationshipOnly.next_followup?.domain, "career");
const both = buildMethodFollowupPlan({
evidence: TWO_SCOREABLE,
holdoutValidation: "not_started",
});
assert.equal(both.next_followup?.method_id, "relatives");
assert.equal(both.next_followup?.domain, "family");
const familyDeclined = collectPlan(TWO_SCOREABLE);
assert.equal(familyDeclined.next_followup?.method_id, "d5_education");
assert.equal(familyDeclined.next_followup?.domain, "education");
assert.notEqual(familyDeclined.next_followup?.domain, "occupation");
const educationDeclined = collectPlan(TWO_SCOREABLE, {
declinedTopics: [
...FAMILY_DECLINED,
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
],
});
assert.equal(educationDeclined.next_followup?.method_id, "d2_finance");
assert.equal(educationDeclined.next_followup?.domain, "finance");
});
test("two scoreable events do not open OOS even when holdout prompts exist", () => {
assert.equal(meetsAcceptanceEventQuality(TWO_SCOREABLE), false);
const declined = new Set(["family"]);
assert.equal(holdoutFollowupFor({
evidence: TWO_SCOREABLE,
oosBlindPrompts: OOS_PROMPTS,
}, declined), null);
const plan = collectPlan(TWO_SCOREABLE);
assert.notEqual(plan.next_followup?.intent, "out_of_sample_check");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.domain, "education");
assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /上学|升学/);
});
test("family collect spoken stem has no year prefix while probe_year stays dated", () => {
const plan = buildMethodFollowupPlan({
evidence: TWO_SCOREABLE,
evidenceCollectionProbes: [{
year: 2021,
year_label: "2021 年前后",
domain: "family",
event_family: "家人结婚、添丁或住院",
source: "age_band",
tracks: ["vimshottari", "narayana"],
tracks_agree: false,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
role: "collect",
phase: "evidence_collection",
information_gain: 0,
semantic_key: "family.2021",
}],
});
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.probe_year, 2021);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
});
test("four scoreable events skip declined OOS domain and ask education holdout", () => {
assert.equal(meetsAcceptanceEventQuality(FOUR_SCOREABLE), true);
const plan = collectPlan(FOUR_SCOREABLE, {
candidatesSeparated: true,
eventProbes: [],
contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] },
});
assert.equal(plan.next_followup?.intent, "out_of_sample_check");
assert.equal(plan.next_followup?.source, "oos_blind");
assert.equal(plan.next_followup?.domain, "education");
});
test("validate_holdout with every OOS domain declined and no dated holdout asks nothing", () => {
const declinedAll = [
{ target_domain: "family", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "finance", status: "declined", intent: "collect_method_evidence" },
];
const plan = collectPlan(FOUR_SCOREABLE, {
sessionOutcome: "validate_holdout",
declinedTopics: declinedAll,
holdoutEvents: [],
candidatesSeparated: true,
eventProbes: [],
});
assert.equal(plan.next_followup, null);
});
test("dated collect order walks remaining year-bearing domains then occupation", () => {
const declined = new Set(["family"]);
assert.equal(nextDatedCollectFollowup(TWO_SCOREABLE, declined)?.domain, "education");
const educationClosed = collectPlan(TWO_SCOREABLE, {
declinedTopics: [
...FAMILY_DECLINED,
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
],
});
assert.equal(educationClosed.next_followup?.domain, "finance");
const remainingDeclined = [
"family",
"education",
"finance",
"relocation",
"health_pressure",
].map((domain) => ({
target_domain: domain,
status: "declined",
intent: "collect_method_evidence",
}));
const occupation = collectPlan(TWO_SCOREABLE, { declinedTopics: remainingDeclined });
assert.equal(occupation.next_followup?.domain, "occupation");
const occupationClosed = collectPlan(TWO_SCOREABLE, {
declinedTopics: [
...remainingDeclined,
{ target_domain: "occupation", status: "declined", intent: "collect_method_evidence" },
],
closedCollectFocuses: [{
questionId: "collect:occupation:collect_method_evidence",
target_domain: "occupation",
status: "declined",
intent: "collect_method_evidence",
}],
});
assert.equal(occupationClosed.next_followup, null);
// 原值: other
// 新值: null
// 原因: BUG-558 方法覆盖完成且七个带年份领域都问过/拒过后不得再落到「也可以再说一件事」
assert.equal(
exhaustionSpokenCollectFollowup({
evidence: TWO_SCOREABLE,
declinedTopics: remainingDeclined,
})?.domain,
"occupation",
);
});
test("persistNextInterviewAfterChoice after family denial asks education, not occupation", async () => {
const source = readFileSync(
new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url),
"utf8",
);
const persistFn = source.slice(
source.indexOf("export async function persistNextInterviewAfterChoice"),
source.indexOf("async function persistFocusAfterChoice"),
);
assert.doesNotMatch(persistFn, /decideFromDossier\(/);
const dossier = {
evidence: TWO_SCOREABLE,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: FAMILY_DECLINED,
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
candidates: [
{ time: "04:52", relativeSupport: 40, rank: 1 },
{ time: "05:04", relativeSupport: 38, rank: 2 },
],
representativeTime: "04:52",
decisionReceipt: {
oos_blind_prompts: OOS_PROMPTS,
gates: {
event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 },
},
},
},
case: { acceptedTime: null },
};
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-09-04T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
}),
});
const persisted = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: null,
askedTurnId: TURN_ID,
nextAction: {
type: "ask_fact_collection",
session_outcome: "collect_evidence",
completion_status: null,
validated: false,
can_offer_range: false,
can_adopt: false,
can_confirm_exact_minute: false,
selection_allowed: false,
propose_allowed: false,
precision_stage: "collect_events",
representative_time: null,
credible_range: null,
stop_reason: null,
},
});
assert.equal(persisted.persisted, true);
assert.equal(persisted.focus?.targetDomain, "education");
assert.equal(persisted.hostNarration, USER_COLLECT_QUESTION.education);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(setFocus?.args.p_target_domain, "education");
assert.equal(
(setFocus?.args.p_expected_answer_schema as { prompt?: string } | undefined)?.prompt,
USER_COLLECT_QUESTION.education,
);
});
test("four scoreable events still collect remaining dated domains after family denial", () => {
const plan = collectPlan(FOUR_SCOREABLE, {
holdoutValidation: "passed",
oosBlindPrompts: [],
candidatesSeparated: true,
});
// 旧:训练门已开 → occupation。新:家人拒答后仍收搬家,用来区分剩余分钟。
assert.notEqual(plan.next_followup?.domain, "education");
assert.notEqual(plan.next_followup?.domain, "occupation");
assert.equal(plan.next_followup?.domain, "relocation");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
});
test("set-focus twice with a domainless collect prompt falls back to USER_COLLECT_QUESTION.education", async () => {
const raw = dossierFixture({
evidence: TWO_SCOREABLE.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.occurredFrom} ${item.eventKind ?? "event"}`,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-04T00:00:00.000Z",
})),
latestResult: candidateSnapshotFixture({
decisionReceipt: {
oos_blind_prompts: OOS_PROMPTS,
gates: {
event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 },
},
},
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: FAMILY_DECLINED,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-04T00:00:00.000Z",
},
});
const store: { prompt: string | null } = { prompt: null };
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
const schema = args.p_expected_answer_schema as { prompt?: string };
store.prompt = typeof schema?.prompt === "string" ? schema.prompt : null;
return {
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-09-04T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
};
},
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
const generic = {
caseId: CASE_ID,
questionId: "collect:education:collect_method_evidence",
intent: "collect_method_evidence",
spokenPrompt: "除了工作,还有哪件事记得年份?",
targetDomain: "education",
};
const first = await (tools["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute(generic);
assert.equal((first as { error?: string }).error, "invalid_spoken_prompt");
assert.equal((first as { reason?: string }).reason, "domain_missing");
const second = await (tools["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute(generic);
assert.equal((second as { error?: string }).error, undefined);
assert.equal((second as { target_domain?: string }).target_domain, "education");
assert.equal(
((second as { expected_answer_schema?: { prompt?: string } }).expected_answer_schema)?.prompt,
USER_COLLECT_QUESTION.education,
);
assert.equal(store.prompt, USER_COLLECT_QUESTION.education);
});
test("turn decision and GET interview expose collection_progress 2/3/1 or null", () => {
const withGate = parseV9CaseDossier(dossierFixture({
latestResult: candidateSnapshotFixture({
decisionReceipt: {
gates: {
event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 },
},
},
}),
}));
assert.ok(withGate);
assert.deepEqual(projectTurnDecision(withGate).collection_progress, {
scoreable: 2,
minimum: 3,
missing: 1,
});
assert.deepEqual(collectionProgressFromReceipt({
gates: { event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 } },
}), { scoreable: 2, minimum: 3, missing: 1 });
const withoutGate = parseV9CaseDossier(dossierFixture({
latestResult: candidateSnapshotFixture({ decisionReceipt: {} }),
}));
assert.ok(withoutGate);
assert.equal(projectTurnDecision(withoutGate).collection_progress, null);
const route = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url),
"utf8",
);
assert.match(route, /collection_progress: collectionProgressFromReceipt/);
const tools = readFileSync(
new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url),
"utf8",
);
assert.match(tools, /采集题必须写出服务端给你的领域/);
});
const CAREER_EXISTENCE_PROBE: DiscriminatingEventProbe = {
year: 2011,
year_label: "2011 年前后",
domain: "career",
event_family: "入职、换工作或职责加重",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "年份锁定 2011 年前后。事件家族:入职、换工作或职责加重。",
role: "distinguish",
information_gain: 0.4,
candidate_ids: ["05:00", "05:20"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
],
semantic_key: "career.2011",
candidate_split_hash: "career.2011:05:00|05:20",
choice_kind: "existence",
};
const RELATIONSHIP_AND_FAMILY = [
dated("relationship", "2014", { eventKind: "relationship_start" }),
dated("family", "2016", { eventKind: "family_event" }),
] as const;
function careerAnswerPlan(answerClass: string, extra: Partial<Parameters<typeof buildMethodFollowupPlan>[0]> = {}) {
return buildMethodFollowupPlan({
evidence: RELATIONSHIP_AND_FAMILY,
eventProbes: [CAREER_EXISTENCE_PROBE],
askedProbeKeys: ["career.2011", "probe:career.2011"],
answeredProbes: [{
semantic_key: "career.2011",
probe_id: "probe:career.2011",
answer_class: answerClass,
classified_from: "choice",
}],
holdoutValidation: "not_started",
...extra,
});
}
test("career discriminator yes covers career collect without ledger career evidence", () => {
const unanswered = buildMethodFollowupPlan({
evidence: RELATIONSHIP_AND_FAMILY,
eventProbes: [CAREER_EXISTENCE_PROBE],
askedProbeKeys: ["career.2011", "probe:career.2011"],
holdoutValidation: "not_started",
});
assert.equal(unanswered.next_followup?.domain, "career");
assert.equal(unanswered.next_followup?.intent, "collect_method_evidence");
assert.equal(spokenFollowupForUser(unanswered.next_followup), USER_COLLECT_QUESTION.career);
const yes = careerAnswerPlan("yes");
assert.notEqual(yes.next_followup?.domain, "career");
assert.equal(yes.next_followup?.domain, "education");
assert.equal(yes.next_followup?.intent, "collect_method_evidence");
assert.equal(spokenFollowupForUser(yes.next_followup), USER_COLLECT_QUESTION.education);
assert.equal(
yes.methods.find((item) => item.method_id === "d10_career")?.status,
"covered",
);
const weakYes = careerAnswerPlan("weak_yes");
assert.notEqual(weakYes.next_followup?.domain, "career");
assert.equal(weakYes.next_followup?.domain, "education");
const no = careerAnswerPlan("no");
assert.equal(no.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(no.next_followup), USER_COLLECT_QUESTION.career);
const unsure = careerAnswerPlan("unsure");
assert.equal(unsure.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(unsure.next_followup), USER_COLLECT_QUESTION.career);
});
test("career discriminator yes does not invent coverage from a semantic_key prefix", () => {
const plan = careerAnswerPlan("yes", { eventProbes: [] });
assert.equal(plan.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career);
});
test("ledger-classified career probe answers do not cover career collect", () => {
const plan = careerAnswerPlan("yes", {
answeredProbes: [{
semantic_key: "career.2011",
probe_id: "probe:career.2011",
answer_class: "yes",
classified_from: "evidence",
}],
});
assert.equal(plan.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career);
});
const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883";
const CORE_DATED_FOR_OFFER = [
dated("education", "2008", { eventKind: "education_start" }),
dated("education", "2012", { eventKind: "education_completion" }),
dated("relationship", "2014", { eventKind: "relationship_start" }),
dated("career", "2011", { eventKind: "career_entry" }),
] as const;
function rpcEvidenceFromDated(
item: MethodFollowupEvidence & { id: string },
) {
return {
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.occurredFrom ?? "undated"} ${item.eventKind ?? "event"}`,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-04T00:00:00.000Z",
};
}
function declinedTopics(
domains: readonly string[],
): Array<{ target_domain: string; status: "declined"; intent: "collect_method_evidence" }> {
return domains.map((domain) => ({
target_domain: domain,
status: "declined" as const,
intent: "collect_method_evidence" as const,
}));
}
function conversationWithDeclines(domains: readonly string[]) {
return {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: declinedTopics(domains),
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-04T00:00:00.000Z",
};
}
function separatedOfferSnapshot(rawEvidence: unknown[]) {
const parsed = parseV9CaseDossier(dossierFixture({ evidence: rawEvidence }));
assert.ok(parsed);
return candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "05:00",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(parsed.evidence),
candidates: [
{ candidate_id: CANDIDATE_ID, rank: 1, time: "05:00", relative_support: 62, tied_minute_count: 1 },
{ candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "05:01", relative_support: 22, tied_minute_count: 1 },
{ candidate_id: THIRD_CANDIDATE_ID, rank: 3, time: "05:02", relative_support: 16, tied_minute_count: 1 },
],
});
}
function collectVsOfferDossier(input: {
extraEvidence?: readonly (MethodFollowupEvidence & { id: string })[];
declined: readonly string[];
}) {
const evidence = [...CORE_DATED_FOR_OFFER, ...(input.extraEvidence ?? [])];
const raw = evidence.map(rpcEvidenceFromDated);
return dossierFixture({
evidence: raw,
evidenceCount: raw.length,
latestResult: separatedOfferSnapshot(raw),
conversationSummary: conversationWithDeclines(input.declined),
});
}
function nextActionFromDecision(
dossier: NonNullable<ReturnType<typeof parseV9CaseDossier>>,
decision: ReturnType<typeof decideFromDossier>,
) {
const catalog = {
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: decision.sessionOutcome,
};
const plan = buildMethodFollowupPlan(catalog);
return {
plan,
userAction: buildNextUserAction({
scorableCount: dossier.evidence.length,
evidenceCount: dossier.evidence.length,
hasLatestResult: true,
selectionAllowed: true,
sessionOutcome: decision.sessionOutcome,
nextFollowup: plan.next_followup,
workingTime: decision.representativeTime,
}),
};
}
async function persistIdleFocus(rawDossier: ReturnType<typeof dossierFixture>) {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rawDossier,
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-09-04T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
}),
});
const persisted = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
return { persisted, setFocus };
}
test("separated candidates keep collecting finance before offering time cards", async () => {
const raw = collectVsOfferDossier({ declined: ["family"] });
const dossier = parseV9CaseDossier(raw);
assert.ok(dossier);
const decision = decideFromDossier(dossier);
assert.equal(decision.sessionOutcome, "collect_evidence");
assert.equal(decision.nextAction, "ask_fact_collection");
assert.equal(publicDecisionFields(decision).can_adopt, false);
const { plan, userAction } = nextActionFromDecision(dossier, decision);
assert.equal(plan.next_followup?.domain, "finance");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(userAction.id, "ask_method_followup");
assert.notEqual(userAction.id, "adopt_representative");
const idle = await persistIdleFocus(raw);
assert.equal(idle.persisted.persisted, true);
assert.equal(idle.setFocus?.args.p_target_domain, "finance");
});
test("dated collect plus occupation close lets one round offer without another question", async () => {
const extra = [
dated("finance", "2017", { eventKind: "income_change" }),
dated("relocation", "2016", { eventKind: "home_change" }),
dated("health_pressure", "2015", { eventKind: "self_health_event" }),
dated("occupation", "2011", { eventKind: "occupation_note" }),
];
const raw = collectVsOfferDossier({
extraEvidence: extra,
declined: ["family"],
});
const dossier = parseV9CaseDossier(raw);
assert.ok(dossier);
const decision = decideFromDossier(dossier);
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.equal(publicDecisionFields(decision).can_adopt, true);
const { plan, userAction } = nextActionFromDecision(dossier, decision);
assert.equal(plan.next_followup, null);
assert.equal(userAction.id, "adopt_representative");
const idle = await persistIdleFocus(raw);
assert.equal(idle.persisted.persisted, false);
assert.equal(idle.setFocus, undefined);
});
test("offer-candidates refuses while remaining dated collect is still open", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => collectVsOfferDossier({ declined: ["family"] }),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
await assert.rejects(
() => (tools["rectification-offer-candidates"] as unknown as {
execute(input: unknown): Promise<unknown>;
}).execute({ caseId: CASE_ID }),
(error: unknown) => error instanceof RectificationToolServiceError && error.code === "offer_not_allowed",
);
assert.equal(
accounting.calls.some((call) =>
call.fn === "transition_agentic_rectification_case_status"
&& call.args.p_status === "candidate_ready"
),
false,
);
});