import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { buildCandidateContrastPacket } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { USER_COLLECT_QUESTION, USER_COLLECT_QUESTION_RETRY, } from "../src/lib/rectification-agentic/user-copy.ts"; import { isStalePreAdoptFocus } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { buildChoiceFrame, parseRectificationChoiceCard, serverOwnedChoiceCopy, } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts"; import { buildMethodFollowupPlan, buildNextUserAction, spokenFollowupForUser, type MethodFollowup, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { completeStyleOptions } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts"; import { openQuestionFromPersistedFocus, persistServerOwnedFocus, } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { d9StyleLabel } from "../src/lib/rectification-agentic/v9/varga-type-tables.ts"; import { CASE_ID, FOCUS_ID, OPEN_ENGINE_CAPABILITY_CEILING, USER_ID, fakeAccounting, } from "./rectification-v9-test-support.ts"; const D9_QUESTION = "亲密关系里,你更接近哪一种相处方式?"; const WALKTHROUGH_EVIDENCE = [ { status: "confirmed" as const, domain: "education", datePrecision: "year" as const, occurredFrom: "2016-01-01", occurredTo: null, eventKind: "education_start", }, { status: "confirmed" as const, domain: "relationship", datePrecision: "year" as const, occurredFrom: "2018-01-01", occurredTo: null, eventKind: "relationship_start", }, { status: "confirmed" as const, domain: "career", datePrecision: "year" as const, occurredFrom: "2019-01-01", occurredTo: null, eventKind: "career_entry", }, { status: "confirmed" as const, domain: "family", datePrecision: "year" as const, occurredFrom: "2020-01-01", occurredTo: null, eventKind: "family_event", }, ]; function d9WalkthroughPacket() { return buildCandidateContrastPacket({ candidateSetVersion: "05:00-05:04", candidateTimes: ["05:00", "05:04"], transitions: [ { layer: "d9", at: "05:04", from_sign: "巨蟹座", to_sign: "狮子座" }, ], }); } function d9StyleOptions() { const completed = completeStyleOptions({ choiceKind: "varga_style", styleOptions: [ { sign: "巨蟹座", label: d9StyleLabel("巨蟹座"), answer_class: "yes" }, { sign: "狮子座", label: d9StyleLabel("狮子座"), answer_class: "weak_yes" }, ], }); assert.equal(completed.ok, true); if (!completed.ok) throw new Error("D9 style options"); return completed.options; } function d9Followup(frame: NonNullable>): MethodFollowup { const probe = d9WalkthroughPacket().probes.find((item) => item.semanticKey.startsWith("varga.d9.")); assert.ok(probe); return { method_id: "d9_relationship", intent: "distinguish_candidates", ask_theme: "relationship_style", domain: "relationship", kind_hint: "relationship_change", user_prompt_hint: probe.question, must_not_label: false, choice_frame: frame, source: "event_probe", information_gain: probe.informationGain, semantic_key: probe.semanticKey, candidate_split_hash: probe.candidateSplitHash, choice_kind: "varga_style", candidate_ids: ["05:00", "05:04"], expected_outcomes: probe.expectedOutcomes.map((row) => ({ answer_class: row.outcomeId, supports: row.supportsCandidateIds, conflicts: row.conflictsCandidateIds, })), style_options: d9StyleOptions(), }; } function focusFromArgs(args: Record) { return { id: FOCUS_ID, caseId: CASE_ID, questionId: String(args.p_question_id ?? ""), intent: String(args.p_intent ?? ""), targetEvidenceId: null, targetDomain: typeof args.p_target_domain === "string" ? args.p_target_domain : null, targetKind: null, expectedAnswerSchema: (args.p_expected_answer_schema ?? {}) as Record, status: "active" as const, askedAt: "2026-09-02T00:00:00.000Z", resolvedAt: null, }; } function persistAccounting() { return fakeAccounting({ set_agentic_rectification_conversation_focus: (_fn, args) => ({ id: FOCUS_ID, case_id: CASE_ID, question_id: args.p_question_id, intent: args.p_intent, target_evidence_id: null, 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-02T00:00:00.000Z", resolved_at: null, idempotent: false, }), }); } function assertChoiceInvariants(input: { card: ReturnType | ReturnType; question: ReturnType; }) { if (input.card) { assert.ok(input.card.prompt?.trim(), "choice_card nonempty ⇒ prompt nonempty"); } if (input.question?.kind === "choice") { assert.ok(input.question.prompt?.trim(), "active choice focus ⇒ projectCurrentQuestion prompt nonempty"); } } test("walkthrough D9 probe question is in the 4-80 window and must survive frame assembly", () => { const packet = d9WalkthroughPacket(); const probe = packet.probes.find((item) => item.semanticKey.startsWith("varga.d9.")); assert.ok(probe); assert.equal(probe.question, D9_QUESTION); assert.ok(probe.question.length >= 4 && probe.question.length <= 80); const plan = buildMethodFollowupPlan({ evidence: WALKTHROUGH_EVIDENCE, contrastPacket: packet, sessionOutcome: "discriminate_candidates", topCandidateTimes: ["05:00", "05:04"], candidatesSeparated: false, }); const followup = plan.next_followup; assert.equal(followup?.intent, "distinguish_candidates", JSON.stringify({ intent: followup?.intent, source: followup?.source, method_id: followup?.method_id, dropped: plan.dropped_probes, })); assert.equal(followup?.method_id, "d9_relationship"); const frame = followup?.choice_frame ?? null; assert.ok(frame, "D9 probe must assemble a choice_frame"); assert.equal(frame?.prompt, D9_QUESTION); const copy = frame ? serverOwnedChoiceCopy(frame) : null; assert.ok(copy?.prompt, "assembled D9 copy must keep the probe question"); }); test("D9 probe shape with unwired prompt makes the choice invariants red", async () => { const packet = d9WalkthroughPacket(); const probe = packet.probes.find((item) => item.semanticKey.startsWith("varga.d9.")); assert.ok(probe); const wired = buildChoiceFrame({ method_id: "d9_relationship", ask_theme: "relationship_style", domain: "relationship", user_prompt_hint: probe.question, choice_kind: "varga_style", semantic_key: probe.semanticKey, style_options: d9StyleOptions(), }, { evidence: WALKTHROUGH_EVIDENCE, probes: [{ year: 0, year_label: "当前这几个候选", domain: "relationship", event_family: "相处方式更接近其中一种", source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, user_meaning: probe.question, role: "distinguish", information_gain: probe.informationGain, semantic_key: probe.semanticKey, candidate_split_hash: probe.candidateSplitHash, candidate_ids: ["05:00", "05:04"], expected_outcomes: probe.expectedOutcomes.map((row) => ({ answer_class: row.outcomeId, supports: row.supportsCandidateIds, conflicts: row.conflictsCandidateIds, })), choice_kind: "varga_style", style_options: d9StyleOptions(), }], }); assert.ok(wired); const unwired = { ...wired, prompt: "", period: "" }; assert.equal(serverOwnedChoiceCopy(unwired), null); const accounting = persistAccounting(); const persisted = await persistServerOwnedFocus({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, activeFocus: null, decisionReceipt: null, followup: d9Followup(unwired), }); const open = openQuestionFromPersistedFocus(persisted); const question = persisted.focus ? projectCurrentQuestion(persisted.focus) : null; const card = persisted.focus ? choiceCardFromCaseDossier({ evidence: WALKTHROUGH_EVIDENCE, conversationSummary: { activeFocus: persisted.focus, declinedSkippedTopics: [], }, latestResult: { decisionReceipt: null, selectionAllowed: false, confirmationAllowed: false, candidates: [ { time: "05:00", rank: 1, relativeSupport: 0.6 }, { time: "05:04", rank: 2, relativeSupport: 0.4 }, ], }, case: { acceptedTime: null, status: "collecting_evidence" }, }) : null; assertChoiceInvariants({ card, question }); assert.ok(persisted.status === "created" || persisted.status === "already_open"); assert.equal(open?.kind, "collect_spoken"); assert.ok(open?.prompt?.trim()); assert.notEqual(question?.kind, "choice"); }); test("agent persistPlanFocus path must fail-closed when D9 choice copy is null", () => { const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8"); const persistPlan = tools.slice(tools.indexOf("const persistPlanFocus")); assert.match(persistPlan, /spokenCollectFallbackFollowup/); }); test("wired D9 persist keeps a nonempty choice prompt", async () => { const packet = d9WalkthroughPacket(); const probe = packet.probes.find((item) => item.semanticKey.startsWith("varga.d9.")); assert.ok(probe); const frame = buildChoiceFrame({ method_id: "d9_relationship", ask_theme: "relationship_style", domain: "relationship", user_prompt_hint: probe.question, choice_kind: "varga_style", semantic_key: probe.semanticKey, style_options: d9StyleOptions(), }, { evidence: WALKTHROUGH_EVIDENCE, probes: [{ year: 0, year_label: "当前这几个候选", domain: "relationship", event_family: "相处方式更接近其中一种", source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, user_meaning: probe.question, role: "distinguish", information_gain: probe.informationGain, semantic_key: probe.semanticKey, candidate_split_hash: probe.candidateSplitHash, candidate_ids: ["05:00", "05:04"], expected_outcomes: probe.expectedOutcomes.map((row) => ({ answer_class: row.outcomeId, supports: row.supportsCandidateIds, conflicts: row.conflictsCandidateIds, })), choice_kind: "varga_style", style_options: d9StyleOptions(), }], }); assert.ok(frame); const persisted = await persistServerOwnedFocus({ accounting: persistAccounting().client, userId: USER_ID, caseId: CASE_ID, activeFocus: null, decisionReceipt: null, followup: d9Followup(frame), }); const open = openQuestionFromPersistedFocus(persisted); const question = persisted.focus ? projectCurrentQuestion(persisted.focus) : null; const card = parseRectificationChoiceCard({ question_id: persisted.focus?.questionId, method_id: "d9_relationship", prompt: persisted.prompt, choice_mode: "A/B/C/D", options: [ { key: "A", label: d9StyleOptions()[0]!.label, answer_class: "yes", role: "primary" }, { key: "B", label: d9StyleOptions()[1]!.label, answer_class: "weak_yes", role: "primary" }, { key: "C", label: d9StyleOptions()[2]!.label, answer_class: "no", role: "primary" }, { key: "D", label: d9StyleOptions()[3]!.label, answer_class: "unsure", role: "primary" }, ], stop_label: "先这样,先看当前范围", stop_message: "先这样", scoring: true, focus_id: FOCUS_ID, }); assert.equal(persisted.status, "created"); assert.equal(open?.kind, "choice"); assert.equal(open?.prompt, D9_QUESTION); assertChoiceInvariants({ card, question }); }); test("unwired D9 persist falls back to spoken collect instead of an empty-prompt card", async () => { const packet = d9WalkthroughPacket(); const probe = packet.probes.find((item) => item.semanticKey.startsWith("varga.d9.")); assert.ok(probe); const wired = buildChoiceFrame({ method_id: "d9_relationship", ask_theme: "relationship_style", domain: "relationship", user_prompt_hint: probe.question, choice_kind: "varga_style", semantic_key: probe.semanticKey, style_options: d9StyleOptions(), }, { evidence: WALKTHROUGH_EVIDENCE, probes: [{ year: 0, year_label: "当前这几个候选", domain: "relationship", event_family: "相处方式更接近其中一种", source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, user_meaning: probe.question, role: "distinguish", information_gain: probe.informationGain, semantic_key: probe.semanticKey, candidate_split_hash: probe.candidateSplitHash, candidate_ids: ["05:00", "05:04"], expected_outcomes: probe.expectedOutcomes.map((row) => ({ answer_class: row.outcomeId, supports: row.supportsCandidateIds, conflicts: row.conflictsCandidateIds, })), choice_kind: "varga_style", style_options: d9StyleOptions(), }], }); assert.ok(wired); const persisted = await persistServerOwnedFocus({ accounting: persistAccounting().client, userId: USER_ID, caseId: CASE_ID, activeFocus: null, decisionReceipt: null, followup: d9Followup({ ...wired, prompt: "", period: "" }), }); const open = openQuestionFromPersistedFocus(persisted); const question = persisted.focus ? projectCurrentQuestion(persisted.focus) : null; assert.ok(persisted.status === "created" || persisted.status === "already_open"); assert.equal(open?.kind, "collect_spoken"); assert.ok(open?.prompt?.trim()); assert.notEqual(question?.kind, "choice"); assertChoiceInvariants({ card: null, question }); }); test("agent instructions do not assert that the next question is already on screen", () => { const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8"); const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); assert.match(agent, /正文不得断言界面当前状态/); assert.match(agent, /接下来我们继续/); assert.doesNotMatch(agent, /自然过渡到界面上的下一步/); assert.match(chat, /await loadCaseSnapshot\(\)/); assert.match(chat, /mergeTurnQuestions/); assert.match(chat, /afterAnswer=\{afterAnswer\}/); assert.doesNotMatch(chat, /isLatestMessage/); }); test("same-domain collect retry uses structured closed-focus state, not body matching", () => { const base = { method_id: "d9_relationship" as const, intent: "collect_method_evidence", ask_theme: "relationship_style" as const, domain: "relationship", kind_hint: "relationship_start", user_prompt_hint: "ask", must_not_label: false as const, choice_frame: null, source: "method_coverage" as const, }; const first = spokenFollowupForUser(base); const retry = spokenFollowupForUser({ ...base, collect_retry: true }); assert.equal(first, USER_COLLECT_QUESTION.relationship); assert.equal(retry, USER_COLLECT_QUESTION_RETRY.relationship); assert.notEqual(first, retry); const plan = buildMethodFollowupPlan({ evidence: [{ status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2019-01-01", occurredTo: null, eventKind: "career_entry", }], closedCollectFocuses: [{ target_domain: "relationship", intent: "collect_method_evidence", status: "resolved", question_id: "collect:relationship:collect_method_evidence", }], sessionOutcome: "collect_evidence", }); assert.equal(plan.next_followup?.domain, "relationship"); assert.equal(plan.next_followup?.collect_retry, true); assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION_RETRY.relationship); const declined = buildMethodFollowupPlan({ evidence: [{ status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2019-01-01", occurredTo: null, eventKind: "career_entry", }], declinedTopics: [{ target_domain: "relationship", intent: "collect_method_evidence", status: "declined", }], sessionOutcome: "collect_evidence", }); assert.notEqual(declined.next_followup?.domain, "relationship"); }); test("accepted time replaces leftover collect with reverse_verify or consultation handoff", () => { assert.equal(isStalePreAdoptFocus("04:53", { intent: "collect_method_evidence" }), true); assert.equal(isStalePreAdoptFocus("04:53", { intent: "distinguish_candidates" }), true); assert.equal(isStalePreAdoptFocus("04:53", { intent: "reverse_verify" }), false); assert.equal(isStalePreAdoptFocus(null, { intent: "collect_method_evidence" }), false); const idle = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); const accept = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url), "utf8"); assert.match(idle, /isStalePreAdoptFocus/); assert.match(idle, /resolveV10ConversationFocus/); assert.match(accept, /persistNextInterviewIfIdle/); const leftover = { intent: "collect_method_evidence", targetDomain: "finance", targetKind: null, expectedAnswerSchema: { collect: true, prompt: USER_COLLECT_QUESTION.finance }, }; const plan = buildMethodFollowupPlan({ evidence: WALKTHROUGH_EVIDENCE, activeFocus: leftover, accepted: true, eventProbes: [{ year: 2021, year_label: "2021 年前后", domain: "finance", event_family: "收入或债务明显变化", source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, user_meaning: "2021 年前后财务变化", role: "distinguish", information_gain: 0.4, semantic_key: "finance.2021", candidate_split_hash: "finance:2021", candidate_ids: ["04:53", "05:00"], expected_outcomes: [ { answer_class: "yes", supports: ["04:53"], conflicts: ["05:00"] }, { answer_class: "no", supports: ["05:00"], conflicts: ["04:53"] }, ], }], }); assert.equal(plan.next_followup?.intent, "reverse_verify"); const action = buildNextUserAction({ scorableCount: 4, evidenceCount: 4, hasLatestResult: true, selectionAllowed: true, sessionOutcome: "adopt_representative", nextFollowup: plan.next_followup, workingTime: "04:53", accepted: true, }); assert.equal(action.id, "verify_adopted_time"); const empty = buildMethodFollowupPlan({ evidence: WALKTHROUGH_EVIDENCE, activeFocus: leftover, accepted: true, eventProbes: [], }); assert.equal(empty.next_followup, null); assert.equal(buildNextUserAction({ scorableCount: 4, evidenceCount: 4, hasLatestResult: true, selectionAllowed: true, sessionOutcome: "adopt_representative", nextFollowup: empty.next_followup, workingTime: "04:53", accepted: true, }).id, "start_consultation"); }); test("coverage does not block overlay can_adopt when the engine allows accept", () => { const offered = decideRectification({ engineCeiling: { acceptanceAllowed: true, selectionAllowed: true, proposeAllowed: true, confirmationAllowed: false, }, methodCoverageAll: false, trainingGateOpen: true, candidateScores: [ { time: "04:53", score: 34 }, { time: "04:51", score: 33 }, { time: "04:47", score: 33 }, ], discriminatorProbe: null, }); assert.equal(offered.canAdopt, true); assert.equal(offered.canConfirmExactMinute, false); const refused = decideRectification({ engineCeiling: { acceptanceAllowed: false, selectionAllowed: false, proposeAllowed: false, confirmationAllowed: false, }, methodCoverageAll: true, trainingGateOpen: true, candidateScores: [ { time: "04:53", score: 34 }, { time: "04:51", score: 33 }, { time: "04:47", score: 33 }, ], discriminatorProbe: null, }); assert.equal(refused.canAdopt, false); assert.equal(refused.canConfirmExactMinute, false); const trainingClosed = decideRectification({ engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, trainingGateOpen: false, candidateScores: [ { time: "04:53", score: 34 }, { time: "04:51", score: 33 }, ], discriminatorProbe: null, }); assert.equal(trainingClosed.canAdopt, false); });