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, collectSpokenPromptForNewFocus, composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { persistCollectSpokenAssistantIfNew, persistEmptyCollectSpokenAssistant } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { spokenCollectFallbackFollowup, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { isSafeCollectSpokenPrompt } from "../src/lib/rectification-agentic/v9/spoken-answer.ts"; import { openQuestionPromptFromToolResult } from "../src/lib/rectification-agentic/v9/turn-narration.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, EVIDENCE_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 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, /有持久化当前问题时/); assert.match(prompt, /题干一律不由你写/); assert.match(prompt, /collect_spoken[\s\S]*服务器接在正文之后/); assert.match(prompt, /没有持久化当前问题时,用自然语言问一件带大概年份的经历/); assert.match(prompt, /请点选/); assert.match(prompt, /选择卡/); assert.doesNotMatch(prompt, /必须由你用自己的话在正文里问出来/); assert.doesNotMatch(prompt, /界面提示条/); }); const FALLBACK_REQUEST_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; const RECORDED_BODY = "记下了:2022年搬家。"; 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, }, }); } function collectFocusDossier() { return dossierFixture({ conversationSummary: conversationSummaryFixture({ activeFocus: collectFocus(), }), }); } function relocation2022Dossier(activeFocus: ReturnType | null) { return dossierFixture({ evidence: [ { id: EVIDENCE_ID, source_turn_id: TURN_ID, subject: "self", event_kind: "relocation", domain: "relocation", occurred_from: "2022-01-01", occurred_to: null, date_precision: "year", summary: "2022年搬家", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-08-30T00:00:00.000Z", }, ], conversationSummary: conversationSummaryFixture({ activeFocus, }), }); } function choiceFocusDossier() { return dossierFixture({ conversationSummary: conversationSummaryFixture({ activeFocus: activeFocusFixture({ intent: "distinguish_candidates", targetDomain: "education", 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" }, ], }, }, }), }), }); } test("new collect_spoken focus is visible from the focus lifecycle, not from body text", () => { const focus = { id: FOCUS_ID, questionId: COLLECT_QUESTION_ID, intent: "collect_method_evidence", targetDomain: "relationship", expectedAnswerSchema: { prompt: RELATIONSHIP_PROMPT, collect: true, }, }; assert.equal( collectSpokenPromptForNewFocus({ previousFocusId: null, focus }), RELATIONSHIP_PROMPT, ); assert.equal( collectSpokenPromptForNewFocus({ previousFocusId: FOCUS_ID, focus }), null, ); assert.equal( collectSpokenPromptForNewFocus({ previousFocusId: null, focus: { id: FOCUS_ID, intent: "distinguish_candidates", expectedAnswerSchema: { choice: { prompt: RELATIONSHIP_PROMPT, option_a: "A", option_b: "B", option_c: "C", option_d: "D" }, }, }, }), null, ); assert.equal(composeCollectSpokenAssistantText(" ", RELATIONSHIP_PROMPT).composed, RELATIONSHIP_PROMPT); assert.equal( composeCollectSpokenAssistantText(RECORDED_BODY, RELATIONSHIP_PROMPT).composed, `${RECORDED_BODY}\n\n${RELATIONSHIP_PROMPT}`, ); assert.equal( composeCollectSpokenAssistantText(RECORDED_BODY, RELATIONSHIP_PROMPT).delta, `\n\n${RELATIONSHIP_PROMPT}`, ); assert.equal(spokenFollowupForUser(collectFollowup()), RELATIONSHIP_PROMPT); assert.equal(isSafeCollectSpokenPrompt(RELATIONSHIP_PROMPT), true); assert.equal(isSafeCollectSpokenPrompt("接下来请点选下面这一问。"), false); assert.equal(isSafeCollectSpokenPrompt("看下面这一问"), false); assert.equal(isSafeCollectSpokenPrompt("请继续 focusId 提问"), false); assert.equal(openQuestionPromptFromToolResult({ type: "tool-result", payload: { result: { open_question: { kind: "collect_spoken", prompt: RELATIONSHIP_PROMPT } }, }, }), null); }); 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: " ", previousFocusId: null, }); 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 appended = await persistCollectSpokenAssistantIfNew({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, requestId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", answerText: RECORDED_BODY, previousFocusId: null, }); assert.equal(appended, `\n\n${RELATIONSHIP_PROMPT}`); const turns = accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn"); assert.equal(turns.length, 2); assert.equal(turns[1]?.args.p_assistant_message, RELATIONSHIP_PROMPT); assert.match(String(turns[1]?.args.p_assistant_message), /还记得别的带年份的感情变化吗/); assert.doesNotMatch(String(turns[1]?.args.p_assistant_message), /请点选/); assert.doesNotMatch(String(turns[1]?.args.p_assistant_message), /focusId|current_question|choice_frame/); }); test("the same open collect focus is not appended again on the next turn", async () => { const accounting = fakeAccounting({ get_agentic_rectification_case_dossier: () => collectFocusDossier(), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), }); const skipped = await persistCollectSpokenAssistantIfNew({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, requestId: FALLBACK_REQUEST_ID, answerText: RECORDED_BODY, previousFocusId: FOCUS_ID, }); assert.equal(skipped, null); assert.equal( accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length, 0, ); }); test("choice focus never copies the stem into the assistant body", async () => { const accounting = fakeAccounting({ get_agentic_rectification_case_dossier: () => choiceFocusDossier(), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), }); const skipped = await persistCollectSpokenAssistantIfNew({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, requestId: FALLBACK_REQUEST_ID, answerText: "记下了。", previousFocusId: null, }); assert.equal(skipped, null); assert.equal( accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length, 0, ); }); test("in-turn persistPlanFocus and idle persist share one collect stem and emit once", async () => { const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); const idle = answerChoice.slice( answerChoice.indexOf("export async function persistNextInterviewIfIdle"), answerChoice.indexOf("export async function persistCollectSpokenAssistantIfNew"), ); assert.match(idle, /if \(dossier\.conversationSummary\.activeFocus\)/); assert.match(idle, /hostNarration:\s*nextInterview\.hostNarration/); assert.match(answerChoice, /spokenFollowupForUser\(followup\)/); const accounting = fakeAccounting({ get_agentic_rectification_case_dossier: () => collectFocusDossier(), append_agentic_rectification_turn: (_fn, args) => ({ turn_id: TURN_ID, assistant_message: args.p_assistant_message, }), }); const first = await persistCollectSpokenAssistantIfNew({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, requestId: FALLBACK_REQUEST_ID, answerText: RECORDED_BODY, previousFocusId: null, hostNarration: spokenFollowupForUser(collectFollowup()), }); assert.equal(first, `\n\n${RELATIONSHIP_PROMPT}`); const second = await persistCollectSpokenAssistantIfNew({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, requestId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", answerText: RECORDED_BODY, previousFocusId: null, alreadyEmitted: true, hostNarration: spokenFollowupForUser(collectFollowup()), }); assert.equal(second, null); assert.equal( accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length, 1, ); }); test("agent route appends a new collect_spoken stem from focus lifecycle, not body text", () => { 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, /persistCollectSpokenAssistantIfNew/); assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle")); assert.ok(afterRun.indexOf("persistNextInterviewIfIdle") < afterRun.indexOf("persistCollectSpokenAssistantIfNew")); assert.match(afterRun, /previousFocusId:\s*result\.previousFocusId/); assert.match(afterRun, /alreadyEmitted:\s*result\.collectSpokenEmitted/); assert.match(afterRun, /hostNarration:\s*idleHostNarration/); assert.match(afterRun, /send\(\{ type: "answer\.delta", text: fallback \}\)/); assert.doesNotMatch(afterRun, /if \(!result\.answerText\.trim\(\)\)/); assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/); const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); assert.match(agentRun, /collectSpokenPromptForNewFocus/); assert.match(agentRun, /composeCollectSpokenAssistantText/); assert.doesNotMatch(agentRun, /answerText\.(?:includes|match|search)\(/); }); async function runCollectTurn(input: { firstDossier: unknown; laterDossier: unknown; spoken?: string; }) { const emitted: Array<{ type: string; text?: string }> = []; let loads = 0; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => { loads += 1; return loads === 1 ? input.firstDossier : input.laterDossier; }, 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: "2022年搬家", 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" } }; if (input.spoken) { yield { type: "text-delta", payload: { text: input.spoken } }; } yield { type: "finish" }; })(), totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 4 }), }), getSkill: async () => ({ name: "jyotish-birth-time-rectification", instructions: "skill" }), }) as never, }); return { result, emitted }; } test("free-text 2022 relocation creates a new collect_spoken stem in the assistant body", async () => { const { result, emitted } = await runCollectTurn({ firstDossier: relocation2022Dossier(null), laterDossier: relocation2022Dossier(collectFocus()), spoken: RECORDED_BODY, }); assert.equal(result.ok, true); assert.equal(result.previousFocusId, null); assert.equal(result.collectSpokenEmitted, true); assert.equal(result.answerText, `${RECORDED_BODY}\n\n${RELATIONSHIP_PROMPT}`); assert.match(result.answerText, /还记得别的带年份的感情变化吗/); const deltas = emitted.filter((event) => event.type === "answer.delta").map((event) => event.text); assert.ok(deltas.includes(RECORDED_BODY)); assert.ok(deltas.includes(`\n\n${RELATIONSHIP_PROMPT}`)); assert.doesNotMatch(result.answerText, /请点选/); assert.doesNotMatch(result.answerText, /focusId|current_question|choice_frame/); }); test("empty stream with a newly created collect_spoken focus uses the focus prompt as the assistant message", async () => { const { result, emitted } = await runCollectTurn({ firstDossier: relocation2022Dossier(null), laterDossier: relocation2022Dossier(collectFocus()), }); assert.equal(result.ok, true); assert.equal(result.answerText, RELATIONSHIP_PROMPT); assert.equal(result.collectSpokenEmitted, true); assert.deepEqual( emitted.filter((event) => event.type === "answer.delta"), [{ type: "answer.delta", text: RELATIONSHIP_PROMPT }], ); }); test("an already open collect_spoken focus is not appended again after a later turn", async () => { const { result } = await runCollectTurn({ firstDossier: collectFocusDossier(), laterDossier: collectFocusDossier(), spoken: RECORDED_BODY, }); assert.equal(result.ok, true); assert.equal(result.previousFocusId, FOCUS_ID); assert.equal(result.collectSpokenEmitted, false); assert.equal(result.answerText, RECORDED_BODY); assert.doesNotMatch(result.answerText, /还记得别的带年份的感情变化吗/); }); test("choice focus leaves the stem on the card and out of the body", async () => { const { result } = await runCollectTurn({ firstDossier: choiceFocusDossier(), laterDossier: choiceFocusDossier(), spoken: RECORDED_BODY, }); assert.equal(result.ok, true); assert.equal(result.collectSpokenEmitted, false); assert.doesNotMatch(result.answerText, /升学结果或学习环境/); assert.doesNotMatch(result.answerText, /还记得别的带年份的感情变化吗/); assert.doesNotMatch(result.answerText, /请点选/); });