Files
Jyotisha/frontend/tests/rectification-spoken-collect.test.ts
T
Jesse_ChenandClaude Opus 5.5 8fc1a5bded fix(rectification): plain-word opening with examples in the stem, plain de-duplicated step receipt (BUG-1049, BUG-1050)
- BUG-1049 (recurrence of BUG-504): the opening stem carries six examples and
  an example answer again and is server-owned on the zero-evidence opening;
  the body is two plain sentences (no 大运/盘面/代表分钟/精确到秒, no year, no
  question). Stem de-dup compares whole sentences / near-equality instead of a
  12-char prefix, which had deleted the body's examples sentence since
  aa7ccb30 (BUG-604) + dd8f35f7 (BUG-648).
- BUG-1050: plain step labels; a finished step label shows once and
  「已完成 N 步」counts shown rows; failed rows read 「…未完成」 from the
  in-progress wording.
- Skill 10.0.30 -> 10.0.31 (OpeningPolicy); 10.0.30 kept as deprecated.
- VOICE / DESIGN / CHANGELOG / BUG_HISTORY / PROGRESS / real-device checklist
  and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 22:04:56 +08:00

641 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
declinedCollectDomains,
nextDatedCollectFollowup,
reverseVerifyRemainingForAdopt,
spokenFollowupForUser,
spokenCollectFallbackFollowup,
type MethodFollowup,
type MethodFollowupEvidence,
} 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";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { collectFocusCloseStatus } from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
import { rectificationChatSurface, readRectificationChatFile } from "./rectification-chat-surface.ts";
import { rectificationAgentRouteSurface, rectificationTypedMessageFastPath } from "./rectification-agent-route-surface.ts";
import { rectificationAgentRunSurface } from "./rectification-agent-run-surface.ts";
const RELATIONSHIP_PROMPT = USER_COLLECT_QUESTION.relationship;
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> = {}): 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<string, unknown>;
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 is 10.0.26 after the targeted-collect-cards bump", () => {
// 原值: "10.0.25"
// 原值: "10.0.26"
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
// 原值: "10.0.30" / 新值: "10.0.31" / 原因: 开场改大白话、题干带例子和示例回答写进 Skill OpeningPolicy(BUG-1049,2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.31");
});
test("cases current_question remains the submit contract, not a visual slot", () => {
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
assert.match(chat, /current_question\?: unknown/);
assert.match(chat, /setCurrentQuestion\(nextQuestion\)/);
assert.doesNotMatch(chat, /className="rectification-question-slot"/);
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
assert.match(chat, /<RectificationChoiceCard/);
assert.match(chat, /onSelect=\{submitChoice\}/);
assert.match(chat, /onStop=\{submitStop\}/);
assert.doesNotMatch(chat, /finalizeRectificationSpokenAndThinking/);
assert.doesNotMatch(chat, /rectification-agentic\/v9\/spoken-answer/);
assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/);
// 原值: 口述采集态必须渲染 `.rectification-collect-stop`
// 新值: 停止只留选择题卡内「先这样」与生成中「停止回答」
// 原因: BUG-595 决策 2,输入框上方不再挂先这样
const wrap = chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("className=\"composer-footer\""));
assert.doesNotMatch(wrap, /rectification-collect-stop/);
assert.doesNotMatch(wrap, /CHOICE_STOP_LABEL/);
// 旧:口述采集挂「没有」「记不清」芯片(BUG-605 / BUG-618)
// 新:芯片全部去掉;打字「没有 / 记不清」仍走短路
// 原因:BUG-628,用户一致差评
assert.doesNotMatch(wrap, /rectification-collect-replies/);
assert.doesNotMatch(wrap, /rectification-collect-reply/);
assert.doesNotMatch(wrap, /send\("message", "没有"\)/);
assert.doesNotMatch(wrap, /rectification-step-state/);
// 原值: 切片容器 map 里的 `const afterAnswer = question`。
// 新值: afterAnswer 在行组件内构造。
// 原因: BUG-725。
const messageEntryForReplies = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
const afterAnswer = messageEntryForReplies.slice(
messageEntryForReplies.indexOf("const afterAnswer = question"),
messageEntryForReplies.indexOf("return ("),
);
assert.doesNotMatch(afterAnswer, /CollectSpokenReplies/);
assert.doesNotMatch(afterAnswer, /shouldShowCollectSpokenReplies/);
assert.doesNotMatch(chat, /function CollectSpokenReplies/);
assert.doesNotMatch(chat, /rectification-collect-replies/);
assert.doesNotMatch(chat, /rectification-collect-reply/);
assert.doesNotMatch(chat, /onChoose\("没有"\)/);
assert.doesNotMatch(chat, /onChoose\("记不清"\)/);
assert.doesNotMatch(chat, />[\s\n]*没有[\s\n]*</);
assert.doesNotMatch(chat, />[\s\n]*记不清[\s\n]*</);
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.doesNotMatch(styles, /\.rectification-collect-replies/);
assert.doesNotMatch(styles, /\.rectification-collect-reply/);
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
});
test("collect_spoken stem lives on turn.question inside the same assistant article", () => {
// old → new → kept: slot sibling → in-bubble question → no body-text scan; refresh rebuilds from asked_turn_id
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
const row = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
// 原值: 读 lib/rectification-agentic/v9/agent-run.ts 单文件。新值: rectificationAgentRunSurface(agent-run.ts + 拆出的 agent-run-*.ts,见 rectification-agent-run-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const agentRun = rectificationAgentRunSurface;
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
assert.match(chat, /const collectSpokenPrompt =/);
assert.match(chat, /parseTurnQuestion\(turn\.question\)/);
assert.match(chat, /function markQuestionAnswered/);
// 原值: 容器把 afterAnswer 作为 ChatMessageRow prop。
// 新值: 行组件内部构造 afterAnswer 再传给 ChatMessageRow。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /afterAnswer=\{afterAnswer\}/);
assert.match(chat, /rectification-message-question/);
assert.match(row, /afterAnswer/);
assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/);
assert.doesNotMatch(chat, /function attachCollectSpokenStem/);
assert.match(chat, /useLayoutEffect\(\(\) => \{\s*currentQuestionRef\.current = currentQuestion;/);
assert.doesNotMatch(chat, /\[busy, currentQuestion, regeneratingMessageKey\]/);
assert.doesNotMatch(chat, /showCollectSpokenPrompt/);
assert.doesNotMatch(chat, /rectification-question-slot/);
assert.doesNotMatch(chat, /showQuestionSlot/);
assert.match(
chat,
/\(liveQuestionOnMessages \|\| questionGap === "persisted_question"\) && collectSpokenPrompt\n\s+\? "请回答上面的问题…"/,
);
// 原值: messages.some(同 focus_id)
// 新值: 只认 latestSettledAssistant
// 原因: BUG-635 决策 5,旧消息上的同焦点不再算仍在显示
assert.match(chat, /const liveQuestionOnMessages = Boolean\(\s*latestSettledAssistant/);
assert.doesNotMatch(chat, /const liveQuestionOnMessages = messages\.some\(/);
// 原值: const liveQuestionOnMessages = messages.some((message) =>
// 新值: latestSettledAssistant 上的 question.focus_id
// 原因: BUG-635 完成轮无问题行时,旧消息的同 focus_id 不得挡住主持人问题行
assert.match(chat, /const liveQuestionOnMessages = Boolean\(\s*latestSettledAssistant/);
assert.match(
readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"),
/\[describedBy, showRemaining \? remainingId : undefined\]\.filter\(Boolean\)\.join\(" "\)/,
);
assert.match(chat, /<RectificationChoiceCard/);
assert.doesNotMatch(chat, /请在下方输入框回答/);
assert.doesNotMatch(chat, /questionHintId/);
assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/);
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(agentRun, /replace: true/);
assert.match(agentRun, /finalizeTurn\("completed", spokenAnswer/);
assert.doesNotMatch(agentRun, /answerText\.(?:includes|match|search)\(/);
assert.match(styles, /\.rectification-message-question \{/);
assert.match(styles, /\.rectification-choice-card\.is-embedded/);
assert.doesNotMatch(styles, /\.rectification-question-slot \{/);
});
test("missing current_question is explicit only for resumable cases", () => {
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
// Was: `const showMissingQuestion = Boolean(... caseSnapshotLoaded ... resumableCase ...
// currentQuestion === null` plus the copy /当前没有可回答的问题,正在等待服务端更新/ and
// /题目加载失败,请刷新/. Those were bare waiting sentences; the same inputs now feed
// `rectificationQuestionGapState`, which yields a live row with retries and then a
// reload button, and is still gated on a resumable case (BUG-505).
assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{[\s\S]*questionMissing: currentQuestion === null,[\s\S]*snapshotLoaded: caseSnapshotLoaded,[\s\S]*resumableCase,/);
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新/);
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
// 原值 readonly;新值 historyReadonly;原因:结果身份只读不等于 Case 已结束。
assert.match(chat, /historyReadonly && \(/);
assert.doesNotMatch(chat, /questionGap[\s\S]*caseStatus.*TERMINAL/);
});
test("choice options render inside the same assistant message, not a sibling slot", () => {
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
const render = chat.slice(
chat.indexOf("{messages.map((message) => {") ,
chat.indexOf("{savedTime &&"),
);
assert.match(chat, /showLiveChoiceCard/);
// 原值: 嵌入卡、afterAnswer、onSelect={submitChoice} 都在容器 map 里。
// 新值: 嵌入卡与 afterAnswer 在行组件内,经 actionsRef 提交;容器的 persisted 题卡仍直接绑 submitChoice。
// 原因: BUG-725 已结算行记忆化,回调不得当 props。
assert.match(messageEntry, /<RectificationChoiceCard/);
assert.match(messageEntry, /afterAnswer/);
assert.match(messageEntry, /actionsRef\.current\.submitChoice/);
assert.match(messageEntry, /actionsRef\.current\.submitStop/);
// 原值: render(容器 map 到 savedTime 之间)含 persisted 题卡 <RectificationChoiceCard、onSelect={submitChoice}、onStop={submitStop}。
// 新值: render 挂 <RectificationQuestionGapNotices 并传 submitChoice / submitStop;该块里的 persisted 题卡绑 onSelect={submitChoice} / onStop={submitStop}。
// 原因: TASK-rectification-code-split-20260926 缺题块(含 persisted 题卡)抽成组件,位置与回调不变。
const gapNotices = readRectificationChatFile("../src/components/rectification-question-gap-notices.tsx");
assert.match(render, /<RectificationQuestionGapNotices/);
assert.match(render, /submitChoice=\{submitChoice\}/);
assert.match(render, /submitStop=\{submitStop\}/);
assert.match(gapNotices, /<RectificationChoiceCard/);
assert.match(gapNotices, /onSelect=\{submitChoice\}/);
assert.match(gapNotices, /onStop=\{submitStop\}/);
assert.doesNotMatch(render, /rectification-question-slot/);
assert.doesNotMatch(gapNotices, /rectification-question-slot/);
assert.doesNotMatch(render, /liveSpoken/);
assert.doesNotMatch(render, /showLiveSpokenCollect/);
assert.doesNotMatch(messageEntry, /rectification-question-slot/);
});
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("export async function requestTieBreakPersonality"),
);
assert.match(nextInterview, /isRenderableChoiceOpenQuestion/);
assert.match(nextInterview, /hostNarration: open\.prompt/);
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const route = rectificationAgentRouteSurface;
assert.match(route, /isRenderableChoiceOpenQuestion/);
// 原值: fastPath = route.ts 里 `if (action === "message")` 到 `const requestTime` 的切片。
// 新值: rectificationTypedMessageFastPath():同一批代码的新位置(agent-route-typed-message.ts 全文 +
// route.ts 从 `if (action === "message")` 起 + agent-route-agent-turn.ts 到 `const requestTime` 为止)。
// 原因: TASK-rectification-code-split-20260926 快路径拆进多个文件;按代码去处重组,断言不变。
const fastPath = rectificationTypedMessageFastPath();
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 lets the model write the stem only through set-focus spokenPrompt", () => {
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, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(prompt, /collect_spoken[\s\S]*不输出输入提示/);
assert.match(prompt, /没有下一问/);
assert.match(prompt, /选择卡|choice 选项由服务端/);
assert.doesNotMatch(prompt, /题干与选项完全由结构化槽位和 UI 承担/);
assert.doesNotMatch(prompt, /collect_spoken 题干由服务器接在同一条正文末尾并写入聊天历史/);
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", () => {
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const route = rectificationAgentRouteSurface;
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"));
const beforeRun = route.slice(
route.indexOf("async start(controller)"),
route.indexOf("const result = await runV9AgentTurn"),
);
assert.match(beforeRun, /if \(action === "opening"\)/);
assert.match(beforeRun, /persistNextInterviewIfIdle/);
assert.match(afterRun, /await finalizeSuccessfulTurnExit/);
assert.match(turnExit, /persistNextInterviewIfIdle/);
assert.doesNotMatch(afterRun, /persistCollectSpokenAssistantIfNew|persistEmptyCollectSpokenAssistant/);
assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/);
// 原值: 成功分支是第一个 `} else {`,其后再 `send({ type: "done"`
// 新值: already_delivered 先 send done,成功分支在 else if (!result.ok) 之后
// 原因: BUG-596
const alreadyDelivered = afterRun.slice(
afterRun.indexOf("if (result.errorCode === \"already_delivered\")"),
afterRun.indexOf("} else if (!result.ok)"),
);
assert.match(alreadyDelivered, /send\(\{\s*type:\s*"done"/);
assert.doesNotMatch(alreadyDelivered, /await finalizeSuccessfulTurnExit/);
const successStart = afterRun.indexOf("} else {", afterRun.indexOf("} else if (!result.ok)"));
const successExit = afterRun.slice(
successStart,
afterRun.lastIndexOf("send({ type: \"done\""),
);
assert.match(successExit, /await finalizeSuccessfulTurnExit/);
assert.doesNotMatch(successExit, /send\(\{\s*type:\s*"error"/);
// 原值: 读 lib/rectification-agentic/v9/agent-run.ts 单文件。新值: rectificationAgentRunSurface(agent-run.ts + 拆出的 agent-run-*.ts,见 rectification-agent-run-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const agentRun = rectificationAgentRunSurface;
assert.doesNotMatch(agentRun, /collectSpokenPromptForNewFocus/);
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(agentRun, /askedTurnId: turnId/);
assert.doesNotMatch(agentRun, /当前可询问范围/);
});
test("classifier close-status maps no and unsure onto collect denial", () => {
assert.equal(collectFocusCloseStatus({
intent: "answer_current_focus",
answer_class: "no",
}), "declined");
assert.equal(collectFocusCloseStatus({
intent: "answer_current_focus",
answer_class: "unsure",
}), "skipped");
assert.equal(collectFocusCloseStatus({
intent: "answer_current_focus",
answer_class: "yes",
}), null);
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const route = rectificationAgentRouteSurface;
const classifier = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), "utf8");
const collectStart = route.lastIndexOf("} else if (focus && isCollectFocusSchema(focus.expectedAnswerSchema))");
const collectBranch = route.slice(collectStart);
assert.doesNotMatch(collectBranch + classifier, /isCollectDeclineUtterance|isCollectSkipUtterance/);
assert.match(collectBranch, /collectFocusCloseStatus/);
assert.match(collectBranch, /status: closeStatus/);
assert.match(collectBranch, /persistCollectDenialTurn/);
assert.doesNotMatch(collectBranch.slice(0, collectBranch.indexOf("classifyTurnIntentWithRetry") + 40), /runV9AgentTurn/);
});
test("skipped collect domains are not re-asked this session; reverse-verify may still touch them", () => {
const skippedFamily = [{
target_domain: "family",
status: "skipped",
intent: "collect_method_evidence",
}];
const declined = declinedCollectDomains(skippedFamily);
assert.equal(declined.has("family"), true);
const next = nextDatedCollectFollowup([], declined);
assert.notEqual(next?.domain, "family");
const familyProbe: DiscriminatingEventProbe = {
year: 2018,
year_label: "2018 年前后",
domain: "family",
event_family: "家里的大事",
source: "dasha_boundary",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2018 年。",
role: "distinguish",
information_gain: 1.1,
semantic_key: "family.2018.dasha_boundary",
candidate_split_hash: "family.2018.dasha_boundary",
candidate_ids: ["05:00", "05:20"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
],
choice_kind: "existence",
};
const remaining = reverseVerifyRemainingForAdopt({
eventProbes: [familyProbe],
evidence: [],
declinedTopics: skippedFamily,
});
assert.equal(remaining.some((probe) => probe.domain === "family"), true);
});
test("after one or two dated events the next collect is an invite, not a domain rotation", () => {
const evidence: MethodFollowupEvidence[] = [
{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
];
const next = nextDatedCollectFollowup(evidence, new Set());
assert.ok(next);
// 原值: family + 「想到别的也可以一起说」,禁止「还有吗」
// 新值: 邀请「还有吗」,例子来自未覆盖种类
// 原因: 产品口径改为用户先说完,再从已说的事追问(BUG-648)
assert.equal(next.domain, "other");
assert.equal(next.collection_key, "collect:invite:more");
const spoken = spokenFollowupForUser(next);
assert.ok(spoken);
assert.match(spoken, /^还有吗?/);
assert.match(spoken, /第一份工作|搬到别的城市|谈恋爱|家里添丁/);
assert.doesNotMatch(spoken, /年前后/);
const two = nextDatedCollectFollowup([
...evidence,
{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2020-06-01", occurredTo: null, eventKind: "education_completion" },
], new Set());
assert.equal(two?.collection_key, "collect:invite:more");
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.match(tools, /recordV10EvidenceBatch/);
assert.match(tools, /result\.acceptedCount > 0[\s\S]*autoRescoreAfterEvidenceChange/);
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
// 原值: 读 lib/rectification-agentic/v9/agent-run.ts 单文件。新值: rectificationAgentRunSurface(agent-run.ts + 拆出的 agent-run-*.ts,见 rectification-agent-run-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const agentRun = rectificationAgentRunSurface;
assert.doesNotMatch(agent, /还能想起别的吗/);
assert.doesNotMatch(agentRun, /还能想起别的吗/);
});
test("three confirmed domains from one message are not re-asked by dated collect", () => {
const evidence: MethodFollowupEvidence[] = [
{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: null },
{ status: "confirmed", domain: "relocation", datePrecision: "year", occurredFrom: "2023-01-01", occurredTo: null },
];
const next = nextDatedCollectFollowup(evidence, new Set());
assert.notEqual(next?.domain, "education");
assert.notEqual(next?.domain, "career");
assert.notEqual(next?.domain, "relocation");
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.match(tools, /recordV10EvidenceBatch/);
assert.match(tools, /result\.acceptedCount > 0[\s\S]*autoRescoreAfterEvidenceChange/);
});
test("declining the invite then two education events anchors to the graduation job question", () => {
const evidence: MethodFollowupEvidence[] = [
{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: null,
eventKind: "education_start",
},
{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2020-06-01",
occurredTo: null,
eventKind: "education_completion",
},
];
const declined = [{
target_domain: "other",
status: "declined",
intent: "collect_method_evidence",
questionId: "collect:invite:more",
target_kind: "invite_more",
}];
const next = nextDatedCollectFollowup(evidence, new Set(), new Set(), { declinedTopics: declined });
const spoken = spokenFollowupForUser(next);
// 原值: 家人采集 + 「2018 年前后」年龄段线索
// 新值: 「2020 年毕业后第一份工作」
// 原因: 撤回 BUG-642 生日推年份;锚定只用来自用户的年份(BUG-648)
assert.match(spoken ?? "", /2020 年毕业后第一份工作/);
assert.doesNotMatch(spoken ?? "", /年前后/);
assert.equal(next?.collection_key, "collect:anchor:education_completion:2020");
});