fix(rectification): persist the next interview after a choice tap
Independent Staging Quality Gate / validate (push) Successful in 13m56s
Independent Staging Quality Gate / publish (push) Successful in 16m46s

Closing a discriminator used to leave GET without a card after refresh.
Write the next dated question in the same request, skip childhood career
and move probes, and do not continue a read-only turn when that question
is already persisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-28 16:30:09 +08:00
co-authored by Cursor
parent 8c75ebb196
commit 68f0759eef
26 changed files with 2455 additions and 311 deletions
@@ -50,14 +50,13 @@ import { isNearBottom, shouldShowJumpToLatest } from "@/lib/rectification-sticky
import {
CHOICE_ACTION,
STOP_ACTION,
isStructuredChoiceUserText,
shouldContinueAfterStructuredChoice,
stableChoiceActionKey,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer";
import {
CHOICE_STOP_MESSAGE,
choiceCardUserMessage,
isPersistedFocusId,
parseRectificationChoiceCard,
type ChoiceKey,
@@ -164,12 +163,18 @@ type RectificationAgenticChatProps = Readonly<{
headerSlot: HTMLElement | null;
}>;
type SettledChoiceAttachment = Readonly<{
card: ChoiceCardModel;
selectedKey: ChoiceKey | "stop";
}>;
type RenderMessage = ChatMessageView & {
renderKey: string;
completedReceipt?: CompletedActivityReceiptView;
failed?: boolean;
turnId?: string;
activityTrace?: readonly AgentActivityTraceItem[];
choiceAttachment?: SettledChoiceAttachment;
};
function turnOfferedSelection(message: RenderMessage): boolean {
@@ -241,6 +246,7 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
}];
}
if (isIncompleteRunBanner(turn.text ?? "")) return [];
if (isStructuredChoiceUserText(turn.text)) return [];
return [{
role: "user",
text: turn.text ?? "",
@@ -517,6 +523,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
type?: unknown;
status?: unknown;
text?: unknown;
replace?: unknown;
message?: unknown;
tool?: unknown;
methods?: unknown;
@@ -531,7 +538,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
if (typeof event.type !== "string") continue;
if (event.type === "answer.delta" && typeof event.text === "string") {
raw += event.text;
raw = event.replace === true ? event.text : raw + event.text;
activityTrace = freezeLiveThink(activityTrace);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
@@ -747,18 +754,26 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
const questionId = choiceCard.question_id;
const actionId = actionIdForChoice(focusId, optionId);
const userText = optionId === "stop"
? (choiceCard.stop_message || CHOICE_STOP_MESSAGE)
: choiceCardUserMessage(choiceCard, optionId);
const answeredCard = choiceCard;
const offeringRenderKey = [...messages].reverse().find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text)
&& !message.choiceAttachment
))?.renderKey ?? null;
setError("");
setPending(true);
keyCounter.current += 1;
const turnKey = keyCounter.current;
const userRenderKey = `v9-choice-user-${turnKey}`;
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
setMessages((current) => [
...current,
{ role: "user", text: userText, renderKey: userRenderKey, state: "settled" },
...current.map((message) => (
offeringRenderKey && message.renderKey === offeringRenderKey
? {
...message,
choiceAttachment: { card: answeredCard, selectedKey: optionId },
}
: message
)),
{
role: "assistant",
text: "",
@@ -772,6 +787,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
},
},
]);
setPending(true);
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
@@ -793,9 +809,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
setMessages((current) => current.filter((message) => (
message.renderKey !== userRenderKey && message.renderKey !== assistantRenderKey
)));
setMessages((current) => current.flatMap((message) => {
if (message.renderKey === assistantRenderKey) return [];
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
return [{ ...message, choiceAttachment: undefined }];
}
return [message];
}));
setChoiceNonce((current) => current + 1);
if (payload?.code === "profile_incomplete") {
onProfileIncomplete?.();
@@ -804,30 +824,36 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setError(payload?.message || payload?.error || `请求失败(${response.status}`);
return;
}
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
: "已记录你的选择。";
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
text: narration,
state: "settled" as const,
activity: undefined,
}
: message));
onMessagesChange?.([
{ role: "user", text: userText },
{ role: "assistant", text: narration },
]);
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
onCompleted?.();
await loadCaseSnapshot();
if (shouldContinueAfterStructuredChoice(payload?.nextAction)) {
if (willContinue) {
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
choiceContinuationPending.current = true;
} else {
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
: "已记录你的选择。";
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
text: narration,
state: "settled" as const,
activity: undefined,
}
: message));
onMessagesChange?.([
{ role: "assistant", text: narration },
]);
}
} catch {
setMessages((current) => current.filter((message) => (
message.renderKey !== userRenderKey && message.renderKey !== assistantRenderKey
)));
setMessages((current) => current.flatMap((message) => {
if (message.renderKey === assistantRenderKey) return [];
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
return [{ ...message, choiceAttachment: undefined }];
}
return [message];
}));
setChoiceNonce((current) => current + 1);
setError("选择题处理失败,请稍后重试。");
} finally {
@@ -839,6 +865,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
caseId,
choiceCard,
loadCaseSnapshot,
messages,
onCompleted,
onMessagesChange,
onProfileIncomplete,
@@ -994,32 +1021,45 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
&& Boolean(message.text)
));
const offeredSelectionOnce = messages.some(turnOfferedSelection);
const offeredThisTurn = Boolean(latestSettledAssistant && turnOfferedSelection(latestSettledAssistant));
const showChoiceCards = Boolean(
const answeredQuestionIds = new Set(
messages.flatMap((message) => message.choiceAttachment
? [message.choiceAttachment.card.question_id]
: []),
);
const liveChoiceHost = [...messages]
.reverse()
.find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text)
&& !message.choiceAttachment
));
const showLiveChoiceCard = Boolean(
choiceCard
&& latestSettledAssistant
&& !offeredThisTurn
&& liveChoiceHost
&& !answeredQuestionIds.has(choiceCard.question_id)
&& !turnOfferedSelection(liveChoiceHost)
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
useLayoutEffect(() => {
choiceCardsOpen.current = showChoiceCards;
choiceCardsOpen.current = showLiveChoiceCard;
updateFollowState();
}, [showChoiceCards, updateFollowState]);
}, [showLiveChoiceCard, updateFollowState]);
const showSelectionCards = Boolean(
candidateResult?.selectionAllowed
&& offeredSelectionOnce
&& latestSettledAssistant
&& !showChoiceCards
&& !showLiveChoiceCard
&& !busy
&& regeneratingMessageKey === null,
);
const selectionCardMessageKey = showSelectionCards && latestSettledAssistant
? latestSettledAssistant.renderKey
: undefined;
const choiceCardMessageKey = showChoiceCards && latestSettledAssistant
? latestSettledAssistant.renderKey
const liveChoiceMessageKey = showLiveChoiceCard && liveChoiceHost
? liveChoiceHost.renderKey
: undefined;
const canSend = !busy && !readonly && !regeneratingMessageKey;
@@ -1084,6 +1124,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}),
}
: message;
const settledChoice = message.choiceAttachment;
const liveChoice = showLiveChoiceCard && message.renderKey === liveChoiceMessageKey
? choiceCard
: null;
const visibleChoiceCard = settledChoice?.card ?? liveChoice;
const vargaSentence = !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
: null;
@@ -1122,12 +1167,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onAccept={(candidateId) => void acceptCandidate(candidateId)}
/>
)}
{showChoiceCards && message.renderKey === choiceCardMessageKey && choiceCard && (
{visibleChoiceCard && (
<RectificationChoiceCard
key={`${choiceCard.question_id}:${choiceNonce}`}
card={choiceCard}
pending={busy}
disabled={readonly}
key={settledChoice
? `${visibleChoiceCard.question_id}:answered:${settledChoice.selectedKey}`
: `${visibleChoiceCard.question_id}:${choiceNonce}`}
card={visibleChoiceCard}
pending={Boolean(liveChoice) && busy}
disabled={readonly || Boolean(settledChoice)}
selectedKey={settledChoice?.selectedKey ?? ""}
onSelect={submitChoice}
onStop={submitStop}
/>
@@ -1177,7 +1225,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
disabled={!canSend}
placeholder={readonly
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: showChoiceCards
: showLiveChoiceCard
? "点上面的选项即可;想补一句细节再写"
: "继续说你记得的人生经历,或回答刚才的问题…"}
onChange={(event) => setDraft(event.target.value)}