fix(rectification): bind collect-denial follow-up to the asked turn
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Saying no to a spoken collect left the next stem only on current_question, so the preparing spinner never cleared. Persist the ack, link asked_turn_id, and emit run.completed with turnId (BUG-525).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-04 16:27:53 +08:00
co-authored by Cursor
parent c140191357
commit 41f973f036
9 changed files with 313 additions and 25 deletions
@@ -13,6 +13,7 @@ import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectifica
import {
applyRectificationChoice,
applyCollectFocusDenial,
persistCollectDenialTurn,
persistNextInterviewIfIdle,
} from "@/lib/rectification-agentic/v9/answer-choice";
import { createAdoptNarrationWriter } from "@/lib/rectification-agentic/v9/adopt-narration-agent";
@@ -52,10 +53,18 @@ import {
export const runtime = "nodejs";
export const maxDuration = 240;
function completedMessageResponse(text: string, requestId: string, caseId: string) {
function completedMessageResponse(
text: string,
requestId: string,
caseId: string,
turnId?: string | null,
) {
const completed = turnId
? { type: "run.completed", turnId }
: { type: "run.completed" };
const body = [
JSON.stringify({ type: "answer.delta", text }),
JSON.stringify({ type: "run.completed" }),
JSON.stringify(completed),
"",
].join("\n");
return new Response(body, {
@@ -363,12 +372,12 @@ export async function POST(request: Request) {
}
if (!classified || classified.intent === "unclear") {
const narration = RECTIFICATION_USER_COPY.unclearFocusReply;
await persistV9DeterministicTurn(accounting, userId, caseId, {
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: narration,
});
return completedMessageResponse(narration, requestId, caseId);
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
}
if (classified.intent === "answer_current_focus") {
if (!classified.answer_class) {
@@ -398,7 +407,7 @@ export async function POST(request: Request) {
narrateAdopt,
});
if (!continueToAgent) {
return completedMessageResponse(applied.narration, requestId, caseId);
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
}
if (classified.intent === "stop_rectification") {
@@ -419,7 +428,7 @@ export async function POST(request: Request) {
userDisplay: parsed.data.message ?? null,
});
await transitionV9CaseStatus(accounting, userId, caseId, "paused");
return completedMessageResponse(applied.narration, requestId, caseId);
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
} else if (focus && isCollectFocusSchema(focus.expectedAnswerSchema)) {
let classified = null;
@@ -443,12 +452,15 @@ export async function POST(request: Request) {
narrateAdopt,
});
if (!continueToAgent) {
await persistV9DeterministicTurn(accounting, userId, caseId, {
const finished = await persistCollectDenialTurn({
accounting,
userId,
caseId,
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: applied.narration,
applied,
});
return completedMessageResponse(applied.narration, requestId, caseId);
return completedMessageResponse(finished.streamText, requestId, caseId, finished.turnId);
}
}
} else {
@@ -468,12 +480,12 @@ export async function POST(request: Request) {
narrateAdopt,
});
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision);
await persistV9DeterministicTurn(accounting, userId, caseId, {
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage,
});
return completedMessageResponse(assistantMessage, requestId, caseId);
return completedMessageResponse(assistantMessage, requestId, caseId, turn.turnId);
}
if (decision.nextAction === "ask_candidate_discriminator") {
const catalog = rectificationFollowupCatalog(
@@ -529,16 +541,16 @@ export async function POST(request: Request) {
expectedRevision: previous?.revision ?? 0,
userDisplay: parsed.data.message ?? null,
});
return completedMessageResponse(applied.narration, requestId, caseId);
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
}
const narration = RECTIFICATION_USER_COPY.choicePrompt;
await persistV9DeterministicTurn(accounting, userId, caseId, {
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: narration,
});
return completedMessageResponse(narration, requestId, caseId);
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
}
if (!plan.next_followup) {
const idle = await persistNextInterviewIfIdle({
@@ -548,12 +560,12 @@ export async function POST(request: Request) {
narrateAdopt,
});
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision);
await persistV9DeterministicTurn(accounting, userId, caseId, {
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage,
});
return completedMessageResponse(assistantMessage, requestId, caseId);
return completedMessageResponse(assistantMessage, requestId, caseId, turn.turnId);
}
}
}
@@ -645,7 +645,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
setCandidateResult(nextCandidate);
setCurrentQuestion(nextQuestion);
if (nextQuestion !== null) setQuestionRetryAttempts(0);
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
setChoiceCard(nextChoice);
setCaseStatus(nextCaseStatus);
@@ -62,6 +62,7 @@ export const RECTIFICATION_USER_COPY = {
adoptCue: "可以从下面选一个先用着。",
hostNarrationFallback: "我按现有材料继续往下收。",
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
collectDeclinedAck: "记下了,这方面先跳过。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
} as const;
@@ -216,6 +217,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.adoptCue,
RECTIFICATION_USER_COPY.hostNarrationFallback,
RECTIFICATION_USER_COPY.continueCollectFallback,
RECTIFICATION_USER_COPY.collectDeclinedAck,
RECTIFICATION_USER_COPY.uncertaintyStop,
RECTIFICATION_USER_COPY.tiedFirstStop,
...Object.values(USER_COLLECT_QUESTION),
@@ -53,6 +53,7 @@ import {
type AdoptNarrationWriter,
} from "./adopt-narration.ts";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus";
import { composeCollectSpokenAssistantText } from "./collect-prompt";
import {
blockingMethodsCovered,
buildMethodFollowupPlan,
@@ -192,6 +193,7 @@ export type AppliedChoiceReceipt = Readonly<{
nextAction: ReturnType<typeof publicNextAction>;
nextInterviewPersisted: boolean;
nextChoiceReady: boolean;
turnId: string | null;
}>;
function asText(value: unknown): string | null {
@@ -643,7 +645,12 @@ export async function applyCollectFocusDenial(
deferFollowup?: boolean;
narrateAdopt?: AdoptNarrationWriter;
},
): Promise<{ narration: string; nextInterviewPersisted: boolean; nextChoiceReady: boolean }> {
): Promise<{
narration: string;
nextInterviewPersisted: boolean;
nextChoiceReady: boolean;
focus: ConversationFocus | null;
}> {
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
const focus = dossier.conversationSummary.activeFocus;
if (!focus || focus.id !== input.focusId) {
@@ -656,9 +663,10 @@ export async function applyCollectFocusDenial(
});
if (input.deferFollowup === true) {
return {
narration: "记下了,这方面先跳过。",
narration: RECTIFICATION_USER_COPY.collectDeclinedAck,
nextInterviewPersisted: false,
nextChoiceReady: false,
focus: null,
};
}
let birthDate: string | null = null;
@@ -701,9 +709,57 @@ export async function applyCollectFocusDenial(
narration: nextInterview.hostNarration,
nextInterviewPersisted: nextInterview.persisted === true || nextInterview.choiceReady,
nextChoiceReady: nextInterview.choiceReady,
focus: nextInterview.focus ?? null,
};
}
export type CollectDenialApplied = Awaited<ReturnType<typeof applyCollectFocusDenial>>;
/**
* After a collect "没有", persist the deterministic turn then bind the next
* focus to that turn so the stem can hang on the message. Stream only the
* acknowledgment; the server-owned stem joins via asked_turn_id (BUG-525).
*/
export async function persistCollectDenialTurn(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
requestId: string;
userMessage: string | null;
applied: CollectDenialApplied;
}): Promise<{ streamText: string; turnId: string }> {
const hasNextStem = Boolean(
input.applied.focus
&& (input.applied.nextInterviewPersisted || input.applied.nextChoiceReady)
&& input.applied.narration.trim(),
);
const stored = hasNextStem
? composeCollectSpokenAssistantText(RECTIFICATION_USER_COPY.collectDeclinedAck, input.applied.narration)
: input.applied.narration;
const streamText = hasNextStem ? RECTIFICATION_USER_COPY.collectDeclinedAck : input.applied.narration;
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: input.requestId,
userMessage: input.userMessage,
assistantMessage: stored,
});
if (input.applied.focus) {
try {
await linkFocusAskedTurn({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
focus: input.applied.focus,
askedTurnId: turn.turnId,
});
} catch (error) {
console.warn(
`[rectification-v9] link collect-denial focus to turn failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
);
}
}
return { streamText, turnId: turn.turnId };
}
export function isStalePreAdoptFocus(
acceptedTime: string | null | undefined,
focus: { intent?: string | null } | null | undefined,
@@ -946,6 +1002,7 @@ async function persistApplied(
let hostNarration = input.narration;
let skippedNextInterview = false;
let nextFocus: ConversationFocus | null = null;
let turnId: string | null = null;
if (
command.deferFollowup !== true
&& input.userStopped !== true
@@ -1016,6 +1073,7 @@ ${nonConvergingRangeNarration({
});
narrationPersisted = true;
if (turn.turnId) {
turnId = turn.turnId;
try {
const focus = nextFocus ?? (await loadV9CaseDossier(accounting, command.userId, command.caseId))
.conversationSummary.activeFocus;
@@ -1071,6 +1129,7 @@ ${nonConvergingRangeNarration({
nextAction,
nextInterviewPersisted,
nextChoiceReady,
turnId,
};
}