import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { deliveryNarrationAllowed, } 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 { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { exhaustionSpokenCollectFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { COLLECT_FOCUS_RETRY_SUFFIXES, collectFocusRetryQuestionIds, persistServerOwnedFocus, stableFollowupQuestionId, } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CASE_ID, TURN_ID, USER_ID, candidateSnapshotFixture, computeFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts"; const BASE_QUESTION = "collect:relationship:collect_method_evidence"; function collectFollowup(overrides: Partial = {}): MethodFollowup { return { method_id: "dasha_events", intent: "collect_method_evidence", ask_theme: "dated_event", domain: "relationship", kind_hint: null, user_prompt_hint: "collect", must_not_label: false, choice_frame: null, source: "method_coverage", ...overrides, }; } function focusRowFromArgs(args: Record) { return { id: "abababab-abab-4bab-8bab-abababababab", 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-10T00:00:00.000Z", resolved_at: null, idempotent: false, }; } function datedEvidence() { return [ { id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" }, { id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-04-01", occurredTo: null, eventKind: "career_entry" }, { id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" }, { id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "income_change" }, { id: "e-reloc", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2022-06-01", occurredTo: null, eventKind: "relocation" }, { id: "e-health", status: "confirmed", domain: "health_pressure", datePrecision: "month", occurredFrom: "2021-11-01", occurredTo: null, eventKind: "health_episode" }, { id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" }, ] as const; } function rpcEvidence(item: ReturnType[number]) { return { id: item.id, source_turn_id: TURN_ID, subject: "self", event_kind: item.eventKind, domain: item.domain, occurred_from: item.occurredFrom, occurred_to: item.occurredTo, date_precision: item.datePrecision, summary: item.domain, status: item.status, supersedes_evidence_id: null, created_at: "2026-09-10T00:00:00.000Z", }; } function relationshipOpenDossier() { const ledger = datedEvidence(); const evidence = ledger.map((item) => rpcEvidence(item)); return dossierFixture({ evidence, evidenceCount: evidence.length, latestResult: candidateSnapshotFixture({ selectionAllowed: true, representativeTime: "04:51", evidenceLedgerFingerprint: evidenceLedgerFingerprint(ledger as never), decisionReceipt: { acceptance_allowed: true, selection_allowed: true, propose_allowed: true, }, }), conversationSummary: { confirmed_evidence_summary: [], pending_revisions: [], active_focus: null, declined_skipped_topics: [], candidate_divergence_summary: null, missing_evidence_categories: [], last_result_policy: null, summary_version: 1, updated_at: "2026-09-10T00:00:00.000Z", }, }); } test("collect focus retries next then next2 then next3 before duplicate_focus", async () => { assert.deepEqual(COLLECT_FOCUS_RETRY_SUFFIXES, ["next", "next2", "next3"]); assert.deepEqual( collectFocusRetryQuestionIds(BASE_QUESTION), [`${BASE_QUESTION}:next`, `${BASE_QUESTION}:next2`, `${BASE_QUESTION}:next3`], ); const blocked = new Set([BASE_QUESTION, `${BASE_QUESTION}:next`, `${BASE_QUESTION}:next2`]); let writes = 0; const accounting = fakeAccounting({ set_agentic_rectification_conversation_focus: (_fn, args) => { writes += 1; const id = String(args.p_question_id); if (blocked.has(id)) throw new Error("agentic_rectification_focus_idempotency_conflict"); return focusRowFromArgs(args); }, }); const persisted = await persistServerOwnedFocus({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, activeFocus: null, decisionReceipt: null, followup: collectFollowup(), }); assert.equal(persisted.status, "created"); assert.equal(writes, 4); assert.equal(persisted.questionId, `${BASE_QUESTION}:next3`); assert.equal(persisted.prompt, USER_COLLECT_QUESTION.relationship); }); test("collect focus four collisions return duplicate_focus", async () => { let writes = 0; const accounting = fakeAccounting({ set_agentic_rectification_conversation_focus: () => { writes += 1; throw new Error("agentic_rectification_focus_idempotency_conflict"); }, }); const persisted = await persistServerOwnedFocus({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, activeFocus: null, decisionReceipt: null, followup: collectFollowup(), }); assert.equal(persisted.status, "duplicate_focus"); assert.equal(writes, 4); assert.equal(persisted.questionId, `${BASE_QUESTION}:next3`); }); test("collect_retry only changes copy, not whether a suffix is tried", async () => { const accounting = fakeAccounting({ set_agentic_rectification_conversation_focus: (_fn, args) => { if (args.p_question_id === BASE_QUESTION) { throw new Error("agentic_rectification_focus_idempotency_conflict"); } return focusRowFromArgs(args); }, }); const persisted = await persistServerOwnedFocus({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, activeFocus: null, decisionReceipt: null, followup: collectFollowup({ collect_retry: true }), }); assert.equal(persisted.status, "created"); assert.equal(persisted.questionId, `${BASE_QUESTION}:next`); assert.equal(persisted.prompt, USER_COLLECT_QUESTION_RETRY.relationship); }); test("probe question ids do not receive collect suffixes", () => { const followup: MethodFollowup = { method_id: "dasha_events", intent: "distinguish_candidates", ask_theme: "dated_event", domain: "education", kind_hint: null, user_prompt_hint: "ask", must_not_label: false, choice_frame: null, source: "event_probe", semantic_key: "education:2016", candidate_split_hash: "education:2016", }; const questionId = stableFollowupQuestionId(followup); assert.match(questionId, /^probe:/); assert.equal(questionId.includes(":next"), false); }); test("deliveryNarrationAllowed requires public can_adopt or a selectable range offer", () => { assert.equal(deliveryNarrationAllowed({ canAdopt: true, sessionOutcome: "collect_evidence", nextAction: "ask_fact_collection", selectionAllowed: true, }), false); assert.equal(deliveryNarrationAllowed({ canAdopt: true, sessionOutcome: "adopt_representative", nextAction: "ready_to_adopt", selectionAllowed: true, }), true); assert.equal(deliveryNarrationAllowed({ canAdopt: false, sessionOutcome: "provisional_range", nextAction: "offer_provisional_range", selectionAllowed: true, }), true); assert.equal(deliveryNarrationAllowed({ canAdopt: false, sessionOutcome: "collect_evidence", nextAction: "offer_provisional_range", selectionAllowed: false, }), false); }); test("duplicate_focus on the last collect ask does not emit delivery copy", async () => { const raw = relationshipOpenDossier(); const remaining = exhaustionSpokenCollectFollowup({ evidence: datedEvidence(), declinedTopics: [], }); // 原值: leftover 感情采集 // 新值: 训练门开后 leftover dated collect 为 null // 原因: BUG-648 assert.equal(remaining, null); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => raw, get_agentic_rectification_case_compute: () => computeFixture(), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: { id: "11111111-1111-4111-8111-111111111111", case_id: CASE_ID, question_id: args.p_question_id, intent: args.p_intent, 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-10T00:00:00.000Z", resolved_at: null, }, idempotent: false, }), }); const idle = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); assert.doesNotMatch(idle.hostNarration ?? "", /领域不限|做不了|材料不够/); const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus"); assert.notEqual(setFocus?.args.p_target_domain, "relationship"); assert.notEqual(setFocus?.args.p_target_domain, "finance"); }); test("delivery copy is gated at the three persist sites", () => { const answer = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const decision = readFileSync( new URL("../src/lib/rectification-agentic/core/rectification-decision.ts", import.meta.url), "utf8", ); assert.match(decision, /export function deliveryNarrationAllowed/); assert.match(answer, /deliveryNarrationAllowed\(decision, nextAction\.type\)/); assert.match(answer, /deliveryNarrationAllowed\(decision, decision\.nextAction\)/); assert.doesNotMatch(answer, /this ask is already closed; fall through to adopt/); assert.match(route, /deliveryNarrationAllowed\(decision, decision\.nextAction\)/); });