fix(rectification): bind collect-denial follow-up to the asked turn
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user