import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { parseRectificationChoiceCard } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { isRenderableChoiceOpenQuestion, openQuestionFromPersistedFocus, persistServerOwnedFocus, stableFollowupQuestionId, } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { emptyAnswerCollectSpokenFallback, projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { persistEmptyCollectSpokenAssistant } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { spokenCollectFallbackFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts"; import { CASE_ID, FOCUS_ID, TURN_ID, USER_ID, activeFocusFixture, candidateSnapshotFixture, conversationSummaryFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; import { parseV9CaseDossier as parseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; const RELATIONSHIP_PROMPT = "还记得别的带年份的感情变化吗?比如开始认真交往、分手或结婚。"; const COLLECT_GET_QUESTION = { question_id: "active_focus:active_focus", kind: "collect_spoken", intent: "collect_method_evidence", domain: "relationship", prompt: RELATIONSHIP_PROMPT, }; function collectFollowup(overrides: Partial = {}): MethodFollowup { return { method_id: "active_focus", intent: "collect_method_evidence", ask_theme: "active_focus", domain: "relationship", kind_hint: null, user_prompt_hint: "collect", must_not_label: false, choice_frame: null, source: "active_focus", ...overrides, }; } function collectPersistResult(overrides: { questionId?: string; prompt?: string; schema?: Record; domain?: string | null; } = {}) { const questionId = overrides.questionId ?? "collect:relationship:collect_method_evidence"; const prompt = overrides.prompt ?? RELATIONSHIP_PROMPT; return { status: "created" as const, questionId, prompt, focus: { id: FOCUS_ID, caseId: CASE_ID, questionId, intent: "collect_method_evidence", targetEvidenceId: null, targetDomain: overrides.domain ?? "relationship", targetKind: null, expectedAnswerSchema: overrides.schema ?? { prompt, collect: true, }, status: "active" as const, askedAt: "2026-08-30T00:00:00.000Z", resolvedAt: null, }, }; } test("skill version stays 10.0.13 for the spoken-collect visibility fix", () => { assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13"); }); test("GET collect_spoken is not rendered as a standalone chat block", () => { const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const choiceCard = readFileSync(new URL("../src/lib/rectification-agentic/v9/choice-card.ts", import.meta.url), "utf8"); assert.doesNotMatch(chat, /parseRectificationSpokenCollect/); assert.doesNotMatch(chat, /showLiveSpokenCollect/); assert.doesNotMatch(chat, /spokenCollect/); assert.doesNotMatch(chat, /liveSpoken/); assert.doesNotMatch(chat, /rectification-spoken-collect-prompt/); assert.doesNotMatch(chat, /aria-label="口述采集题"/); assert.doesNotMatch(styles, /rectification-spoken-collect-prompt/); assert.doesNotMatch(choiceCard, /parseRectificationSpokenCollect|RectificationSpokenCollect/); assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/); assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null); }); test("choice_card still renders through the existing choice-card branch", () => { const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); const render = chat.slice( chat.indexOf("{messages.map((message) => {"), chat.indexOf("{savedTime &&"), ); assert.match(render, /showLiveChoiceCard/); assert.match(render, / { const collect = { kind: "collect_spoken" as const, prompt: RELATIONSHIP_PROMPT, }; assert.equal(emptyAnswerCollectSpokenFallback("", collect), RELATIONSHIP_PROMPT); assert.equal(emptyAnswerCollectSpokenFallback(" ", collect), RELATIONSHIP_PROMPT); assert.equal(emptyAnswerCollectSpokenFallback("记下了。", collect), null); assert.equal(emptyAnswerCollectSpokenFallback("", { kind: "choice", prompt: RELATIONSHIP_PROMPT, }), null); assert.equal(emptyAnswerCollectSpokenFallback("", null), null); assert.equal(emptyAnswerCollectSpokenFallback("", { kind: "collect_spoken", prompt: " ", }), null); }); test("openQuestionFromPersistedFocus returns collect_spoken without making collect choice-ready", () => { const collect = openQuestionFromPersistedFocus(collectPersistResult()); assert.equal(collect?.kind, "collect_spoken"); assert.equal(collect?.prompt, RELATIONSHIP_PROMPT); assert.equal(collect?.unrenderable, undefined); assert.equal(isRenderableChoiceOpenQuestion(collect), false); const choiceFocus = { status: "created" as const, questionId: "probe:education.2016", prompt: "2016 年前后 · 升学结果或学习环境出现明显变化", focus: { id: FOCUS_ID, caseId: CASE_ID, questionId: "probe:education.2016", intent: "distinguish_candidates", targetEvidenceId: null, targetDomain: "education", targetKind: null, expectedAnswerSchema: { choice: { prompt: "2016 年前后 · 升学结果或学习环境出现明显变化", option_a: "明确发生且时间吻合", option_b: "发生过但程度较弱", option_c: "明确没有发生", option_d: "这段记不清楚", options: [ { key: "A", label: "明确发生且时间吻合", answer_class: "yes" }, { key: "B", label: "发生过但程度较弱", answer_class: "weak_yes" }, { key: "C", label: "明确没有发生", answer_class: "no" }, { key: "D", label: "这段记不清楚", answer_class: "unsure" }, ], }, }, status: "active" as const, askedAt: "2026-08-30T00:00:00.000Z", resolvedAt: null, }, }; const choice = openQuestionFromPersistedFocus(choiceFocus); assert.equal(choice?.kind, "choice"); assert.equal(choice?.prompt, choiceFocus.prompt); assert.notEqual(choice?.unrenderable, true); assert.equal(isRenderableChoiceOpenQuestion(choice), true); const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); const nextInterview = answerChoice.slice( answerChoice.indexOf("export async function persistNextInterviewAfterChoice"), answerChoice.indexOf("async function persistFocusAfterChoice"), ); assert.match(nextInterview, /isRenderableChoiceOpenQuestion/); assert.match(nextInterview, /接下来请点选下面这一问/); assert.ok(nextInterview.indexOf("isRenderableChoiceOpenQuestion") < nextInterview.indexOf("接下来请点选下面这一问")); const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); assert.match(route, /isRenderableChoiceOpenQuestion/); const fastPath = route.slice( route.indexOf('if (action === "message")'), route.indexOf("const requestTime"), ); assert.match(fastPath, /isRenderableChoiceOpenQuestion\(open\)/); }); test("collect focus keeps current_probe and inference.next_probe null; choice focus does not", () => { const collectDossier = parseDossier(dossierFixture({ latestResult: candidateSnapshotFixture({ decisionReceipt: { inference_state: { next_probe: { id: "should-hide" } } }, }), conversationSummary: conversationSummaryFixture({ activeFocus: activeFocusFixture({ intent: "collect_method_evidence", targetDomain: "relationship", expectedAnswerSchema: { prompt: RELATIONSHIP_PROMPT, collect: true, }, }), }), })); assert.ok(collectDossier); const collectProjection = projectTurnDecision(collectDossier); assert.equal((collectProjection.current_question as { kind?: string } | null)?.kind, "collect_spoken"); assert.equal(collectProjection.current_probe ?? null, null); assert.equal((collectProjection.inference as { next_probe?: unknown } | null)?.next_probe ?? null, null); const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8"); const projection = tools.slice( tools.indexOf("function agentVisibleLatestProjection"), tools.indexOf("question_contract: {"), ); assert.match(projection, /kind:\s*extras\.openQuestion\.kind|kind: extras\.openQuestion\.kind/); assert.match(projection, /kind === "choice"/); assert.match(projection, /next_probe: null/); assert.match(projection, /current_probe: null/); }); test("active_focus collect followups get a domain-stable questionId, not active_focus:active_focus", async () => { const followup = collectFollowup(); assert.equal(stableFollowupQuestionId(followup).includes("active_focus:active_focus"), false); assert.match(stableFollowupQuestionId(followup), /^collect:relationship:collect_method_evidence$/); const degraded = spokenCollectFallbackFollowup({ ...followup, method_id: "dasha_events", ask_theme: "dated_event", domain: "education", semantic_key: "education:2016", probe_year: 2016, }); assert.equal(stableFollowupQuestionId(degraded).includes("education:2016"), false); assert.match(stableFollowupQuestionId(degraded), /^collect:education:collect_method_evidence$/); const accounting = 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: 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, idempotent: false, }), }); const stale = { id: FOCUS_ID, caseId: CASE_ID, questionId: "active_focus:active_focus", intent: "collect_method_evidence", targetEvidenceId: null, targetDomain: "relationship", targetKind: null, expectedAnswerSchema: { prompt: RELATIONSHIP_PROMPT, collect: true, }, status: "active" as const, askedAt: "2026-08-30T00:00:00.000Z", resolvedAt: null, }; const persisted = await persistServerOwnedFocus({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, activeFocus: stale, decisionReceipt: null, followup, }); assert.equal(persisted.status, "created"); assert.notEqual(persisted.questionId, "active_focus:active_focus"); assert.match(persisted.questionId ?? "", /^collect:relationship:collect_method_evidence$/); assert.equal(persisted.status, "created"); const open = openQuestionFromPersistedFocus(persisted); assert.equal(open?.kind, "collect_spoken"); }); test("agent prompt requires spoken collect questions in the body and forbids 请点选 unless a choice card exists", () => { const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8"); const prompt = agent.slice( agent.indexOf("const agenticRectificationInstructions"), agent.indexOf("export function getRectificationV9Agent"), ); assert.match(prompt, /口述采集题[\s\S]*必须由你用自己的话在正文里问出来/); assert.match(prompt, /请点选/); assert.match(prompt, /选择卡/); assert.doesNotMatch(prompt, /界面提示条/); assert.doesNotMatch(prompt, /不复述题干/); }); const FALLBACK_REQUEST_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; function collectFocusDossier() { return dossierFixture({ conversationSummary: conversationSummaryFixture({ activeFocus: activeFocusFixture({ intent: "collect_method_evidence", targetDomain: "relationship", expectedAnswerSchema: { prompt: RELATIONSHIP_PROMPT, collect: true, }, }), }), }); } test("empty agent body persists the collect_spoken prompt as a plain assistant message", async () => { const accounting = fakeAccounting({ get_agentic_rectification_case_dossier: () => collectFocusDossier(), append_agentic_rectification_turn: (_fn, args) => ({ turn_id: "99999999-9999-4999-8999-999999999999", idempotent: false, assistant_message: args.p_assistant_message, }), }); const filled = await persistEmptyCollectSpokenAssistant({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, requestId: FALLBACK_REQUEST_ID, answerText: " ", }); assert.equal(filled, RELATIONSHIP_PROMPT); const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn"); assert.equal(turn?.args.p_assistant_message, RELATIONSHIP_PROMPT); assert.equal(turn?.args.p_user_message, null); assert.equal(turn?.args.p_status, "completed"); const skipped = await persistEmptyCollectSpokenAssistant({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, requestId: FALLBACK_REQUEST_ID, answerText: "记下了。你长期做什么工作?", }); assert.equal(skipped, null); assert.equal( accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length, 1, ); }); test("agent route emits the collect prompt only when the body is empty", () => { 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.match(afterRun, /persistNextInterviewIfIdle/); assert.match(afterRun, /persistEmptyCollectSpokenAssistant/); assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle")); assert.ok(afterRun.indexOf("persistNextInterviewIfIdle") < afterRun.indexOf("persistEmptyCollectSpokenAssistant")); assert.match(afterRun, /if \(!result\.answerText\.trim\(\)\)/); assert.match(afterRun, /send\(\{ type: "answer\.delta", text: fallback \}\)/); assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/); }); test("empty stream with an open collect_spoken focus uses the focus prompt as the assistant message", async () => { const emitted: Array<{ type: string; text?: string }> = []; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => collectFocusDossier(), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), }); const result = await runV9AgentTurn({ userId: USER_ID, caseId: CASE_ID, sessionId: "22222222-2222-4222-8222-222222222222", requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", action: "evidence", message: "2016年9月离开家去北京工作", modelName: "gpt-4o-mini", accounting: accounting.client, billing: { reserve: async () => ({ success: true, status: 200 }), complete: async () => true, release: async () => true, }, emit: (event) => { emitted.push(event); }, buildAgent: async () => ({ stream: async () => ({ fullStream: (async function* () { yield { type: "start" }; yield { type: "tool-call", payload: { toolName: "skill", args: { name: "jyotish-birth-time-rectification" } } }; yield { type: "tool-result", payload: { toolName: "skill" } }; yield { type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } }; yield { type: "tool-result", payload: { toolName: "rectification-read-case" } }; yield { type: "finish" }; })(), totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 4 }), }), getSkill: async () => ({ name: "jyotish-birth-time-rectification", instructions: "skill" }), }) as never, }); assert.equal(result.ok, true); assert.equal(result.answerText, RELATIONSHIP_PROMPT); assert.deepEqual( emitted.filter((event) => event.type === "answer.delta"), [{ type: "answer.delta", text: RELATIONSHIP_PROMPT }], ); });