fix(rectification): gate collect-phase adopt cards and keep post-adopt verify answerable
Independent Staging Quality Gate / validate (push) Successful in 9m50s
Independent Staging Quality Gate / publish (push) Successful in 1m53s

Public can_adopt follows session_outcome; reverse_verify reuses the persisted question id; offer cards settle on the owning message with a status-bar handoff.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-03 09:29:34 +08:00
co-authored by Cursor
parent 5c0bec0c94
commit 35e5781e66
31 changed files with 1774 additions and 109 deletions
@@ -19,6 +19,7 @@ import {
RECTIFICATION_TOOL_PROGRESS_LABELS,
activityTraceFromReceipt,
rectificationCompletedTrail,
rectificationLiveProgressLabel,
rectificationToolActivityPhase,
} from "@/lib/rectification-activity-labels";
import {
@@ -28,7 +29,8 @@ import {
type CompletedActivityReceiptView,
} from "@/lib/rectification-activity-receipt";
import {
canRenderRectificationSelectionCards,
canShowRectificationReadonlyRange,
canShowRectificationSelectionCards,
isRecommendedRectificationCandidate,
natalRecastMeaning,
parseRectificationCandidateResult,
@@ -57,7 +59,7 @@ import {
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import { CHOICE_MODE, CHOICE_STOP_LABEL, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { CharacterRemaining } from "./character-remaining";
@@ -80,6 +82,7 @@ type PersistedTurn = Readonly<{
text: string | null;
status: string;
question?: unknown;
offer_result_id?: string | null;
receipt?: Readonly<{
status: string;
phases: readonly string[];
@@ -87,6 +90,9 @@ type PersistedTurn = Readonly<{
tool: string;
status: string;
methods?: readonly string[];
started_at?: string | null;
elapsed_ms?: number | null;
detail?: Readonly<Record<string, unknown>> | null;
}>[];
tools: readonly string[];
methods?: readonly string[];
@@ -109,9 +115,11 @@ type CurrentQuestionModel = Readonly<{
function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const question = value as { kind?: unknown; prompt?: unknown; focus_id?: unknown; question_id?: unknown };
if (question.kind !== "choice" && question.kind !== "collect_spoken") return null;
if (question.kind !== "choice" && question.kind !== "collect_spoken" && question.kind !== "reverse_verify") {
return null;
}
return {
kind: question.kind,
kind: question.kind === "collect_spoken" ? "collect_spoken" : "choice",
prompt: typeof question.prompt === "string" && question.prompt.trim()
? question.prompt.trim()
: null,
@@ -126,6 +134,16 @@ function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | n
return null;
}
function RectificationReadonlyRange({
range,
}: Readonly<{ range: readonly [string, string] }>) {
return (
<p className="rectification-readonly-range" role="status">
{range[0]}{range[1]}
</p>
);
}
function RectificationCandidateCards({
result,
acceptingCandidateId,
@@ -200,20 +218,31 @@ type RenderMessage = ChatMessageView & {
turnId?: string;
activityTrace?: readonly AgentActivityTraceItem[];
question?: TurnQuestion;
candidateOffer?: Readonly<{ resultId: string }>;
};
function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] {
const byId = new Map<string, TurnQuestion | null>();
const byId = new Map<string, { question: TurnQuestion | null; offerResultId: string | null }>();
for (const item of turns) {
if (!item || typeof item !== "object") continue;
const turn = item as { id?: unknown; question?: unknown };
const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown };
if (typeof turn.id !== "string") continue;
byId.set(turn.id, parseTurnQuestion(turn.question));
byId.set(turn.id, {
question: parseTurnQuestion(turn.question),
offerResultId: typeof turn.offer_result_id === "string" ? turn.offer_result_id : null,
});
}
return current.map((message) => {
if (!message.turnId || !byId.has(message.turnId)) return message;
const question = byId.get(message.turnId) ?? undefined;
return { ...message, question: question ?? undefined };
const next = byId.get(message.turnId);
const question = next?.question ?? undefined;
return {
...message,
question: question ?? undefined,
candidateOffer: next?.offerResultId
? { resultId: next.offerResultId }
: message.candidateOffer,
};
});
}
@@ -271,8 +300,8 @@ function choiceCardFromQuestion(
answer_class: "unsure",
role: "primary",
})),
stop_label: CHOICE_STOP_LABEL,
stop_message: "先这样",
stop_label: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL,
stop_message: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
scoring: question.kind !== "reverse_verify",
probe_id: question.probe_id,
case_revision: null,
@@ -342,6 +371,9 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
failed,
turnId: turn.id,
question: parseTurnQuestion(turn.question) ?? undefined,
candidateOffer: typeof turn.offer_result_id === "string"
? { resultId: turn.offer_result_id }
: undefined,
}];
}
if (isIncompleteRunBanner(turn.text ?? "")) return [];
@@ -404,6 +436,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const runAbort = useRef<AbortController | null>(null);
const choiceActionIds = useRef(new Map<string, string>());
const currentQuestionRef = useRef(currentQuestion);
const offerSectionRef = useRef<HTMLDivElement | null>(null);
const runStartedAtRef = useRef(0);
const stepStartedAtRef = useRef(0);
const liveToolRef = useRef<string | null>(null);
const liveBaseLabelRef = useRef("正在处理…");
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
@@ -447,6 +484,75 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onPendingChange?.(value);
}, [onPendingChange]);
const beginLiveRun = useCallback((baseLabel: string) => {
const now = Date.now();
runStartedAtRef.current = now;
stepStartedAtRef.current = now;
liveToolRef.current = null;
liveBaseLabelRef.current = baseLabel;
}, []);
const rememberLiveActivity = useCallback((baseLabel: string, tool: string | null = liveToolRef.current) => {
if (liveBaseLabelRef.current !== baseLabel) {
liveBaseLabelRef.current = baseLabel;
stepStartedAtRef.current = Date.now();
}
liveToolRef.current = tool;
return rectificationLiveProgressLabel({
tool,
baseLabel,
stepStartedAt: stepStartedAtRef.current,
runStartedAt: runStartedAtRef.current,
});
}, []);
useEffect(() => {
if (!busy) return;
const id = window.setInterval(() => {
const label = rectificationLiveProgressLabel({
tool: liveToolRef.current,
baseLabel: liveBaseLabelRef.current,
stepStartedAt: stepStartedAtRef.current,
runStartedAt: runStartedAtRef.current,
});
setMessages((current) => current.map((message) => {
if (message.state !== "thinking" && message.state !== "streaming") return message;
if (!message.activity || message.activity.label === label) return message;
return { ...message, activity: { ...message.activity, label } };
}));
}, 4000);
return () => window.clearInterval(id);
}, [busy]);
useEffect(() => {
if (!candidateResult?.resultId) return;
const canOffer = canShowRectificationSelectionCards(candidateResult);
const adopted = Boolean(candidateResult.selectedTime);
if (!canOffer && !adopted) return;
const resultId = candidateResult.resultId;
queueMicrotask(() => {
setMessages((current) => {
if (current.some((message) => message.candidateOffer?.resultId === resultId)) return current;
if (adopted && current.some((message) => message.candidateOffer)) {
return current.map((message) => (
message.candidateOffer ? { ...message, candidateOffer: { resultId } } : message
));
}
if (!canOffer || adopted) return current;
const owner = [...current].reverse().find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text)
&& !message.failed
));
if (!owner) return current;
return current.map((message) => (
message.renderKey === owner.renderKey ? { ...message, candidateOffer: { resultId } } : message
));
});
});
}, [candidateResult]);
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
// from parsing agent text or hidden sentinels.
const applyCaseSnapshot = useCallback((payload: {
@@ -529,6 +635,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if ((action === "message" && !trimmed) || busy || readonly) return;
setError("");
setPending(true);
beginLiveRun("正在处理…");
keyCounter.current += 1;
const requestId = globalThis.crypto.randomUUID();
@@ -658,14 +765,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activityTrace = freezeLiveThink(activityTrace);
currentActivity = nextActivityView(currentActivity, {
phase: "answer-composition",
label: "正在组织回答…",
label: rememberLiveActivity("正在组织回答…"),
});
frames.setAnswer(raw);
} else if (event.type === "activity.changed" && isPublicRectificationActivity(event.activity)) {
const activity = event.activity;
currentActivity = nextActivityView(currentActivity, {
phase: "evidence-validation",
label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity],
label: rememberLiveActivity(RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity]),
});
frames.touch();
} else if (event.type === "attempt.reset") {
@@ -676,7 +783,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
completedTurnId = undefined;
currentActivity = nextActivityView(undefined, {
phase: "evidence-validation",
label: "正在处理…",
label: rememberLiveActivity("正在处理…", null),
});
frames.reset();
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
@@ -714,7 +821,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
);
currentActivity = nextActivityView(currentActivity, {
phase: rectificationToolActivityPhase(tool),
label: RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
label: rememberLiveActivity(RECTIFICATION_TOOL_PROGRESS_LABELS[tool], tool),
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
});
frames.touch();
@@ -736,7 +843,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
currentActivity = nextActivityView(currentActivity, {
phase: rectificationToolActivityPhase(tool),
label: RECTIFICATION_TOOL_DONE_LABELS[tool],
label: rememberLiveActivity(RECTIFICATION_TOOL_DONE_LABELS[tool], tool),
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
});
frames.touch();
@@ -833,7 +940,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (runAbort.current === abortController) runAbort.current = null;
setPending(false);
}
}, [busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]);
}, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]);
const actionIdForChoice = useCallback((focusId: string, optionId: ChoiceOptionId) => {
const key = stableChoiceActionKey(focusId, optionId);
@@ -847,19 +954,29 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const submitStructuredChoice = useCallback(async (
action: typeof CHOICE_ACTION | typeof STOP_ACTION,
optionId: ChoiceKey | "stop",
override?: Readonly<{
focusId: string;
questionId: string | null;
probeId?: string | null;
caseRevision?: number | null;
}>,
) => {
if (!choiceCard || busy || readonly) return;
const focusId = choiceCard.focus_id;
const focusId = override?.focusId ?? choiceCard?.focus_id;
if (!focusId || busy || readonly) return;
if (!override && !choiceCard) return;
if (!isPersistedFocusId(focusId)) {
setError("当前选择题已失效,请等待下一问。");
return;
}
const questionId = choiceCard.question_id;
const questionId = override?.questionId ?? choiceCard?.question_id;
const probeId = override?.probeId ?? choiceCard?.probe_id;
const expectedRevision = override?.caseRevision ?? choiceCard?.case_revision ?? 0;
const actionId = actionIdForChoice(focusId, optionId);
setError("");
keyCounter.current += 1;
const turnKey = keyCounter.current;
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
beginLiveRun(RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer);
setMessages((current) => [
...markQuestionAnswered(current, focusId, optionId),
{
@@ -888,9 +1005,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
actionId,
focusId,
questionId,
probeId: choiceCard.probe_id,
probeId: probeId ?? null,
optionId: optionId === "stop" ? undefined : optionId,
expectedRevision: choiceCard.case_revision ?? 0,
expectedRevision,
origin: "choice_click",
clientActionId: actionId,
}),
@@ -950,8 +1067,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setPending(false);
}
}, [
actionIdForChoice,
busy,
beginLiveRun,
caseId,
choiceCard,
loadCaseSnapshot,
@@ -1119,26 +1235,40 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
latestLiveQuestion
&& latestLiveQuestion.options?.length === 4
&& currentQuestion?.focus_id === latestLiveQuestion.focus_id
&& (latestLiveQuestion.kind === "choice" || latestLiveQuestion.kind === "reverse_verify")
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
const canOfferCards = canShowRectificationSelectionCards(candidateResult);
const persistedOfferKey = messages.find((message) => message.candidateOffer)?.renderKey;
const selectionCardMessageKey = persistedOfferKey
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
const showSelectionCards = Boolean(
candidateResult?.selectionAllowed
&& candidateResult?.canAdopt
&& canRenderRectificationSelectionCards(candidateResult)
candidateResult
&& selectionCardMessageKey
&& (canOfferCards || Boolean(candidateResult.selectedTime)),
);
const showReadonlyRange = Boolean(
canShowRectificationReadonlyRange(candidateResult)
&& latestSettledAssistant
&& !showLiveChoiceCard
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
&& !showSelectionCards,
);
const selectionCardMessageKey = showSelectionCards && latestSettledAssistant
? latestSettledAssistant.renderKey
: undefined;
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
? currentQuestion.prompt
: null;
const showCollectStop = Boolean(
latestLiveQuestion
&& latestLiveQuestion.kind === "collect_spoken"
&& currentQuestion?.focus_id === latestLiveQuestion.focus_id
&& !questionIsAnswered(latestLiveQuestion)
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
const adoptedRangeLabel = candidateResult?.credibleRange
? `${candidateResult.credibleRange[0]}${candidateResult.credibleRange[1]}`
: null;
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
const liveQuestionOnMessages = messages.some((message) => (
message.role === "assistant"
@@ -1185,6 +1315,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
void submitStructuredChoice(STOP_ACTION, "stop");
}
function submitCollectStop() {
if (!latestLiveQuestion || latestLiveQuestion.kind !== "collect_spoken") return;
void submitStructuredChoice(STOP_ACTION, "stop", {
focusId: latestLiveQuestion.focus_id,
questionId: latestLiveQuestion.question_id,
probeId: latestLiveQuestion.probe_id,
caseRevision: choiceCard?.case_revision ?? 0,
});
}
const boardPeek = compactBoard && !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
@@ -1254,11 +1394,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
question
&& currentQuestion?.focus_id === question.focus_id
&& !questionIsAnswered(question)
&& choiceCard
&& choiceCard.focus_id === question.focus_id
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
&& regeneratingMessageKey === null
&& (
question.kind === "collect_spoken"
|| (
choiceCard
&& choiceCard.focus_id === question.focus_id
&& (question.kind === "choice" || question.kind === "reverse_verify")
)
),
);
const embeddedCard = question ? choiceCardFromQuestion(question, liveQuestion ? choiceCard : null) : null;
const afterAnswer = question && displayedMessage.state === "settled"
@@ -1277,11 +1423,20 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onStop={submitStop}
/>
)}
{question.status === "skipped" && (
<p className="rectification-question-skipped" role="status">
{savedTime ? `已跳过(已采用 ${savedTime}` : "已跳过"}
</p>
)}
</div>
)
: undefined;
return (
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
<div
key={message.renderKey}
className="rectification-message-wrap rectification-message-entry"
ref={showSelectionCards && message.renderKey === selectionCardMessageKey ? offerSectionRef : undefined}
>
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
<ChatMessageRow
message={displayedMessage}
@@ -1307,10 +1462,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<RectificationCandidateCards
result={candidateResult}
acceptingCandidateId={acceptingCandidateId}
readonly={readonly}
readonly={readonly || busy || regeneratingMessageKey !== null}
onAccept={(candidateId) => void acceptCandidate(candidateId)}
/>
)}
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
<RectificationReadonlyRange range={candidateResult.credibleRange} />
)}
</div>
);
})}
@@ -1319,13 +1477,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{savedTime}
</p>
)}
{savedTime && savedStatus === "accepted" && onStartConsultation && (
<div className="rectification-consult-handoff">
<Button type="button" onClick={onStartConsultation}>
</Button>
</div>
)}
{error && <p className="error-message" role="alert">{error}</p>}
{readonly && (
<div className="rectification-terminal-actions">
@@ -1340,6 +1491,22 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{!conversationAnchor.anchored && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
{savedTime && savedStatus === "accepted" && (
<div className="rectification-adopt-status" role="status">
<span>
{savedTime}
{adoptedRangeLabel ? ` · 范围 ${adoptedRangeLabel}` : ""}
</span>
{showSelectionCards && (
<span className="rectification-adopt-status__link"></span>
)}
{onStartConsultation && (
<Button type="button" variant="outline" onClick={onStartConsultation}>
</Button>
)}
</div>
)}
{(showMissingQuestion || showUnavailableQuestion || showQuestionLoadFailed) && (
<p className="rectification-composer-status" role="status">
{showQuestionLoadFailed
@@ -1378,6 +1545,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}}
onStop={stopRun}
/>
{showCollectStop && (
<button
type="button"
className="rectification-collect-stop"
disabled={!canSend}
onClick={submitCollectStop}
>
{CHOICE_STOP_LABEL}
</button>
)}
<div className="composer-footer">
<ModelSelector