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
@@ -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;