fix(rectification): keep targeted collect cards tappable after snapshot reread (BUG-669~671)
Independent Staging Quality Gate / validate (push) Successful in 25m6s
Independent Staging Quality Gate / publish (push) Successful in 2m9s

GET dropped the A–D card when the recast had no choice_frame, so a refresh left a disabled card and a collect-wait. Project from the persisted copy, rebuild the keep frame, and send a dead choice to repair-exit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-13 21:50:35 +08:00
co-authored by Cursor
parent 3c75643b83
commit 192845ce2f
12 changed files with 426 additions and 11 deletions
@@ -68,6 +68,7 @@ import {
searchWindowFromSnapshot,
caseStageFromSnapshot,
interviewCollectWaiting,
interviewChoiceCardUnavailable,
interviewStopReasonFromSnapshot,
interviewSessionOutcomeFromSnapshot,
type RectificationCaseSnapshotPayload,
@@ -1482,6 +1483,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
latestLiveQuestion
&& latestLiveQuestion.options?.length === 4
&& currentQuestion?.focus_id === latestLiveQuestion.focus_id
&& choiceCard
&& choiceCard.focus_id === latestLiveQuestion.focus_id
&& (latestLiveQuestion.kind === "choice" || latestLiveQuestion.kind === "reverse_verify")
&& !busy
&& !readonly
@@ -1541,12 +1544,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? currentQuestion.prompt
: null;
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
const deadChoice = interviewChoiceCardUnavailable({
questionKind: currentQuestion?.kind,
hasChoiceCard: Boolean(choiceCard),
});
const liveQuestionOnMessages = Boolean(
latestSettledAssistant
&& latestSettledAssistant.question
&& currentQuestion
&& latestSettledAssistant.question.focus_id === currentQuestion.focus_id
&& !questionIsAnswered(latestSettledAssistant.question),
&& !questionIsAnswered(latestSettledAssistant.question)
&& !deadChoice,
);
// The gap between a settled turn and its next question has two visible
// states: `preparing` (one live timeline row, timed refetches) and
@@ -1562,11 +1570,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const questionGap = rectificationQuestionGapState({
liveQuestionVisible: liveQuestionOnMessages,
questionMissing: currentQuestion === null,
questionLoadFailed: questionSource === "unavailable",
questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus"),
questionLoadFailed: questionSource === "unavailable" || deadChoice,
questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice,
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
nextUserActionId,
collectWaiting,
collectWaiting: collectWaiting && !deadChoice,
busy,
readonly,
regenerating: regeneratingMessageKey !== null,
@@ -1765,7 +1773,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
)
),
);
const embeddedCard = question ? choiceCardFromQuestion(question, liveQuestion ? choiceCard : null) : null;
const unansweredDeadChoice = Boolean(
question
&& !questionIsAnswered(question)
&& !liveQuestion
&& (question.kind === "choice" || question.kind === "reverse_verify")
);
const embeddedCard = question && !unansweredDeadChoice
? choiceCardFromQuestion(question, liveQuestion ? choiceCard : null)
: null;
const afterAnswer = question && displayedMessage.state === "settled"
? (
<div className="rectification-message-question">
@@ -32,6 +32,7 @@ export const TARGETED_COLLECT_OPTION_B = "没有发生过";
export const TARGETED_COLLECT_OPTION_C = "记不太清楚";
export const TARGETED_COLLECT_OPTION_D = "这条先跳过";
export const TARGETED_COLLECT_WHY_USER = "答有的话再说大概年月,没有或记不清就问下一条。";
export const TARGETED_COLLECT_KEEP_HINT = "照发服务端卡片,不要自己写题干。";
export const FORBIDDEN_CHOICE_COPY = /外貌|体质|胎记|疤痕|伤疤|身高|体型|(?:[01]?\d|2[0-3]):[0-5]\d/;
export const FOCUS_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -533,6 +534,12 @@ export function serverOwnedChoiceCopy(frame: RectificationChoiceFrame): AgentCho
};
}
export function isTargetedCollectChoiceSchema(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
return (value as { targeted_collect?: unknown }).targeted_collect === true
&& parseAgentChoiceCopy(value) !== null;
}
export function parseAgentChoiceCopy(value: unknown): AgentChoiceCopy | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
@@ -660,6 +667,39 @@ export function choiceCardFromPersistedVerifyCopy(input: {
};
}
export function choiceCardFromPersistedTargetedCopy(input: {
copy: AgentChoiceCopy;
questionId: string;
methodId: string;
probeId: string | null;
caseRevision: number | null;
focusId: string;
}): RectificationChoiceCard | null {
if (!input.copy.prompt.trim() || !input.questionId.trim()) return null;
if (!isPersistedFocusId(input.focusId)) return null;
return {
question_id: input.questionId,
method_id: input.methodId,
prompt: input.copy.prompt,
why: "",
varga: null,
choice_mode: CHOICE_MODE,
options: input.copy.options.map((option) => ({
...option,
role: "primary" as const,
})),
stop_label: CHOICE_SKIP_QUESTION_LABEL,
stop_message: CHOICE_SKIP_QUESTION_MESSAGE,
scoring: false,
probe_id: input.probeId,
case_revision: input.caseRevision,
focus_id: input.focusId,
why_user: TARGETED_COLLECT_WHY_USER,
answer_impact: emptyAnswerImpact(),
choice_kind: "existence",
};
}
export function isHoldoutVerificationQuote(quote: string): boolean {
return quote.includes(HOLDOUT_MESSAGE_PREFIX);
}
@@ -3,7 +3,8 @@
*
* The tap card is shown when the rebuilt follow-up has a matching
* discriminator frame, or when the open focus is already a reverse-verify /
* out-of-sample AD schema (even if the recast has no choice_frame).
* out-of-sample / targeted-collect AD schema (even if the recast has no
* choice_frame, and even if persist added a `:next` suffix).
* Card identity is the persisted focus UUID plus the inference revision.
*/
@@ -73,11 +73,14 @@ import {
buildChoiceFrame,
isPersistedFocusId,
parseAgentChoiceCopy,
isTargetedCollectChoiceSchema,
preferConcreteChoicePrompt,
mergeChoiceCard,
choiceCardFromPersistedVerifyCopy,
choiceCardFromPersistedTargetedCopy,
serverOwnedChoiceCopy,
buildTargetedCollectExistenceFrame,
TARGETED_COLLECT_KEEP_HINT,
type RectificationChoiceCard,
type RectificationChoiceFrame,
} from "./choice-card.ts";
@@ -634,6 +637,24 @@ const PROBE_METHOD_ID = {
health_pressure: "d30_health",
} as const;
function targetedCollectDomainFromFocus(focus: {
questionId?: string | null;
targetDomain?: string | null;
}): string | null {
if (typeof focus.targetDomain === "string" && focus.targetDomain.trim()) {
return focus.targetDomain.trim();
}
const match = /^collect:targeted:([^:]+)/.exec(focus.questionId?.trim() ?? "");
return match?.[1] ?? null;
}
function targetedCollectMethodId(domain: string | null): MethodFollowup["method_id"] {
if (domain && domain in PROBE_METHOD_ID) {
return PROBE_METHOD_ID[domain as keyof typeof PROBE_METHOD_ID];
}
return "dasha_events";
}
function contrastFollowupDomain(
domain: string | null,
): keyof typeof REVERSE_VERIFY_THEME {
@@ -2214,6 +2235,49 @@ export function buildMethodFollowupPlan(input: {
const acceptedKeepHint = input.accepted
? "当前排盘已采用该时间。现在按该分钟核对。"
: "";
const targetedCopy = isTargetedCollectChoiceSchema(focus.expectedAnswerSchema)
? parseAgentChoiceCopy(focus.expectedAnswerSchema)
: null;
if (focus.intent === "collect_method_evidence" && targetedCopy) {
const questionId = focus.questionId?.trim() ?? "";
const domain = targetedCollectDomainFromFocus(focus);
const methodId = targetedCollectMethodId(domain);
const frame = questionId
? buildTargetedCollectExistenceFrame({
questionId,
methodId,
prompt: targetedCopy.prompt,
})
: null;
if (frame) {
return {
methods,
next_followup: {
method_id: methodId,
intent: "collect_method_evidence",
ask_theme: domain && domain in REVERSE_VERIFY_THEME
? REVERSE_VERIFY_THEME[domain as keyof typeof REVERSE_VERIFY_THEME]
: "dated_event",
domain,
kind_hint: collectKindFromFocus(focus),
user_prompt_hint: TARGETED_COLLECT_KEEP_HINT,
must_not_label: false,
choice_frame: frame,
choice_kind: "existence",
source: "active_focus",
collection_key: questionId,
spoken_prompt: targetedCopy.prompt,
invite_more_once: false,
},
deferred_followup: null,
session_outcome: sessionOutcome ?? "collect_evidence",
stop_domain_rotation: true,
do_not_poll: DO_NOT_POLL,
not_in_rotation: NOT_IN_ROTATION,
dropped_probes: rankedCatalog.dropped,
};
}
}
const keepNext = keepAcceptedFocus
? makeFollowup((() => {
const parsedId = parsePersistedFollowupQuestionId(focus.questionId);
@@ -2947,15 +3011,15 @@ export function projectRectificationChoiceCard(
const schemaCopy = parseAgentChoiceCopy(schema);
const intent = input.activeFocus?.intent ?? "";
const verifyOnly = intent === "reverse_verify" || intent === "out_of_sample_check";
const schemaProbeId = schema && typeof schema === "object" && typeof (schema as { probe_id?: unknown }).probe_id === "string"
? (schema as { probe_id: string }).probe_id
: null;
// Recast can drop choice_frame when the schema has no probe_year. The open
// AD schema is still the live question; GET must not silence it.
const persistedVerifyCard = (): RectificationChoiceCard | null => {
if (!verifyOnly || !schemaCopy) return null;
const parsed = parsePersistedFollowupQuestionId(input.activeFocus?.questionId);
const questionId = input.activeFocus?.questionId?.trim() ?? "";
const probeId = schema && typeof schema === "object" && typeof (schema as { probe_id?: unknown }).probe_id === "string"
? (schema as { probe_id: string }).probe_id
: null;
return choiceCardFromPersistedVerifyCopy({
copy: {
...schemaCopy,
@@ -2965,11 +3029,31 @@ export function projectRectificationChoiceCard(
methodId: parsed?.method_id
?? (intent === "out_of_sample_check" ? "oos_blind" : "reverse_verify"),
scoring: parsed?.scoring !== false,
probeId,
probeId: schemaProbeId,
caseRevision: input.caseRevision ?? null,
focusId,
});
};
// Targeted collect is the first collect + AD combination. GET must project
// from the persisted copy even when the plan recast has no frame, and even
// when persist added :next to the question id.
const persistedTargetedCard = (): RectificationChoiceCard | null => {
if (!isTargetedCollectChoiceSchema(schema) || !schemaCopy) return null;
const questionId = input.activeFocus?.questionId?.trim() ?? "";
return choiceCardFromPersistedTargetedCopy({
copy: schemaCopy,
questionId,
methodId: targetedCollectMethodId(targetedCollectDomainFromFocus({
questionId,
targetDomain: input.activeFocus?.targetDomain,
})),
probeId: schemaProbeId,
caseRevision: input.caseRevision ?? null,
focusId,
});
};
const targetedCard = persistedTargetedCard();
if (targetedCard) return targetedCard;
const followup = plan.next_followup ?? plan.deferred_followup ?? null;
if (!followup) return persistedVerifyCard();
const frame = followup.choice_frame;
@@ -347,6 +347,15 @@ export function interviewSessionOutcomeFromSnapshot(payload: RectificationCaseSn
return null;
}
export function interviewChoiceCardUnavailable(input: Readonly<{
questionKind?: string | null;
hasChoiceCard: boolean;
}>): boolean {
// A choice question without a GET card is a dead tap target, not a
// collect-wait. The repair-exit path must take over.
return input.questionKind === "choice" && !input.hasChoiceCard;
}
export function interviewCollectWaiting(input: Readonly<{
stopReason?: string | null;
sessionOutcome?: string | null;