BUG-626 and BUG-627 are already reserved on staging by the skipped-health holdout brief. Co-authored-by: Cursor <cursoragent@cursor.com>
538 lines
25 KiB
TypeScript
538 lines
25 KiB
TypeScript
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 { isCollectDeclineUtterance, isCollectSkipUtterance } from "../src/lib/rectification-agentic/v9/turn-intent-classifier.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.20 after the delivery UI simplify bump", () => {
|
||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
|
||
});
|
||
|
||
test("cases current_question remains the submit contract, not a visual 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.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/);
|
||
const afterAnswer = chat.slice(
|
||
chat.indexOf("const afterAnswer = question"),
|
||
chat.indexOf("key={message.renderKey}"),
|
||
);
|
||
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
|
||
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 styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
|
||
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
|
||
assert.match(chat, /const collectSpokenPrompt =/);
|
||
assert.match(chat, /parseTurnQuestion\(turn\.question\)/);
|
||
assert.match(chat, /function markQuestionAnswered/);
|
||
assert.match(chat, /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, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/);
|
||
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", () => {
|
||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||
// 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/);
|
||
assert.match(chat, /readonly && \(/);
|
||
assert.doesNotMatch(chat, /questionGap[\s\S]*caseStatus.*TERMINAL/);
|
||
});
|
||
|
||
test("choice options render inside the same assistant message, not a sibling slot", () => {
|
||
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(chat, /showLiveChoiceCard/);
|
||
assert.match(render, /<RectificationChoiceCard/);
|
||
assert.match(render, /onSelect=\{submitChoice\}/);
|
||
assert.match(render, /onStop=\{submitStop\}/);
|
||
assert.match(render, /afterAnswer/);
|
||
assert.doesNotMatch(render, /rectification-question-slot/);
|
||
assert.doesNotMatch(render, /liveSpoken/);
|
||
assert.doesNotMatch(render, /showLiveSpokenCollect/);
|
||
});
|
||
|
||
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, /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 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", () => {
|
||
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"));
|
||
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"/);
|
||
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
|
||
assert.doesNotMatch(agentRun, /collectSpokenPromptForNewFocus/);
|
||
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
|
||
assert.match(agentRun, /askedTurnId: turnId/);
|
||
assert.doesNotMatch(agentRun, /当前可询问范围/);
|
||
});
|
||
|
||
test("没有 and 记不清 short-circuit collect_spoken without a model run", () => {
|
||
assert.equal(isCollectDeclineUtterance("没有"), true);
|
||
assert.equal(isCollectDeclineUtterance("没有。"), true);
|
||
assert.equal(isCollectSkipUtterance("记不清"), true);
|
||
assert.equal(isCollectSkipUtterance("记不清!"), true);
|
||
assert.equal(isCollectDeclineUtterance("记不清"), false);
|
||
assert.equal(isCollectSkipUtterance("没有"), false);
|
||
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
||
const collectStart = route.lastIndexOf("} else if (focus && isCollectFocusSchema(focus.expectedAnswerSchema))");
|
||
const collectBranch = route.slice(collectStart);
|
||
const collectFocus = collectBranch.slice(0, collectBranch.indexOf("classifyRectificationTurnIntent"));
|
||
assert.match(collectFocus, /isCollectDeclineUtterance/);
|
||
assert.match(collectFocus, /isCollectSkipUtterance/);
|
||
assert.match(collectFocus, /status: isCollectSkipUtterance\(userMessage\) \? "skipped" : "declined"/);
|
||
assert.match(collectFocus, /persistCollectDenialTurn/);
|
||
assert.doesNotMatch(collectFocus, /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("single confirmed domain opening goes to dated collect without a chase-more turn", () => {
|
||
const evidence: MethodFollowupEvidence[] = [
|
||
{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
|
||
];
|
||
const next = nextDatedCollectFollowup(evidence, new Set());
|
||
assert.ok(next);
|
||
assert.notEqual(next.domain, "education");
|
||
assert.equal(next.domain, "family");
|
||
const spoken = spokenFollowupForUser(next);
|
||
assert.ok(spoken);
|
||
assert.match(spoken, /家里/);
|
||
assert.match(spoken, /想到别的也可以一起说/);
|
||
assert.doesNotMatch(spoken, /还有吗|别的吗|还能想起别的吗/);
|
||
assert.doesNotMatch(spoken, /先说你最容易想起的一两件/);
|
||
const later = nextDatedCollectFollowup(evidence, new Set(), new Set(), { askedDatedCollect: true });
|
||
assert.equal(later?.domain, "family");
|
||
assert.doesNotMatch(spokenFollowupForUser(later) ?? "", /想到别的也可以一起说/);
|
||
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 copy = readFileSync(new URL("../src/lib/rectification-agentic/user-copy.ts", import.meta.url), "utf8");
|
||
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
|
||
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
|
||
assert.doesNotMatch(copy, /还有吗|还能想起别的吗/);
|
||
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/);
|
||
});
|