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
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)}
@@ -10,42 +10,48 @@ type RectificationChoiceCardProps = Readonly<{
card: ChoiceCard;
pending: boolean;
disabled: boolean;
selectedKey?: ChoiceKey | "stop" | "";
onSelect: (key: ChoiceKey) => void;
onStop: () => void;
}>;
export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
const [selectedKey, setSelectedKey] = useState<ChoiceKey | "stop" | "">("");
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "">("");
const selectedKey = props.selectedKey || localSelected;
const firstChoiceRef = useRef<HTMLButtonElement | null>(null);
const primary = props.card.options.filter((option) => option.role === "primary");
const secondary = props.card.options.filter((option) => option.role === "secondary");
const answered = Boolean(selectedKey);
useEffect(() => {
if (!props.pending) firstChoiceRef.current?.focus();
}, [props.card.question_id, props.pending]);
if (props.pending || answered) return;
firstChoiceRef.current?.focus();
}, [answered, props.card.question_id, props.pending]);
function select(key: ChoiceKey) {
if (props.pending || props.disabled || selectedKey) return;
setSelectedKey(key);
setLocalSelected(key);
props.onSelect(key);
}
function stop() {
if (props.pending || props.disabled || selectedKey) return;
setSelectedKey("stop");
setLocalSelected("stop");
props.onStop();
}
return (
<section
className="rectification-choice-card"
className={`rectification-choice-card${props.pending ? " is-pending" : ""}${answered ? " is-answered" : ""}`}
aria-label={props.card.scoring ? "校正判断" : "盘外核对"}
>
<fieldset className="birth-time-choice-question" disabled={props.pending || props.disabled || Boolean(selectedKey)}>
<fieldset
className="birth-time-choice-question"
disabled={props.pending || props.disabled || answered}
data-answered={answered ? "true" : "false"}
>
<legend>{props.card.prompt}</legend>
{props.card.why ? <p className="rectification-choice-why">{props.card.why}</p> : null}
<div className="birth-time-primary-choices">
{primary.map((option, index) => (
{props.card.options.map((option, index) => (
<button
key={option.key}
ref={index === 0 ? firstChoiceRef : undefined}
@@ -57,22 +63,9 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
<strong>{option.key}.</strong> {option.label}
</button>
))}
</div>
<div className="birth-time-special-choices">
{secondary.map((option) => (
<button
key={option.key}
type="button"
className="birth-time-choice-option is-secondary"
data-selected={selectedKey === option.key ? "true" : "false"}
onClick={() => select(option.key)}
>
{option.key}. {option.label}
</button>
))}
<button
type="button"
className="birth-time-choice-option is-secondary"
className="birth-time-choice-option is-primary"
data-selected={selectedKey === "stop" ? "true" : "false"}
onClick={stop}
>