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 { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.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 { CASE_ID, EVIDENCE_ID, FOCUS_ID, TURN_ID, USER_ID, activeFocusFixture, candidateSnapshotFixture, conversationSummaryFixture, dossierFixture, fakeAccounting, } 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.14 for the spoken-collect visibility fix", () => { assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.14"); }); test("cases current_question drives the unified question slot", () => { const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); assert.match(chat, /current_question\?: unknown/); assert.match(chat, /setCurrentQuestion\(nextQuestion\)/); assert.match(chat, /className="rectification-question-slot"/); assert.match(chat, /currentQuestion\?\.kind === "choice"/); assert.match(chat, / { 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"); assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/); assert.match(chat, /const collectSpokenPrompt =/); assert.match(chat, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/); assert.match(chat, / { const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); assert.match(chat, /const showMissingQuestion = Boolean\([\s\S]*caseSnapshotLoaded[\s\S]*resumableCase[\s\S]*currentQuestion === null/); assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/); assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/); assert.match(chat, /readonly && \(/); assert.doesNotMatch(chat, /showMissingQuestion[\s\S]*caseStatus.*TERMINAL/); }); 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 = 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, /hostNarration: open\.prompt/); 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 does not let the model write a persisted question stem", () => { 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, /有持久化 current_question 时/); assert.match(prompt, /题干与选项完全由结构化槽位和 UI 承担/); assert.match(prompt, /collect_spoken[\s\S]*不输出输入提示/); assert.match(prompt, /没有 current_question 时也不要自拟区分题/); assert.match(prompt, /选择卡/); assert.doesNotMatch(prompt, /必须由你用自己的话在正文里问出来/); assert.doesNotMatch(prompt, /界面提示条/); }); const COLLECT_QUESTION_ID = "collect:relationship:collect_method_evidence"; function collectFocus() { return activeFocusFixture({ intent: "collect_method_evidence", targetDomain: "relationship", questionId: COLLECT_QUESTION_ID, expectedAnswerSchema: { prompt: RELATIONSHIP_PROMPT, collect: true, }, }); } test("agent route keeps question ownership in the server Case projection", () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const turnExit = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-exit.ts", import.meta.url), "utf8"); const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn")); assert.match(afterRun, /await finalizeSuccessfulTurnExit/); assert.match(turnExit, /persistNextInterviewIfIdle/); assert.doesNotMatch(afterRun, /persistCollectSpokenAssistantIfNew|persistEmptyCollectSpokenAssistant/); assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/); const successExit = afterRun.slice(afterRun.indexOf("} else {"), afterRun.indexOf("send({ type: \"done\"")); assert.match(successExit, /await finalizeSuccessfulTurnExit/); assert.doesNotMatch(successExit, /send\(\{\s*type:\s*"error"/); const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); assert.doesNotMatch(agentRun, /collectSpokenPromptForNewFocus|composeCollectSpokenAssistantText/); });