fix(rectification): one stem per turn, one card per focus (BUG-1045/1046)

BUG-1045 (recurrence of BUG-585 via BUG-969): the snapshot merge now drops
the attached stem from the streamed "ack + stem" text with the same
stripQuestionSentences the GET route uses; server text unchanged.

BUG-1046: a failed choice submit (409 / network) or typed send withdraws
the local answered mark, remounts the card, re-reads the Case and shows
"这次没提交上,请再点一次。"; the persisted question hangs on the latest
settled assistant message with other copies of the same focus removed
(standalone block only when nothing can carry it); willContinue and the
send() settle merge unseen assistant turns (same gap as BUG-685).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-26 10:49:25 +08:00
co-authored by Claude Opus 5.5
parent 07998972e0
commit e4c1c7a342
18 changed files with 906 additions and 18 deletions
@@ -58,6 +58,7 @@ import {
RECTIFICATION_QUESTION_RETRY_LIMIT,
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
RECTIFICATION_SNAPSHOT_RETRY_LABEL,
RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY,
RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY,
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
RECTIFICATION_DELIVERED_COPY,
@@ -125,6 +126,7 @@ import {
parseTurnQuestion,
questionIsAnswered,
resolveSelectionCardMessageKey,
turnQuestionFromCurrent,
type SelectionCardLock,
} from "@/lib/rectification-agentic/v9/turn-question";
import { Button } from "@/components/ui/button";
@@ -132,6 +134,7 @@ import {
appendUnseenAssistantTurns,
applySnapshotTurnsToMessages,
mergeTurnQuestions,
placePersistedQuestion,
} from "@/lib/rectification-snapshot-messages";
type PersistedTurn = Readonly<{
@@ -261,6 +264,29 @@ function markQuestionAnswered(
});
}
/**
* BUG-1046: undo `markQuestionAnswered` when the answer never reached the
* server, so the question is active again with no option picked. Only the mark
* this action made is undone; a server-resolved question is left alone.
*/
function withdrawAnswerMark(
current: RenderMessage[],
focusId: string,
selected: ChoiceKey | "stop" | "skip_probe" | "typed",
): RenderMessage[] {
const marked = selected === "typed" ? null : selected;
return current.map((message) => {
const question = message.question;
if (!question || question.focus_id !== focusId) return message;
if (question.status !== "resolved" || question.answer_option !== marked) return message;
return { ...message, question: { ...question, status: "active", answer_option: null } };
});
}
function isSettledAssistantMessage(message: RenderMessage): boolean {
return message.role === "assistant" && message.state === "settled" && !message.failed;
}
function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonly unknown[] {
return Array.isArray(payload?.turns) ? payload.turns : [];
}
@@ -734,6 +760,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
liveRow,
]));
if (!continuation) conversationAnchor.pinLatestTurn();
// The typed answer never reached the server: the question it marked is
// still the pending one (BUG-1046, same as a failed tap).
const withdrawTypedMark = (current: RenderMessage[]) => (
!continuation && action === "message" && pendingFocusId
? withdrawAnswerMark(current, pendingFocusId, "typed")
: current
);
let raw = "";
let runOutcome: "succeeded" | "stopped" | "failed" = "failed";
let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace();
@@ -785,7 +818,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (!response.ok) {
const payload = await response.json().catch(() => null);
const message = payload?.message || payload?.error || `请求失败(${response.status})`;
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setMessages((current) => withdrawTypedMark(
current.filter((message) => message.renderKey !== assistantRenderKey),
));
if (payload?.code === "profile_incomplete") {
onProfileIncomplete?.();
return;
@@ -801,7 +836,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return;
}
if (!response.body) {
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setMessages((current) => withdrawTypedMark(
current.filter((message) => message.renderKey !== assistantRenderKey),
));
setError("服务暂时不可用,请稍后再试。");
return;
}
@@ -970,7 +1007,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
runOutcome = "succeeded";
await loadCaseSnapshot((turns) => {
if (turns.length) {
setMessages((current) => mergeTurnQuestions(current, turns));
// D4 (BUG-1046): a turn the server wrote alongside this one (the
// one carrying the next question) joins the transcript too.
setMessages((current) => applySnapshotTurnsToMessages(
current,
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
}
});
onMessagesChange?.([
@@ -1007,7 +1050,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return;
}
setError("生时校正暂时不可用,请稍后再试。");
setMessages((current) => current.flatMap((message): RenderMessage[] => {
setMessages((current) => withdrawTypedMark(current).flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
return hasActivityReceipt(completedReceipt)
? [{
@@ -1098,6 +1141,26 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
]);
conversationAnchor.pinLatestTurn();
setPending(true);
// BUG-1046 (D2): a tap that did not land takes back its local answered
// mark and placeholder; the card key changes (answer_option → nonce), so
// the card remounts clean and one tappable card is left.
const rollBackFailedChoice = () => {
setMessages((current) => withdrawAnswerMark(
current.filter((message) => message.renderKey !== assistantRenderKey),
focusId ?? "",
optionId,
));
setChoiceNonce((current) => current + 1);
};
// The server may have moved on (stale_question, a dropped response after
// it applied): re-read the Case so the card reflects what it holds.
const reloadAfterFailedChoice = () => loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current,
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
const abortController = new AbortController();
runAbort.current = abortController;
try {
@@ -1122,13 +1185,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setChoiceNonce((current) => current + 1);
rollBackFailedChoice();
if (payload?.code === "profile_incomplete") {
onProfileIncomplete?.();
return;
}
setError(payload?.message || payload?.error || `请求失败(${response.status})`);
setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY);
await reloadAfterFailedChoice();
return;
}
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
@@ -1139,7 +1202,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onCompleted?.();
await loadCaseSnapshot((turns) => {
if (willContinue) {
setMessages((current) => mergeTurnQuestions(current, turns));
// D4 (BUG-1046, the same gap BUG-685 closed for the tie-break entry): a turn
// the server wrote while applying the tap joins the transcript,
// above the row the follow-up run continues on.
setMessages((current) => mergeTurnQuestions(
appendUnseenAssistantTurns(
current,
messagesFromTurns(turns as readonly PersistedTurn[]),
{ beforeRenderKey: assistantRenderKey },
),
turns,
));
return;
}
const narration = typeof payload?.narration === "string" && payload.narration.trim()
@@ -1192,9 +1265,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return;
}
lastRunOutcome.current = "failed";
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setChoiceNonce((current) => current + 1);
setError("选择题处理失败,请稍后重试。");
rollBackFailedChoice();
setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY);
await reloadAfterFailedChoice();
} finally {
if (runAbort.current === abortController) runAbort.current = null;
if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded";
@@ -1541,6 +1614,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
retryAttempts: questionRetryAttempts,
retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT,
});
// BUG-1046 (D3): the persisted question is drawn once. With an assistant
// message to carry it, it hangs there (BUG-678's intent) and other copies of
// the same focus come off; the standalone block is only the fallback when
// nothing can carry it. The gap state itself is unchanged.
const persistedTurnQuestion = questionGap === "persisted_question"
? turnQuestionFromCurrent(currentQuestion, choiceCard)
: null;
const placedQuestion = persistedTurnQuestion
? placePersistedQuestion(messages, persistedTurnQuestion, isSettledAssistantMessage)
: null;
const placedByKey = placedQuestion && placedQuestion.messages !== messages
? new Map(placedQuestion.messages.map((message) => [message.renderKey, message]))
: null;
const showStandaloneQuestion = questionGap === "persisted_question"
&& (placedQuestion ? placedQuestion.standalone : true);
const verifiedIdleCopy = questionGap === "verified_idle"
? postAdoptVerifyDoneCopy(
savedTime,
@@ -1711,6 +1799,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
)}
{messages.map((message) => {
// D3: the same message with the persisted question placed on it.
const entryMessage = placedByKey?.get(message.renderKey) ?? message;
const regenerating = regeneratingMessageKey === message.renderKey;
const canRegenerate = message.renderKey === latestRegeneratableKey
&& !busy
@@ -1722,7 +1812,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
className="rectification-message-wrap rectification-message-entry"
>
<RectificationMessageEntry
message={message}
message={entryMessage}
busy={busy}
readonly={readonly}
regenerating={regenerating}
@@ -1763,7 +1853,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
);
})}
{questionGap === "persisted_question" && currentQuestion?.prompt && (
{showStandaloneQuestion && currentQuestion?.prompt && (
<div className="rectification-message-wrap rectification-message-entry" data-testid="persisted-question">
<div className="rectification-message-question is-standalone">
{persistedQuestionSurface({