fix(rectification): server-append spoken collect stems after free-text turns
Independent Staging Quality Gate / validate (push) Successful in 10m43s
Independent Staging Quality Gate / publish (push) Successful in 9m8s

Choice path already wrote spokenFollowupForUser into the body; free-text
dropped that stem and only filled empty answers, so a new collect_spoken
focus stayed invisible after “记下了”.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-30 15:49:20 +08:00
co-authored by Cursor
parent 04b2ccdbe2
commit 52477306cc
12 changed files with 485 additions and 70 deletions
@@ -622,6 +622,7 @@ test("free-text turns persist the next followup so current_question is not null"
caseId: CASE_ID,
});
assert.equal(persisted.persisted, true);
assert.ok(persisted.hostNarration || persisted.choiceReady);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
const question = projectCurrentQuestion({
@@ -920,6 +921,7 @@ test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once",
caseId: CASE_ID,
});
assert.equal(persisted.persisted, true);
assert.ok(persisted.hostNarration || persisted.choiceReady);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
assert.notEqual(setFocus?.args.p_intent, "collect_method_evidence");
@@ -9,14 +9,17 @@ import {
persistServerOwnedFocus,
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.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 { emptyAnswerCollectSpokenFallback, projectTurnDecision, collectSpokenPromptForNewFocus, composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { persistCollectSpokenAssistantIfNew, persistEmptyCollectSpokenAssistant } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { spokenCollectFallbackFollowup, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { isSafeCollectSpokenPrompt } from "../src/lib/rectification-agentic/v9/spoken-answer.ts";
import { openQuestionPromptFromToolResult } from "../src/lib/rectification-agentic/v9/turn-narration.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,
EVIDENCE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
@@ -297,36 +300,150 @@ test("active_focus collect followups get a domain-stable questionId, not active_
assert.equal(open?.kind, "collect_spoken");
});
test("agent prompt requires spoken collect questions in the body and forbids 请点选 unless a choice card exists", () => {
test("agent prompt does not let the model write a persisted question stem", () => {
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, /口述采集题[\s\S]*必须由你用自己的话在正文里问出来/);
assert.match(prompt, /有持久化当前问题时/);
assert.match(prompt, /题干一律不由你写/);
assert.match(prompt, /collect_spoken[\s\S]*服务器接在正文之后/);
assert.match(prompt, /没有持久化当前问题时,用自然语言问一件带大概年份的经历/);
assert.match(prompt, /请点选/);
assert.match(prompt, /选择卡/);
assert.doesNotMatch(prompt, /必须由你用自己的话在正文里问出来/);
assert.doesNotMatch(prompt, /界面提示条/);
assert.doesNotMatch(prompt, /不复述题干/);
});
const FALLBACK_REQUEST_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const RECORDED_BODY = "记下了:2022年搬家。";
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,
},
});
}
function collectFocusDossier() {
return dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: collectFocus(),
}),
});
}
function relocation2022Dossier(activeFocus: ReturnType<typeof collectFocus> | null) {
return dossierFixture({
evidence: [
{
id: EVIDENCE_ID,
source_turn_id: TURN_ID,
subject: "self",
event_kind: "relocation",
domain: "relocation",
occurred_from: "2022-01-01",
occurred_to: null,
date_precision: "year",
summary: "2022年搬家",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-30T00:00:00.000Z",
},
],
conversationSummary: conversationSummaryFixture({
activeFocus,
}),
});
}
function choiceFocusDossier() {
return dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "collect_method_evidence",
targetDomain: "relationship",
intent: "distinguish_candidates",
targetDomain: "education",
expectedAnswerSchema: {
prompt: RELATIONSHIP_PROMPT,
collect: true,
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" },
],
},
},
}),
}),
});
}
test("new collect_spoken focus is visible from the focus lifecycle, not from body text", () => {
const focus = {
id: FOCUS_ID,
questionId: COLLECT_QUESTION_ID,
intent: "collect_method_evidence",
targetDomain: "relationship",
expectedAnswerSchema: {
prompt: RELATIONSHIP_PROMPT,
collect: true,
},
};
assert.equal(
collectSpokenPromptForNewFocus({ previousFocusId: null, focus }),
RELATIONSHIP_PROMPT,
);
assert.equal(
collectSpokenPromptForNewFocus({ previousFocusId: FOCUS_ID, focus }),
null,
);
assert.equal(
collectSpokenPromptForNewFocus({
previousFocusId: null,
focus: {
id: FOCUS_ID,
intent: "distinguish_candidates",
expectedAnswerSchema: {
choice: { prompt: RELATIONSHIP_PROMPT, option_a: "A", option_b: "B", option_c: "C", option_d: "D" },
},
},
}),
null,
);
assert.equal(composeCollectSpokenAssistantText(" ", RELATIONSHIP_PROMPT).composed, RELATIONSHIP_PROMPT);
assert.equal(
composeCollectSpokenAssistantText(RECORDED_BODY, RELATIONSHIP_PROMPT).composed,
`${RECORDED_BODY}\n\n${RELATIONSHIP_PROMPT}`,
);
assert.equal(
composeCollectSpokenAssistantText(RECORDED_BODY, RELATIONSHIP_PROMPT).delta,
`\n\n${RELATIONSHIP_PROMPT}`,
);
assert.equal(spokenFollowupForUser(collectFollowup()), RELATIONSHIP_PROMPT);
assert.equal(isSafeCollectSpokenPrompt(RELATIONSHIP_PROMPT), true);
assert.equal(isSafeCollectSpokenPrompt("接下来请点选下面这一问。"), false);
assert.equal(isSafeCollectSpokenPrompt("看下面这一问"), false);
assert.equal(isSafeCollectSpokenPrompt("请继续 focusId 提问"), false);
assert.equal(openQuestionPromptFromToolResult({
type: "tool-result",
payload: {
result: { open_question: { kind: "collect_spoken", prompt: RELATIONSHIP_PROMPT } },
},
}), null);
});
test("empty agent body persists the collect_spoken prompt as a plain assistant message", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
@@ -342,6 +459,7 @@ test("empty agent body persists the collect_spoken prompt as a plain assistant m
caseId: CASE_ID,
requestId: FALLBACK_REQUEST_ID,
answerText: " ",
previousFocusId: null,
});
assert.equal(filled, RELATIONSHIP_PROMPT);
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
@@ -349,37 +467,139 @@ test("empty agent body persists the collect_spoken prompt as a plain assistant m
assert.equal(turn?.args.p_user_message, null);
assert.equal(turn?.args.p_status, "completed");
const skipped = await persistEmptyCollectSpokenAssistant({
const appended = await persistCollectSpokenAssistantIfNew({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
answerText: RECORDED_BODY,
previousFocusId: null,
});
assert.equal(appended, `\n\n${RELATIONSHIP_PROMPT}`);
const turns = accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(turns.length, 2);
assert.equal(turns[1]?.args.p_assistant_message, RELATIONSHIP_PROMPT);
assert.match(String(turns[1]?.args.p_assistant_message), /还记得别的带年份的感情变化吗/);
assert.doesNotMatch(String(turns[1]?.args.p_assistant_message), /请点选/);
assert.doesNotMatch(String(turns[1]?.args.p_assistant_message), /focusId|current_question|choice_frame/);
});
test("the same open collect focus is not appended again on the next turn", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
});
const skipped = await persistCollectSpokenAssistantIfNew({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: FALLBACK_REQUEST_ID,
answerText: "记下了。你长期做什么工作?",
answerText: RECORDED_BODY,
previousFocusId: FOCUS_ID,
});
assert.equal(skipped, null);
assert.equal(
accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length,
0,
);
});
test("choice focus never copies the stem into the assistant body", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => choiceFocusDossier(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
});
const skipped = await persistCollectSpokenAssistantIfNew({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: FALLBACK_REQUEST_ID,
answerText: "记下了。",
previousFocusId: null,
});
assert.equal(skipped, null);
assert.equal(
accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length,
0,
);
});
test("in-turn persistPlanFocus and idle persist share one collect stem and emit once", async () => {
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const idle = answerChoice.slice(
answerChoice.indexOf("export async function persistNextInterviewIfIdle"),
answerChoice.indexOf("export async function persistCollectSpokenAssistantIfNew"),
);
assert.match(idle, /if \(dossier\.conversationSummary\.activeFocus\)/);
assert.match(idle, /hostNarration:\s*nextInterview\.hostNarration/);
assert.match(answerChoice, /spokenFollowupForUser\(followup\)/);
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
append_agentic_rectification_turn: (_fn, args) => ({
turn_id: TURN_ID,
assistant_message: args.p_assistant_message,
}),
});
const first = await persistCollectSpokenAssistantIfNew({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: FALLBACK_REQUEST_ID,
answerText: RECORDED_BODY,
previousFocusId: null,
hostNarration: spokenFollowupForUser(collectFollowup()),
});
assert.equal(first, `\n\n${RELATIONSHIP_PROMPT}`);
const second = await persistCollectSpokenAssistantIfNew({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
answerText: RECORDED_BODY,
previousFocusId: null,
alreadyEmitted: true,
hostNarration: spokenFollowupForUser(collectFollowup()),
});
assert.equal(second, 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", () => {
test("agent route appends a new collect_spoken stem from focus lifecycle, not body text", () => {
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.match(afterRun, /persistCollectSpokenAssistantIfNew/);
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.ok(afterRun.indexOf("persistNextInterviewIfIdle") < afterRun.indexOf("persistCollectSpokenAssistantIfNew"));
assert.match(afterRun, /previousFocusId:\s*result\.previousFocusId/);
assert.match(afterRun, /alreadyEmitted:\s*result\.collectSpokenEmitted/);
assert.match(afterRun, /hostNarration:\s*idleHostNarration/);
assert.match(afterRun, /send\(\{ type: "answer\.delta", text: fallback \}\)/);
assert.doesNotMatch(afterRun, /if \(!result\.answerText\.trim\(\)\)/);
assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.match(agentRun, /collectSpokenPromptForNewFocus/);
assert.match(agentRun, /composeCollectSpokenAssistantText/);
assert.doesNotMatch(agentRun, /answerText\.(?:includes|match|search)\(/);
});
test("empty stream with an open collect_spoken focus uses the focus prompt as the assistant message", async () => {
async function runCollectTurn(input: {
firstDossier: unknown;
laterDossier: unknown;
spoken?: string;
}) {
const emitted: Array<{ type: string; text?: string }> = [];
let loads = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
get_agentic_rectification_case_dossier: () => {
loads += 1;
return loads === 1 ? input.firstDossier : input.laterDossier;
},
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
@@ -389,7 +609,7 @@ test("empty stream with an open collect_spoken focus uses the focus prompt as th
sessionId: "22222222-2222-4222-8222-222222222222",
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
action: "evidence",
message: "2016年9月离开家去北京工作",
message: "2022年搬家",
modelName: "gpt-4o-mini",
accounting: accounting.client,
billing: {
@@ -406,6 +626,9 @@ test("empty stream with an open collect_spoken focus uses the focus prompt as th
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" } };
if (input.spoken) {
yield { type: "text-delta", payload: { text: input.spoken } };
}
yield { type: "finish" };
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 4 }),
@@ -413,10 +636,63 @@ test("empty stream with an open collect_spoken focus uses the focus prompt as th
getSkill: async () => ({ name: "jyotish-birth-time-rectification", instructions: "skill" }),
}) as never,
});
return { result, emitted };
}
test("free-text 2022 relocation creates a new collect_spoken stem in the assistant body", async () => {
const { result, emitted } = await runCollectTurn({
firstDossier: relocation2022Dossier(null),
laterDossier: relocation2022Dossier(collectFocus()),
spoken: RECORDED_BODY,
});
assert.equal(result.ok, true);
assert.equal(result.previousFocusId, null);
assert.equal(result.collectSpokenEmitted, true);
assert.equal(result.answerText, `${RECORDED_BODY}\n\n${RELATIONSHIP_PROMPT}`);
assert.match(result.answerText, /还记得别的带年份的感情变化吗/);
const deltas = emitted.filter((event) => event.type === "answer.delta").map((event) => event.text);
assert.ok(deltas.includes(RECORDED_BODY));
assert.ok(deltas.includes(`\n\n${RELATIONSHIP_PROMPT}`));
assert.doesNotMatch(result.answerText, /请点选/);
assert.doesNotMatch(result.answerText, /focusId|current_question|choice_frame/);
});
test("empty stream with a newly created collect_spoken focus uses the focus prompt as the assistant message", async () => {
const { result, emitted } = await runCollectTurn({
firstDossier: relocation2022Dossier(null),
laterDossier: relocation2022Dossier(collectFocus()),
});
assert.equal(result.ok, true);
assert.equal(result.answerText, RELATIONSHIP_PROMPT);
assert.equal(result.collectSpokenEmitted, true);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: RELATIONSHIP_PROMPT }],
);
});
test("an already open collect_spoken focus is not appended again after a later turn", async () => {
const { result } = await runCollectTurn({
firstDossier: collectFocusDossier(),
laterDossier: collectFocusDossier(),
spoken: RECORDED_BODY,
});
assert.equal(result.ok, true);
assert.equal(result.previousFocusId, FOCUS_ID);
assert.equal(result.collectSpokenEmitted, false);
assert.equal(result.answerText, RECORDED_BODY);
assert.doesNotMatch(result.answerText, /还记得别的带年份的感情变化吗/);
});
test("choice focus leaves the stem on the card and out of the body", async () => {
const { result } = await runCollectTurn({
firstDossier: choiceFocusDossier(),
laterDossier: choiceFocusDossier(),
spoken: RECORDED_BODY,
});
assert.equal(result.ok, true);
assert.equal(result.collectSpokenEmitted, false);
assert.doesNotMatch(result.answerText, /升学结果或学习环境/);
assert.doesNotMatch(result.answerText, /还记得别的带年份的感情变化吗/);
assert.doesNotMatch(result.answerText, /请点选/);
});
@@ -143,6 +143,7 @@ export function conversationSummaryFixture(overrides: {
export function activeFocusFixture(overrides: {
id?: string;
questionId?: string;
targetEvidenceId?: string | null;
intent?: string;
targetDomain?: string | null;
@@ -152,7 +153,7 @@ export function activeFocusFixture(overrides: {
return {
id: overrides.id ?? FOCUS_ID,
case_id: CASE_ID,
question_id: "question-1",
question_id: overrides.questionId ?? "question-1",
intent: overrides.intent ?? "confirm_revision",
target_evidence_id: overrides.targetEvidenceId ?? null,
target_domain: overrides.targetDomain ?? "career",