fix(rectification): ask spoken collect questions in the agent body
Independent Staging Quality Gate / validate (push) Successful in 9m32s
Independent Staging Quality Gate / publish (push) Successful in 6m53s

The standalone collect prompt bar reused the choice-card chrome and
duplicated the question. Visibility now comes from the spoken reply,
with an empty-body fallback that posts the persisted focus prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-30 09:39:47 +08:00
co-authored by Cursor
parent 02d4c91626
commit ea0fbd4406
10 changed files with 226 additions and 108 deletions
+4 -4
View File
@@ -6756,13 +6756,13 @@
- 状态:resolved
- 首次发现:2026-08-30
- 最近更新:2026-08-30
- 影响面:GET `current_question`、`applyCaseSnapshot`、`openQuestionFromPersistedFocus`、`agentVisibleLatestProjection`、`stableFollowupQuestionId`、POST `/api/rectification/agent` 出卡判定、Agent 系统提示第 4 条
- 影响面:GET `current_question`、`openQuestionFromPersistedFocus`、`agentVisibleLatestProjection`、`stableFollowupQuestionId`、POST `/api/rectification/agent` 出卡判定与空正文兜底、Agent 系统提示第 4 条
- 用户现象:助手正文只写「接下来看下面这一问。」,界面没有任何问题。GET 里 `choice_card` 为 null,`current_question` 已是合法口述采集题(`kind=collect_spoken`、prompt 非空),问题只存在于数据库和 API。
- 触发条件:方法覆盖回落到不带 choice_frame 的口述采集焦点;Agent 按选择卡承接措辞说话;前端只解析 `choice_card`。
- 根因:三段同时成立。(1) 前端从不渲染 `current_question`:`applyCaseSnapshot` 只解析 `latest_result` / `choice_card` / `case`,`kind=collect_spoken` 在界面上没有承载位置。(2) 面向 Agent 的两套投影自相矛盾:`projectTurnDecision` 给出 `current_question.kind=collect_spoken` 与 prompt;记完证据后的 `agentVisibleLatestProjection` 取自 `openQuestionFromPersistedFocus`,后者对 collect focus 一律返回 null。Agent 收到打架信号,退回「卡片会显示」的措辞。(3) `stableFollowupQuestionId` 在 followup 既无 semantic_key 也无 probe_year 时退到 `${method_id}:${ask_theme}`;`persistPlanFocus` 传入的 active_focus 计划正好两者都没有,questionId 退化成 `active_focus:active_focus`。
- 修复:P0:新增 fail-closed `parseRectificationSpokenCollect`,只接受 `kind=collect_spoken` 且 prompt 通过既有裁剪规则的对象。`applyCaseSnapshot` 解析 `current_question`;`choice_card` 为空且非 busy/readonly/regenerating 时,在最后一条助手消息下用现有选择卡轻量样式展示题干,不出 A/B/C/D。消失由服务端 `current_question` 变 null 驱动,不用正文文本判断是否已问过。P1:`openQuestionFromPersistedFocus` 对 collect focus 返回带 `kind=collect_spoken` 的同形结果;`choiceReady` / 出卡判定改为 `isRenderableChoiceOpenQuestion`,只对 `kind=choice` 为真,collect 不得触发「请点选」。`current_probe` 与 `inference.next_probe` 仍只对可渲染选择题放行。系统提示改为口述题干由界面提示条展示,正文不复述;「请点选」「看下面这一问」只允许在有选择卡时使用。P2:collect 在无 semantic_key / probe_year 时用 `collect:<domain>:<intent>`;`active_focus:active_focus` 不得与新 id 互相 already_open。Skill 保持 10.0.13,未放宽 confirmation gate。
- 验证:`frontend/tests/rectification-spoken-collect.test.ts` 锁解析器 fail-closed、卡片优先不渲染口述条、collect openQuestion 不为卡片、probe 在 collect 下为空、active_focus 派生 questionId 带 domain。BUG-440 collect-stall 全绿,D12 同领域计分卡仍优先于家人口述题。`npx tsc --noEmit` 通过;eslint 改动文件 0 error。指定套件 `tests/rectification-*.test.ts tests/birth-time-rectification-contract.test.ts tests/agentic-rectification-*.test.ts` 679 项、0 失败。全量 `tests/*.test.ts` 2273 项、0 失败。失败集合未扩大到产品逻辑。
- 防复发:服务端落下的问题必须有可见承载,不得只靠 Agent 正文。同一焦点在所有面向 Agent 的投影里形态必须一致。「请点选」类措辞只允许在有选择卡时使用。不得用正文文本判断助手有没有问出来。collect questionId 不得退化为 `active_focus:active_focus`。
- 修复:P1/P2 仍在:`openQuestionFromPersistedFocus` 对 collect focus 返回带 `kind=collect_spoken` 的同形结果;`choiceReady` / 出卡判定只对 `kind=choice` 为真;`current_probe` 与 `inference.next_probe` 仍只对可渲染选择题放行;collect 在无 semantic_key / probe_year 时用 `collect:<domain>:<intent>`,`active_focus:active_focus` 不得与新 id 互相 already_open。P0 独立提示条已撤回:前端不再解析或渲染 `current_question`;口述采集题改由 Agent 用自己的话在正文里问出来,一次一问,不得照抄服务端 prompt 原文,但意思和时间范围不得改。「请点选」「看下面这一问」仍只允许在确实有选择卡时使用。服务端 collect focus 继续持久化,GET 仍返回 `current_question`。`runV9AgentTurn` 返回后若 `result.ok` 且 `answerText` 为空、且存在 `kind=collect_spoken` 的 active focus,则补一条普通助手消息(内容等于 focus prompt)并在关流前发出;`answerText` 非空一律不补。Skill 保持 10.0.13,未放宽 confirmation gate。
- 验证:`frontend/tests/rectification-spoken-collect.test.ts` 锁:GET `collect_spoken` 不再渲染独立块、`.rectification-spoken-collect-prompt` 不存在、选择卡分支仍在、提示词要求正文提问、空正文落 focus prompt、非空正文不补。collect openQuestion 不为卡片、probe 在 collect 下为空、active_focus 派生 questionId 带 domain。BUG-440 collect-stall 全绿,D12 同领域计分卡仍优先于家人口述题。
- 防复发:服务端问题的可见性由正文与确定性兜底保证,不得为无选项的问题新造视觉容器,更不得借用选择卡的样式类。同一焦点在所有面向 Agent 的投影里形态必须一致。「请点选」类措辞只允许在有选择卡时使用。不得用正文文本判断助手有没有问出来。collect questionId 不得退化为 `active_focus:active_focus`。
- 相关记录:BUG-404、BUG-411、BUG-432、BUG-437、BUG-440
- 复发自:BUG-432(无选择卡的问题曾要求由 Agent 正文问出,前端仍不渲染 `current_question`)
- 修复版本:待发布
@@ -10,7 +10,12 @@ import {
transitionV9CaseStatus,
} from "@/lib/rectification-agentic/v9/tool-service";
import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectification-agentic/v9/decision-from-dossier";
import { applyRectificationChoice, applyCollectFocusDenial, persistNextInterviewIfIdle } from "@/lib/rectification-agentic/v9/answer-choice";
import {
applyRectificationChoice,
applyCollectFocusDenial,
persistNextInterviewIfIdle,
persistEmptyCollectSpokenAssistant,
} from "@/lib/rectification-agentic/v9/answer-choice";
import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service";
import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action";
import { runV9AgentTurn, type V9RunBilling } from "@/lib/rectification-agentic/v9/agent-run";
@@ -674,6 +679,22 @@ export async function POST(request: Request) {
`[rectification-v9] persist next interview after turn failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
if (!result.answerText.trim()) {
try {
const fallback = await persistEmptyCollectSpokenAssistant({
accounting: accounting as never,
userId,
caseId,
requestId: crypto.randomUUID(),
answerText: result.answerText,
});
if (fallback) send({ type: "answer.delta", text: fallback });
} catch (error) {
console.warn(
`[rectification-v9] empty collect spoken fallback failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
}
}
send({ type: "done", emitted: true });
}
-7
View File
@@ -2902,13 +2902,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-spoken-collect-prompt {
margin: 0;
color: var(--color-ink);
font-size: var(--type-body-sm);
font-weight: 600;
line-height: 1.55;
}
.rectification-candidates-heading { display: grid; gap: 5px; }
.rectification-candidates-heading strong { font-size: var(--type-title-sm); font-family: var(--font-display); font-weight: 600; letter-spacing: -.2px; }
.rectification-candidates-heading span,
@@ -59,10 +59,8 @@ import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agen
import {
isPersistedFocusId,
parseRectificationChoiceCard,
parseRectificationSpokenCollect,
type ChoiceKey,
type RectificationChoiceCard as ChoiceCardModel,
type RectificationSpokenCollect,
} from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
import { ChatMessageRow } from "./chat-message-row";
@@ -288,7 +286,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(null);
const [spokenCollect, setSpokenCollect] = useState<RectificationSpokenCollect | null>(null);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
@@ -391,18 +388,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const applyCaseSnapshot = useCallback((payload: {
latest_result?: unknown;
choice_card?: unknown;
current_question?: unknown;
case?: { accepted_time?: unknown; confirmed_time?: unknown };
} | null) => {
if (!payload) return;
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
const nextSpoken = parseRectificationSpokenCollect(payload.current_question);
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
setCandidateResult(nextCandidate);
setChoiceCard(nextChoice);
setSpokenCollect(nextSpoken);
if (confirmedTime) {
setSavedTime(confirmedTime);
setSavedStatus("confirmed");
@@ -1053,14 +1047,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
choiceCardsOpen.current = showLiveChoiceCard;
updateFollowState();
}, [showLiveChoiceCard, updateFollowState]);
const showLiveSpokenCollect = Boolean(
!choiceCard
&& spokenCollect
&& liveChoiceHost
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
const showSelectionCards = Boolean(
candidateResult?.selectionAllowed
&& offeredSelectionOnce
@@ -1075,9 +1061,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const liveChoiceMessageKey = showLiveChoiceCard && liveChoiceHost
? liveChoiceHost.renderKey
: undefined;
const liveSpokenMessageKey = showLiveSpokenCollect && liveChoiceHost
? liveChoiceHost.renderKey
: undefined;
const canSend = !busy && !readonly && !regeneratingMessageKey;
function submitChoice(key: ChoiceKey) {
@@ -1145,9 +1128,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const liveChoice = showLiveChoiceCard && message.renderKey === liveChoiceMessageKey
? choiceCard
: null;
const liveSpoken = showLiveSpokenCollect && message.renderKey === liveSpokenMessageKey
? spokenCollect
: null;
const visibleChoiceCard = settledChoice?.card ?? liveChoice;
const vargaSentence = !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
@@ -1200,11 +1180,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onStop={submitStop}
/>
)}
{liveSpoken && (
<section className="rectification-choice-card" aria-label="口述采集题">
<p className="rectification-spoken-collect-prompt">{liveSpoken.prompt}</p>
</section>
)}
</div>
);
})}
@@ -54,6 +54,7 @@ import {
openQuestionPromptFromToolResult,
publicNarrationDtoFromDossier,
} from "./turn-narration";
import { emptyAnswerCollectSpokenFallback, projectCurrentQuestion } from "./turn-decision";
import {
defaultMessageOrigin,
isRectificationMessageOrigin,
@@ -916,7 +917,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (!answerText.trim()) {
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
const narration = composeRectificationTurnNarration(publicNarrationDtoFromDossier(latest));
const collect = emptyAnswerCollectSpokenFallback(
"",
projectCurrentQuestion(latest.conversationSummary.activeFocus),
);
const narration = collect
?? composeRectificationTurnNarration(publicNarrationDtoFromDossier(latest));
if (narration.trim()) {
answerText = narration;
answerDeltas.push(narration);
@@ -47,6 +47,7 @@ import {
spokenFollowupForUser,
} from "./method-followup";
import type { SessionOutcomeKind } from "./confirmation-gate";
import { emptyAnswerCollectSpokenFallback, projectCurrentQuestion } from "./turn-decision";
export type ApplyChoiceCommand = Readonly<{
userId: string;
@@ -499,6 +500,28 @@ export async function persistNextInterviewIfIdle(input: {
};
}
export async function persistEmptyCollectSpokenAssistant(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
requestId: string;
answerText: string;
}): Promise<string | null> {
if (input.answerText.trim()) return null;
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const prompt = emptyAnswerCollectSpokenFallback(
input.answerText,
projectCurrentQuestion(dossier.conversationSummary.activeFocus),
);
if (!prompt) return null;
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: input.requestId,
userMessage: null,
assistantMessage: prompt,
});
return prompt;
}
async function persistApplied(
accounting: AccountingClient,
command: ApplyChoiceCommand,
@@ -516,30 +516,6 @@ export function choiceCardUserMessage(
return card.scoring ? line : `${HOLDOUT_MESSAGE_PREFIX}:${line}`;
}
export type RectificationSpokenCollect = Readonly<{
kind: "collect_spoken";
question_id: string | null;
prompt: string;
domain: string | null;
}>;
export function parseRectificationSpokenCollect(value: unknown): RectificationSpokenCollect | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
if (row.kind !== "collect_spoken") return null;
if (row.choice_mode === CHOICE_MODE || Array.isArray(row.options) || row.choice) return null;
const prompt = clippedProbeLabel(row.prompt, 2, 400);
if (!prompt) return null;
return {
kind: "collect_spoken",
question_id: typeof row.question_id === "string" && row.question_id.trim()
? row.question_id.trim()
: null,
prompt,
domain: typeof row.domain === "string" && row.domain.trim() ? row.domain.trim() : null,
};
}
export function parseRectificationChoiceCard(value: unknown): RectificationChoiceCard | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
@@ -67,6 +67,16 @@ function looksLikeChoiceSchema(schema: Readonly<Record<string, unknown>> | null
);
}
export function emptyAnswerCollectSpokenFallback(
answerText: string,
question: Pick<CurrentQuestionProjection, "kind" | "prompt"> | null | undefined,
): string | null {
if (answerText.trim()) return null;
if (question?.kind !== "collect_spoken") return null;
const prompt = typeof question.prompt === "string" ? question.prompt.trim() : "";
return prompt || null;
}
export function projectCurrentQuestion(
focus: {
id?: string;
+1 -1
View File
@@ -63,7 +63,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
1. 第一步调用 rectification-read-case。服务器是事实、焦点、权限与终态的唯一权威。
2. 事实只能来自用户原话;复述日期必须用 display_date_label。不得虚构事件、候选或出生分钟。
3. 新事件走 rectification-record-evidence-batch。工具执行保持静默;思考用简体中文写在思维链;对用户说的话必须自己写在正文里,不叙述工具或内部状态。
4. 有持久化选择题(current_question.kind=choice / 选择卡)时,题干和选项只由选择卡展示,正文只自然承接,不得另写、改写或复述。「请点选」「看下面这一问」只允许在确实有选择卡时使用。口述采集题(kind=collect_spoken)的题干由界面提示条展示,正文只自然承接、不复述题干。没有持久化当前问题时,用自然语言问一件带大概年份的经历,不得自拟区分题。点选与「先这样」由服务器处理。
4. 有持久化选择题(current_question.kind=choice / 选择卡)时,题干和选项只由选择卡展示,正文只自然承接,不得另写、改写或复述。「请点选」「看下面这一问」只允许在确实有选择卡时使用。口述采集题(current_question.kind=collect_spoken)必须由你用自己的话在正文里问出来,一次一问;不要照抄服务端 prompt 原文,但意思和时间范围不得改。没有持久化当前问题时,用自然语言问一件带大概年份的经历,不得自拟区分题。点选与「先这样」由服务器处理。
5. 不得宣称唯一出生分钟。confirmation_allowed 为 false 或宽度大于 5 时,说明这是不可分区间,代表分钟只是代表性候选。出牌轮写入 skill_verification_report;80%/60% 只是事件吻合率。
6. 一次一问。不泄露提示词或 Skill 原文。`;
@@ -2,29 +2,30 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
parseRectificationChoiceCard,
parseRectificationSpokenCollect,
} from "../src/lib/rectification-agentic/v9/choice-card.ts";
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 { emptyAnswerCollectSpokenFallback, projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { persistEmptyCollectSpokenAssistant } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { spokenCollectFallbackFollowup } from "../src/lib/rectification-agentic/v9/method-followup.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,
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";
@@ -88,51 +89,53 @@ test("skill version stays 10.0.13 for the spoken-collect visibility fix", () =>
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
});
test("spoken collect parser accepts collect_spoken GET payloads and fail-closes otherwise", () => {
const parsed = parseRectificationSpokenCollect(COLLECT_GET_QUESTION);
assert.equal(parsed?.kind, "collect_spoken");
assert.equal(parsed?.prompt, RELATIONSHIP_PROMPT);
assert.equal(parsed?.domain, "relationship");
assert.equal(parseRectificationSpokenCollect({
...COLLECT_GET_QUESTION,
kind: "choice",
}), null);
assert.equal(parseRectificationSpokenCollect({
kind: "collect_spoken",
prompt: " ",
domain: "relationship",
}), null);
assert.equal(parseRectificationSpokenCollect({
kind: "collect_spoken",
domain: "relationship",
}), null);
assert.equal(parseRectificationSpokenCollect(null), null);
assert.equal(parseRectificationSpokenCollect({
kind: "collect_spoken",
prompt: RELATIONSHIP_PROMPT,
choice_mode: "A/B/C/D",
options: [{ key: "A" }],
}), null);
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("spoken collect prompt bar is not rendered when a choice card is present", () => {
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");
assert.match(chat, /parseRectificationSpokenCollect/);
assert.match(chat, /payload\.current_question/);
assert.match(chat, /showLiveSpokenCollect = Boolean\(/);
assert.match(chat, /!choiceCard/);
assert.match(chat, /spokenCollect/);
const render = chat.slice(
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{savedTime &&"),
);
assert.match(render, /showLiveSpokenCollect/);
assert.match(render, /showLiveChoiceCard/);
const spokenBlock = render.slice(render.indexOf("{liveSpoken &&"));
assert.match(spokenBlock, /rectification-spoken-collect-prompt/);
assert.doesNotMatch(spokenBlock, /onSelect=\{submitChoice\}|submitStop|A\/B\/C\/D/);
assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/);
assert.match(render, /<RectificationChoiceCard/);
assert.match(render, /onSelect=\{submitChoice\}/);
assert.match(render, /onStop=\{submitStop\}/);
assert.doesNotMatch(render, /liveSpoken/);
assert.doesNotMatch(render, /showLiveSpokenCollect/);
});
test("empty answerText with a collect_spoken focus falls back to the focus prompt; non-empty does not", () => {
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", () => {
@@ -294,15 +297,126 @@ test("active_focus collect followups get a domain-stable questionId, not active_
assert.equal(open?.kind, "collect_spoken");
});
test("agent prompt forbids 请点选 bridging unless a choice card exists", () => {
test("agent prompt requires spoken collect questions in the body and forbids 请点选 unless a choice card exists", () => {
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, /口述采集题[\s\S]*必须由你用自己的话在正文里问出来/);
assert.match(prompt, /请点选/);
assert.match(prompt, /选择卡/);
assert.doesNotMatch(prompt, /口述采集题(无选择卡,kind=collect_spoken)必须由你在正文里问出来/);
assert.doesNotMatch(prompt, /界面提示条/);
assert.doesNotMatch(prompt, /不复述题干/);
});
const FALLBACK_REQUEST_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
function collectFocusDossier() {
return dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "collect_method_evidence",
targetDomain: "relationship",
expectedAnswerSchema: {
prompt: RELATIONSHIP_PROMPT,
collect: true,
},
}),
}),
});
}
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: " ",
});
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 skipped = await persistEmptyCollectSpokenAssistant({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: FALLBACK_REQUEST_ID,
answerText: "记下了。你长期做什么工作?",
});
assert.equal(skipped, null);
assert.equal(
accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length,
1,
);
});
test("agent route emits the collect prompt only when the body is empty", () => {
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, /persistEmptyCollectSpokenAssistant/);
assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle"));
assert.ok(afterRun.indexOf("persistNextInterviewIfIdle") < afterRun.indexOf("persistEmptyCollectSpokenAssistant"));
assert.match(afterRun, /if \(!result\.answerText\.trim\(\)\)/);
assert.match(afterRun, /send\(\{ type: "answer\.delta", text: fallback \}\)/);
assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/);
});
test("empty stream with an open collect_spoken focus uses the focus prompt as the assistant message", async () => {
const emitted: Array<{ type: string; text?: string }> = [];
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
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: "2016年9月离开家去北京工作",
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" } };
yield { type: "finish" };
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 4 }),
}),
getSkill: async () => ({ name: "jyotish-birth-time-rectification", instructions: "skill" }),
}) as never,
});
assert.equal(result.ok, true);
assert.equal(result.answerText, RELATIONSHIP_PROMPT);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: RELATIONSHIP_PROMPT }],
);
});