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);
}
}
}