import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; import test from "node:test"; import { attachQuestionsToTurns, copyTextForMessage, parseTurnQuestion, } from "../src/lib/rectification-agentic/v9/turn-question.ts"; import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { RECTIFICATION_AGENT_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts"; import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; import { CASE_ID, FOCUS_ID, TURN_ID, USER_ID, activeFocusFixture, conversationSummaryFixture, dossierFixture, fakeAccounting, } from "./rectification-v9-test-support.ts"; const OTHER_TURN = "44444444-4444-4444-8444-444444444444"; const EXISTENCE_OPTIONS = [ { key: "A" as const, label: "明确发生且时间吻合", answer_class: "yes" }, { key: "B" as const, label: "发生过但时间偏了", answer_class: "weak_yes" }, { key: "C" as const, label: "没有这回事", answer_class: "no" }, { key: "D" as const, label: "这段记不清楚", answer_class: "unsure" }, ]; function choiceSchema(prompt: string) { return { prompt, probe_id: "probe:test", choice: { prompt, option_a: EXISTENCE_OPTIONS[0]!.label, option_b: EXISTENCE_OPTIONS[1]!.label, option_c: EXISTENCE_OPTIONS[2]!.label, option_d: EXISTENCE_OPTIONS[3]!.label, options: EXISTENCE_OPTIONS, }, }; } const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); const row = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8"); const casesRoute = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8"); const migration = readFileSync( new URL("../supabase/migrations/20260902010000_rectification_question_on_turn.sql", import.meta.url), "utf8", ); test("question-on-turn migration adds asked_turn_id and answer_option", () => { assert.match(migration, /add column if not exists asked_turn_id uuid/); assert.match(migration, /add column if not exists answer_option text/); assert.match(migration, /p_asked_turn_id uuid default null/); assert.match(migration, /p_answer_option text default null/); assert.match(migration, /list_agentic_rectification_conversation_focuses/); assert.equal( existsSync(new URL("../db/migrations/20260902010000_rectification_question_on_turn.sql", import.meta.url)), false, ); }); test("GET turns attach question by asked_turn_id and never by timestamps", () => { assert.match(casesRoute, /attachQuestionsToTurns\(dossier\.turns, listed\.focuses/); assert.match(casesRoute, /question: turn\.question/); assert.match(casesRoute, /question_source: questionSourceFromFocusList\(listed\)/); const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8"); assert.match(attach, /if \(!focus\.askedTurnId\) continue/); assert.doesNotMatch(attach, /askedAt|createdAt/); }); test("attachQuestionsToTurns joins only the matching asked turn", () => { const prompt = GENERIC_COLLECT_QUESTION; const turns = [ { id: TURN_ID, role: "assistant" as const, text: "你好。", status: "completed" }, { id: OTHER_TURN, role: "assistant" as const, text: "我们继续。", status: "completed" }, ]; const attached = attachQuestionsToTurns(turns, [{ id: FOCUS_ID, caseId: CASE_ID, questionId: "collect:relationship:collect_method_evidence", intent: "collect_method_evidence", targetEvidenceId: null, targetDomain: "relationship", targetKind: null, expectedAnswerSchema: { prompt, collect: true }, status: "active", askedAt: "2026-09-02T00:00:00.000Z", resolvedAt: null, askedTurnId: TURN_ID, answerOption: null, }]); assert.equal(attached[0]?.question?.focus_id, FOCUS_ID); assert.equal(attached[0]?.question?.prompt, prompt); assert.equal(attached[0]?.question?.kind, "collect_spoken"); assert.equal(attached[1]?.question, null); }); test("copy text is body, blank line, stem, then option lines", () => { const question = parseTurnQuestion({ focus_id: FOCUS_ID, question_id: "d9:relationship:2023", kind: "choice", prompt: "2023 年前后,你有没有一段认真开始或结束的关系?", options: [ { key: "A", label: "明确发生且时间吻合" }, { key: "B", label: "发生过但时间偏了" }, { key: "C", label: "没有这回事" }, { key: "D", label: "记不清" }, ], status: "resolved", answer_option: "A", probe_id: null, }); const copied = copyTextForMessage("2016 年 9 月上大学,记下了。", question); assert.match(copied, /记下了。\n\n2023 年前后/); assert.match(copied, /A\. 明确发生且时间吻合 ✓/); assert.match(copied, /B\. 发生过但时间偏了/); }); test("one assistant article owns the stem and options; the slot class is gone", () => { assert.match(row, /afterAnswer/); assert.match(chat, /afterAnswer=\{afterAnswer\}/); assert.match(chat, /rectification-message-question/); assert.match(chat, /variant="embedded"/); assert.match(chat, /function markQuestionAnswered/); assert.match(chat, /"typed"/); assert.doesNotMatch(chat, /rectification-question-slot/); assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/); assert.match(chat, /role="status"/); // Was: /当前没有可回答的问题,正在等待服务端更新/ and /题目加载失败,请刷新/ — bare copy // that told the reader to wait for the server with nothing happening. The gap is // now a live timeline row with timed refetches, then copy and a reload (BUG-505). assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新/); assert.match(chat, /RECTIFICATION_QUESTION_PREPARING_LABEL/); assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/); assert.match(chat, /question_source/); }); test("agent toolbag includes set-focus so spokenPrompt can run", () => { assert.equal(RECTIFICATION_AGENT_TOOLS.includes("rectification-set-focus"), true); const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8"); assert.match(tools, /"rectification-set-focus",/); assert.doesNotMatch(tools, /"rectification-set-focus": _omitted/); }); test("message-path idle persist links a pre-built choice focus to the current turn", async () => { const prompt = "2023 年前后,你有没有一段认真开始或结束的关系?"; const accounting = fakeAccounting({ get_agentic_rectification_case_dossier: () => dossierFixture({ conversationSummary: conversationSummaryFixture({ activeFocus: activeFocusFixture({ intent: "distinguish_candidates", questionId: "d9:relationship:2023", targetDomain: "relationship", expectedAnswerSchema: choiceSchema(prompt), }), }), }), set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: { ...activeFocusFixture({ intent: "distinguish_candidates", questionId: String(args.p_question_id), askedTurnId: String(args.p_asked_turn_id ?? ""), }), asked_turn_id: args.p_asked_turn_id, }, idempotent: true, }), }); const result = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, askedTurnId: TURN_ID, }); assert.equal(result.persisted, false); const linked = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus"); assert.equal(linked?.args.p_asked_turn_id, TURN_ID); }); test("typed answers keep the previous question on the same message", () => { assert.match(chat, /markQuestionAnswered\(current, pendingFocusId, "typed"\)/); assert.match(chat, /answer_option: selected === "typed" \? null : selected/); assert.match(chat, /status: "resolved"/); }); test("refresh rebuilds answered and live questions from asked_turn_id only", () => { const liveTurn = "55555555-5555-4555-8555-555555555555"; const collectPrompt = GENERIC_COLLECT_QUESTION; const answeredPrompt = "2015 年前后,有没有搬家或换过主要居住地?"; const livePrompt = "2014 年前后,有没有升学、转学或换学习环境?"; const attached = attachQuestionsToTurns( [ { id: TURN_ID, role: "assistant" as const, text: "你好,可以慢慢说。", status: "completed" }, { id: OTHER_TURN, role: "assistant" as const, text: "记下了,并更新了候选比较。", status: "completed" }, { id: liveTurn, role: "assistant" as const, text: "2016 年入学记下了。", status: "completed" }, ], [ { id: FOCUS_ID, caseId: CASE_ID, questionId: "collect:relationship:collect_method_evidence", intent: "collect_method_evidence", targetEvidenceId: null, targetDomain: "relationship", targetKind: null, expectedAnswerSchema: { prompt: collectPrompt, collect: true }, status: "resolved", askedAt: "2026-09-02T00:00:00.000Z", resolvedAt: "2026-09-02T00:01:00.000Z", askedTurnId: TURN_ID, answerOption: null, }, { id: "66666666-6666-4666-8666-666666666666", caseId: CASE_ID, questionId: "relocation:2015", intent: "distinguish_candidates", targetEvidenceId: null, targetDomain: "relocation", targetKind: null, expectedAnswerSchema: choiceSchema(answeredPrompt), status: "resolved", askedAt: "2026-09-02T00:02:00.000Z", resolvedAt: "2026-09-02T00:03:00.000Z", askedTurnId: OTHER_TURN, answerOption: "A", }, { id: "77777777-7777-4777-8777-777777777777", caseId: CASE_ID, questionId: "education:2014", intent: "distinguish_candidates", targetEvidenceId: null, targetDomain: "education", targetKind: null, expectedAnswerSchema: choiceSchema(livePrompt), status: "active", askedAt: "2026-09-02T00:04:00.000Z", resolvedAt: null, askedTurnId: liveTurn, answerOption: null, }, ], ); assert.equal(attached[0]?.question?.kind, "collect_spoken"); assert.equal(attached[0]?.question?.status, "resolved"); assert.equal(attached[1]?.question?.kind, "choice"); assert.equal(attached[1]?.question?.options?.length, 4); assert.equal(attached[1]?.question?.answer_option, "A"); assert.equal(attached[1]?.question?.prompt, answeredPrompt); assert.ok(attached[1]?.question?.prompt.includes("2015")); assert.equal(attached[2]?.question?.kind, "choice"); assert.equal(attached[2]?.question?.options?.length, 4); assert.equal(attached[2]?.question?.prompt, livePrompt); assert.ok(attached[2]?.question?.prompt.includes("2014")); assert.equal(attached[2]?.question?.answer_option, null); assert.equal(attached[2]?.text, "2016 年入学记下了。"); });