/** * 2026-09-26, staging e53052a2, real device (TASK-rectification-opening-plain-20260926): * * BUG-1049 (recurrence of BUG-504): the opening showed no examples. BUG-604 * moved the six examples out of the stem into the body's third sentence; * BUG-648 then rewrote that sentence to begin with the stem's first 12 * characters, and `stripQuestionSentences` deleted any body sentence sharing * that prefix — on the server, on GET, and (since BUG-1045) in the client * merge. The body also spoke jargon (大运、盘面、代表分钟、精确到秒). * BUG-1050: the step receipt spoke internal names (「读取校正记录」「设置对话焦点」) * and listed the second set-focus call again. */ import assert from "node:assert/strict"; import test from "node:test"; import { GENERIC_COLLECT_QUESTION, OPENING_ANSWER_EXAMPLE, OPENING_BODY_JARGON, OPENING_COLLECT_DOMAINS, isAcceptableOpeningBody, openingSpokenBody, } from "../src/lib/rectification-agentic/user-copy.ts"; import { composeCollectSpokenAssistantText, stripQuestionSentences, } from "../src/lib/rectification-agentic/v9/collect-prompt.ts"; import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts"; import { buildMethodFollowupPlan, isOpeningCollectFollowup, spokenFollowupForUser, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { mergeTurnQuestions, type SnapshotTurnMessage } from "../src/lib/rectification-snapshot-messages.ts"; import { RECTIFICATION_ACTIVITY_PROGRESS_LABELS, RECTIFICATION_TOOL_DONE_LABELS, RECTIFICATION_TOOL_PROGRESS_LABELS, rectificationCompletedTrail, } from "../src/lib/rectification-activity-labels.ts"; import { completeActivityTraceStep, emptyActivityTrace, startActivityTraceStep, } from "../src/lib/agent-activity-trace.ts"; import { rectificationTimelineRows } from "../src/lib/rectification-timeline-adapter.ts"; import { timelineSummaryLabel } from "../src/components/consultation-run-timeline.tsx"; import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; import { CASE_ID, TURN_ID, USER_ID, computeFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; const RANGE = ["04:45", "05:15"] as const; const BODY = openingSpokenBody(RANGE); const BODY_SENTENCES = [ "我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。", "做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。", ]; const EXAMPLES = ["上大学", "第一份工作", "搬到别的城市", "谈恋爱或结婚", "家里添丁", "生病住院"]; function occurrences(text: string, needle: string): number { return text.split(needle).length - 1; } function openingFocus(askedTurnId: string | null): ConversationFocus { return { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", caseId: CASE_ID, questionId: "collect:other:collect_method_evidence", intent: "collect_method_evidence", targetEvidenceId: null, targetDomain: "other", targetKind: null, expectedAnswerSchema: { prompt: GENERIC_COLLECT_QUESTION, collect: true }, status: "active", askedAt: "2026-09-26T08:00:00.000Z", resolvedAt: null, askedTurnId, answerOption: null, }; } test("(a) the zero-evidence opening question slot carries 比如 + six examples + 例如 an example answer", () => { const followup = buildMethodFollowupPlan({ evidence: [] }).next_followup; assert.ok(followup); assert.equal(isOpeningCollectFollowup(followup, []), true); const stem = spokenFollowupForUser(followup, []) ?? ""; assert.equal(stem, GENERIC_COLLECT_QUESTION); assert.match(stem, /比如/); assert.match(stem, /例如/); for (const example of EXAMPLES) assert.ok(stem.includes(example), example); assert.deepEqual([...OPENING_COLLECT_DOMAINS], EXAMPLES); assert.ok(stem.includes(`「${OPENING_ANSWER_EXAMPLE}」`)); // The example answer is the only year in the stem and it is fixed copy, not // derived from a birth date; the body stays year-free. assert.deepEqual(stem.match(/(?:19|20)\d{2}/g), ["2015"]); assert.doesNotMatch(BODY, /(?:19|20)\d{2}/); }); test("(a) only the zero-evidence opening uses the example-bearing stem; once an event is dated it is gone", () => { const followup = buildMethodFollowupPlan({ evidence: [] }).next_followup; assert.ok(followup); const dated = [{ status: "confirmed" as const, domain: "career", datePrecision: "month" as const, occurredFrom: "2019-03-01", occurredTo: null, }]; assert.equal(isOpeningCollectFollowup(followup, dated), false); assert.equal(isOpeningCollectFollowup({ ...followup, collect_retry: true }, []), false); assert.notEqual(spokenFollowupForUser({ ...followup, collect_retry: true }, []), GENERIC_COLLECT_QUESTION); }); test("(a) the opening set-focus stores the server stem even when the model rewords spokenPrompt", async () => { const writes: Array> = []; 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-26T00:00:00.000Z", }, }), get_agentic_rectification_case_compute: () => computeFixture(), set_agentic_rectification_conversation_focus: (_fn, args) => { writes.push(args); return { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", case_id: CASE_ID, question_id: args.p_question_id, intent: args.p_intent, target_evidence_id: null, 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-09-26T00:00:00.000Z", resolved_at: null, asked_turn_id: args.p_asked_turn_id, idempotent: false, }; }, }); const tool = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, })["rectification-set-focus"] as unknown as { execute(input: unknown): Promise> }; const result = await tool.execute({ caseId: CASE_ID, questionId: "collect:other:collect_method_evidence", intent: "collect_method_evidence", // A valid rewording without examples — what the model tends to write. spokenPrompt: "先说你最容易想起的一两件,年月大概就行。", }); assert.equal((result as { error?: string }).error, undefined); assert.equal(writes.length, 1); const schema = writes[0]?.p_expected_answer_schema as Record; assert.equal(schema.prompt, GENERIC_COLLECT_QUESTION); }); test("(b) server compose, GET attach and client merge keep both body sentences and show the stem once", () => { // Server: a deterministic reply composes body + stem (BUG-969 ③). const composed = composeCollectSpokenAssistantText(BODY, GENERIC_COLLECT_QUESTION); assert.equal(occurrences(composed, GENERIC_COLLECT_QUESTION), 1); for (const sentence of BODY_SENTENCES) assert.ok(composed.includes(sentence), sentence); // Idempotent. assert.equal(composeCollectSpokenAssistantText(composed, GENERIC_COLLECT_QUESTION), composed); for (const stored of [BODY, composed]) { // GET: the question is attached and the stem leaves the bubble. const [turn] = attachQuestionsToTurns([ { id: "t0", role: "assistant" as const, text: stored, status: "completed" }, ], [openingFocus("t0")]); assert.ok(turn); assert.equal(turn.question?.prompt, GENERIC_COLLECT_QUESTION); assert.equal(turn.text, BODY, "GET keeps the body whole"); const readerSees = `${turn.text}\n${turn.question?.prompt}`; assert.equal(occurrences(readerSees, GENERIC_COLLECT_QUESTION), 1); for (const example of EXAMPLES) assert.equal(occurrences(readerSees, example), 1, example); // Client: the live bubble merged with the snapshot turn reads the same. const live: SnapshotTurnMessage[] = [{ role: "assistant", turnId: "t0", text: stored, renderKey: "live" }]; const [merged] = mergeTurnQuestions(live, [turn]); assert.equal(merged?.text, BODY, "client merge keeps the body whole"); assert.equal(merged?.question?.prompt, GENERIC_COLLECT_QUESTION); } }); test("(b) a body sentence that only shares an opening phrase with the stem is kept (the BUG-1049 deletion)", () => { // Exactly what staging e53052a2 stored for the opening (Skill 10.0.30 copy). const oldStem = "先说你最容易想起的一两件,年月大概就行。"; const oldExamples = "先说你最容易想起的一两件,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁或长辈住院、生病受伤,年月大概就行。"; const oldBody = `眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。${oldExamples}`; assert.ok(stripQuestionSentences(oldBody, oldStem).includes(oldExamples)); // A model body that restates the stem verbatim (either sentence) still loses it. const restated = `${BODY}${GENERIC_COLLECT_QUESTION}`; assert.equal(stripQuestionSentences(restated, GENERIC_COLLECT_QUESTION), BODY); const secondOnly = `${BODY}说个大概年月就行,例如「2015 年夏天换了工作」。`; assert.equal(stripQuestionSentences(secondOnly, GENERIC_COLLECT_QUESTION), BODY); // A near-copy (one or two words reworded) is still a restatement (BUG-585). assert.equal( stripQuestionSentences("记下了。家里如果有结婚、添丁或住院的事,记得大概哪年就行。", "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。"), "记下了。", ); }); test("(c) the opening body is plain: no jargon, no question, no years, no examples list", () => { assert.equal(BODY, BODY_SENTENCES.join("")); for (const word of ["大运", "盘面", "代表分钟", "精确到秒"]) { assert.equal(BODY.includes(word), false, word); assert.ok((OPENING_BODY_JARGON as readonly string[]).includes(word), word); } assert.doesNotMatch(BODY, /[??]/); assert.equal(isAcceptableOpeningBody(BODY, RANGE), true); assert.equal(openingSpokenBody(null), "我们来把你的出生时间缩小到更准的范围。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。"); // The old fallback body is no longer acceptable from the model. assert.equal(isAcceptableOpeningBody("眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。", RANGE), false); assert.equal(isAcceptableOpeningBody("你好,我是生时校正助手。", RANGE), false); assert.equal(isAcceptableOpeningBody(`${BODY}比如上大学、第一份工作、搬到别的城市。`, RANGE), false); assert.equal(isAcceptableOpeningBody(openingSpokenBody(["04:50", "05:10"]), RANGE), false, "wrong window"); }); test("(d) repeated tool calls show one step each; the count is the shown steps; no internal names", () => { let trace = emptyActivityTrace(); for (const [index, tool] of ([ "rectification-read-case", "rectification-set-focus", "rectification-set-focus", ] as const).entries()) { trace = startActivityTraceStep(trace, tool, RECTIFICATION_TOOL_PROGRESS_LABELS[tool], index + 1); trace = completeActivityTraceStep(trace, tool, RECTIFICATION_TOOL_DONE_LABELS[tool]); } assert.equal(trace.length, 3, "the live trace keeps one row per call"); const rows = rectificationTimelineRows({ trace, receipt: undefined, activity: undefined, settled: true }); assert.deepEqual(rows.map((row) => row.label), ["看了你的资料", "准备好下一个问题"]); assert.equal(timelineSummaryLabel(rows, false), "已完成 2 步"); // Two tools that share a plain label are one step too. let shared = emptyActivityTrace(); for (const tool of ["rectification-record-evidence-batch", "rectification-propose-evidence"] as const) { shared = startActivityTraceStep(shared, tool, RECTIFICATION_TOOL_PROGRESS_LABELS[tool]); shared = completeActivityTraceStep(shared, tool, RECTIFICATION_TOOL_DONE_LABELS[tool]); } assert.deepEqual( rectificationTimelineRows({ trace: shared, receipt: undefined, activity: undefined, settled: true }).map((row) => row.label), ["记下你说的事"], ); assert.equal( rectificationCompletedTrail(["rectification-read-case", "rectification-set-focus", "rectification-set-focus"]), "已完成:看了你的资料 · 准备好下一个问题", ); const every = [ ...Object.values(RECTIFICATION_TOOL_DONE_LABELS), ...Object.values(RECTIFICATION_TOOL_PROGRESS_LABELS), ...Object.values(RECTIFICATION_ACTIVITY_PROGRESS_LABELS), ].join("\n"); for (const jargon of ["对话焦点", "校正记录", "事件证据", "候选", "稳健性", "焦点"]) { assert.equal(every.includes(jargon), false, jargon); } assert.equal(RECTIFICATION_TOOL_DONE_LABELS["rectification-read-case"], "看了你的资料"); assert.equal(RECTIFICATION_TOOL_DONE_LABELS["rectification-set-focus"], "准备好下一个问题"); // In-progress lines reuse the VOICE-approved stage sentences (BUG-1047). assert.equal(RECTIFICATION_TOOL_PROGRESS_LABELS["rectification-set-focus"], "正在准备下一个问题…"); assert.equal(RECTIFICATION_TOOL_PROGRESS_LABELS["rectification-propose-evidence"], "正在记下这件事…"); assert.equal(RECTIFICATION_TOOL_PROGRESS_LABELS["rectification-compare-candidates"], "正在重新对照盘面…"); });