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
@@ -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 }],
);
});