import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { USER_COLLECT_QUESTION, USER_COLLECT_QUESTION_RETRY } from "../src/lib/rectification-agentic/user-copy.ts"; import { persistNextInterviewIfIdle, isOrphanOtherCollectFocus, } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { persistableFocusDomain, parseCollectFocusQuestionId, stableFollowupQuestionId, } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { buildMethodFollowupPlan, spokenFollowupForUser, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CASE_ID, FOCUS_ID, TURN_ID, USER_ID, activeFocusFixture, candidateSnapshotFixture, computeFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; type ExecutableTool = { execute(input: unknown): Promise; }; const BANNED_OTHER_COLLECT = "也可以再" + "说一件"; function dated( domain: string, year: string, extra: { eventKind?: string } = {}, ) { const occupation = extra.eventKind === "occupation_note"; return { status: "confirmed" as const, domain, datePrecision: occupation ? "unknown" as const : "month" as const, occurredFrom: occupation ? null : `${year}-03-01`, occurredTo: null, eventKind: extra.eventKind, }; } const SEVEN_DATED = [ dated("education", "2016"), dated("career", "2020"), dated("relationship", "2018"), dated("finance", "2024"), dated("relocation", "2022"), dated("health_pressure", "2021"), ]; const FAMILY_DECLINED = [{ target_domain: "family", status: "declined" }]; const OCCUPATION_FOCUS = { id: FOCUS_ID, questionId: "collect:occupation:collect_method_evidence", intent: "collect_method_evidence", targetDomain: "other", targetKind: "occupation_note", expectedAnswerSchema: { collect: true, prompt: USER_COLLECT_QUESTION.occupation, }, }; function rpcEvidence(rows: readonly ReturnType[]) { return rows.map((item, index) => ({ id: `e-${item.domain}-${index}`, source_turn_id: TURN_ID, subject: "self", event_kind: item.eventKind ?? item.domain, 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-08T00:00:00.000Z", })); } test("occupation continuation keeps collect:occupation even when target_domain is other", () => { assert.equal(persistableFocusDomain("occupation"), "other"); assert.deepEqual(parseCollectFocusQuestionId("collect:occupation:collect_method_evidence"), { domain: "occupation", }); const plan = buildMethodFollowupPlan({ evidence: SEVEN_DATED, declinedTopics: FAMILY_DECLINED, activeFocus: OCCUPATION_FOCUS, sessionOutcome: "collect_evidence", contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] }, }); const next = plan.next_followup; assert.ok(next); assert.equal(next?.domain, "occupation"); assert.equal(spokenFollowupForUser(next), USER_COLLECT_QUESTION.occupation); assert.equal(stableFollowupQuestionId(next!), OCCUPATION_FOCUS.questionId); }); test("set-focus does not persist collect:other after two invalid spoken prompts", async () => { const writes: string[] = []; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ evidence: [], evidenceCount: 0, 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-08T00:00:00.000Z", }, }), get_agentic_rectification_case_compute: () => computeFixture(), set_agentic_rectification_conversation_focus: (_fn, args) => { writes.push(String(args.p_question_id ?? "")); throw new Error("must not persist collect:other fallback"); }, }); const execute = (createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, })["rectification-set-focus"] as unknown as ExecutableTool>).execute; const invalid = { caseId: CASE_ID, questionId: "collect:other:collect_method_evidence", intent: "collect_method_evidence", spokenPrompt: "请选一个选项继续问经历吧", targetDomain: "career", }; const first = await execute(invalid); const second = await execute(invalid); assert.equal((first as { error?: string }).error, "invalid_spoken_prompt"); assert.equal((second as { error?: string }).error, "invalid_spoken_prompt"); assert.equal(writes.some((id) => id.startsWith("collect:other:")), false); assert.equal(writes.length, 0); }); test("second set-focus in the same turn is idempotent and keeps the occupation focus", async () => { const store: { focus: ReturnType | null } = { focus: activeFocusFixture({ questionId: OCCUPATION_FOCUS.questionId, intent: "collect_method_evidence", targetDomain: "other", targetKind: "occupation_note", expectedAnswerSchema: OCCUPATION_FOCUS.expectedAnswerSchema, askedTurnId: TURN_ID, }), }; const writes: string[] = []; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ evidence: rpcEvidence(SEVEN_DATED), evidenceCount: SEVEN_DATED.length, conversationSummary: { confirmed_evidence_summary: [], pending_revisions: [], active_focus: store.focus, declined_skipped_topics: FAMILY_DECLINED, candidate_divergence_summary: null, missing_evidence_categories: [], last_result_policy: null, summary_version: 1, updated_at: "2026-09-08T00:00:00.000Z", }, }), get_agentic_rectification_case_compute: () => computeFixture(), set_agentic_rectification_conversation_focus: (_fn, args) => { writes.push(String(args.p_question_id ?? "")); store.focus = activeFocusFixture({ questionId: String(args.p_question_id), intent: String(args.p_intent), targetDomain: typeof args.p_target_domain === "string" ? args.p_target_domain : "other", targetKind: "occupation_note", expectedAnswerSchema: (args.p_expected_answer_schema as Record) ?? {}, askedTurnId: typeof args.p_asked_turn_id === "string" ? args.p_asked_turn_id : TURN_ID, }); return { focus: store.focus, idempotent: writes.length > 1, }; }, }); const execute = (createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, })["rectification-set-focus"] as unknown as ExecutableTool>).execute; const first = await execute({ caseId: CASE_ID, questionId: OCCUPATION_FOCUS.questionId, intent: "collect_method_evidence", spokenPrompt: USER_COLLECT_QUESTION.occupation, targetDomain: "occupation", }) as Record; const second = await execute({ caseId: CASE_ID, questionId: "collect:other:collect_method_evidence", intent: "collect_method_evidence", spokenPrompt: "请选一个选项继续问经历吧", targetDomain: "career", }) as Record; assert.equal(first.focus_id, FOCUS_ID); assert.equal(second.idempotent, true); assert.equal(second.focus_id, first.focus_id); assert.equal(second.question_id, OCCUPATION_FOCUS.questionId); assert.equal(writes.some((id) => id.startsWith("collect:other:")), false); }); test("orphan collect:other focus is skipped so idle persist can deliver", async () => { const covered = [ ...SEVEN_DATED, dated("occupation", "2020", { eventKind: "occupation_note" }), ]; const rpcRows = rpcEvidence(covered); const fingerprint = evidenceLedgerFingerprint(rpcRows.map((item) => ({ id: item.id, sourceTurnId: item.source_turn_id, subject: item.subject, eventKind: item.event_kind, domain: item.domain, occurredFrom: item.occurred_from, occurredTo: item.occurred_to, datePrecision: item.date_precision, summary: item.summary, status: item.status, supersedesEvidenceId: item.supersedes_evidence_id, createdAt: item.created_at, })) as never); let skipped = false; const base = dossierFixture({ evidence: rpcRows, evidenceCount: covered.length, latestResult: candidateSnapshotFixture({ selectionAllowed: true, representativeTime: "04:51", evidenceLedgerFingerprint: fingerprint, decisionReceipt: { accept_allowed: true, acceptance_allowed: true, propose_allowed: true, selection_allowed: true, confirmation_allowed: false, }, }), conversationSummary: { confirmed_evidence_summary: [], pending_revisions: [], active_focus: activeFocusFixture({ questionId: "collect:other:collect_method_evidence", intent: "collect_method_evidence", targetDomain: "other", expectedAnswerSchema: { collect: true, prompt: "placeholder" }, }), declined_skipped_topics: FAMILY_DECLINED, candidate_divergence_summary: null, missing_evidence_categories: [], last_result_policy: null, summary_version: 1, updated_at: "2026-09-08T00:00:00.000Z", }, }); assert.equal(isOrphanOtherCollectFocus({ questionId: "collect:other:collect_method_evidence", }, covered), true); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => skipped ? { ...base, conversation_summary: { ...base.conversation_summary, active_focus: null, }, } : base, get_agentic_rectification_case_compute: () => computeFixture(), resolve_agentic_rectification_conversation_focus: (_fn, args) => { skipped = true; return { focus_id: args.p_focus_id, status: "skipped", evidence_id: null, idempotent: false, }; }, set_agentic_rectification_conversation_focus: (_fn, args) => { throw new Error(`must not persist ${String(args.p_question_id)}`); }, }); const idle = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); assert.equal(skipped, true); assert.equal(idle.terminalNote, true); assert.ok(idle.hostNarration); assert.equal((idle.hostNarration ?? "").includes(BANNED_OTHER_COLLECT), false); }); test("agent-run writes the exhaustion gate when idle persist returns terminalNote", () => { const agent = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); assert.match(agent, /idle\.terminalNote && idle\.hostNarration/); assert.match(agent, /persistExhaustionGateTurn/); assert.match(chat, /canOfferCards = canShowRectificationSelectionCards/); }); test("user-copy and rectification sources no longer contain the other-collect fallback", () => { const files = [ "../src/lib/rectification-agentic/user-copy.ts", "../src/lib/rectification-agentic/v9/method-followup.ts", "../src/lib/rectification-agentic/v9/server-focus.ts", "../src/lib/rectification-agentic/v9/answer-choice.ts", "../src/mastra/rectification-v9-tools.ts", ]; for (const relative of files) { const source = readFileSync(new URL(relative, import.meta.url), "utf8"); assert.equal(source.includes(BANNED_OTHER_COLLECT), false, relative); } assert.equal(USER_COLLECT_QUESTION.other, undefined); assert.equal(USER_COLLECT_QUESTION_RETRY.other, undefined); });