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:
co-authored by
Claude Opus 5.5
parent
07998972e0
commit
e4c1c7a342
@@ -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({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { stripQuestionSentences } from "./collect-prompt";
|
||||
import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card";
|
||||
import { parseAgentChoiceCopy, type ChoiceKey, type RectificationChoiceCard } from "./choice-card";
|
||||
import type { ConversationFocus } from "./tool-service";
|
||||
|
||||
export type TurnQuestionKind = "choice" | "collect_spoken" | "reverse_verify";
|
||||
@@ -109,6 +109,32 @@ export function parseTurnQuestion(value: unknown): TurnQuestion | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot's `current_question` (+ its GET `choice_card`) as the question a
|
||||
* message carries, for hanging it on the latest assistant message (BUG-1046).
|
||||
*/
|
||||
export function turnQuestionFromCurrent(
|
||||
current: Readonly<{ kind: "choice" | "collect_spoken"; prompt: string | null; focus_id: string | null; question_id: string | null }> | null,
|
||||
card: RectificationChoiceCard | null,
|
||||
): TurnQuestion | null {
|
||||
if (!current?.prompt || !current.focus_id) return null;
|
||||
const matchingCard = card && card.focus_id === current.focus_id ? card : null;
|
||||
const questionId = current.question_id ?? matchingCard?.question_id ?? null;
|
||||
if (!questionId) return null;
|
||||
return {
|
||||
focus_id: current.focus_id,
|
||||
question_id: questionId,
|
||||
kind: current.kind,
|
||||
prompt: current.prompt,
|
||||
options: current.kind === "choice" && matchingCard
|
||||
? matchingCard.options.map((option) => ({ key: option.key, label: option.label }))
|
||||
: null,
|
||||
status: "active",
|
||||
answer_option: null,
|
||||
probe_id: matchingCard?.probe_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function attachQuestionsToTurns<T extends { id: string; role: string; text: string | null }>(
|
||||
turns: readonly T[],
|
||||
focuses: readonly ConversationFocus[],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stripQuestionSentences } from "./rectification-agentic/v9/collect-prompt.ts";
|
||||
import { parseTurnQuestion, persistedOfferFromTurn, type TurnQuestion } from "./rectification-agentic/v9/turn-question.ts";
|
||||
|
||||
export type SnapshotTurnMessage = {
|
||||
@@ -27,8 +28,17 @@ export function mergeTurnQuestions<T extends SnapshotTurnMessage>(
|
||||
if (!message.turnId || !byId.has(message.turnId)) return message;
|
||||
const next = byId.get(message.turnId);
|
||||
const question = next?.question ?? undefined;
|
||||
// BUG-1045: a deterministic reply streams 「ack + next stem」 so the stem is
|
||||
// readable even if the card never attaches (BUG-969 ③). Once the card is
|
||||
// attached here the bubble drops the same sentence — the same
|
||||
// `stripQuestionSentences` the GET route's `attachQuestionsToTurns` runs,
|
||||
// so the live transcript and a refresh read the same. Idempotent.
|
||||
const text = question && message.text
|
||||
? stripQuestionSentences(message.text, question.prompt)
|
||||
: message.text;
|
||||
return {
|
||||
...message,
|
||||
text,
|
||||
question: question ?? undefined,
|
||||
candidateOffer: persistedOfferFromTurn(
|
||||
next?.offerResultId,
|
||||
@@ -39,9 +49,15 @@ export function mergeTurnQuestions<T extends SnapshotTurnMessage>(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assistant turns the server wrote that the transcript does not hold yet.
|
||||
* `beforeRenderKey` keeps a live row (a tap's follow-up run) last, so a turn
|
||||
* the server wrote while applying the tap reads above it (BUG-1046 / D4).
|
||||
*/
|
||||
export function appendUnseenAssistantTurns<T extends SnapshotTurnMessage>(
|
||||
current: T[],
|
||||
extras: readonly T[],
|
||||
options?: Readonly<{ beforeRenderKey?: string }>,
|
||||
): T[] {
|
||||
const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : []));
|
||||
const incoming = extras.filter((message) => (
|
||||
@@ -49,7 +65,12 @@ export function appendUnseenAssistantTurns<T extends SnapshotTurnMessage>(
|
||||
&& message.turnId
|
||||
&& !known.has(message.turnId)
|
||||
));
|
||||
return incoming.length ? [...current, ...incoming] : current;
|
||||
if (!incoming.length) return current;
|
||||
const at = options?.beforeRenderKey
|
||||
? current.findIndex((message) => message.renderKey === options.beforeRenderKey)
|
||||
: -1;
|
||||
if (at < 0) return [...current, ...incoming];
|
||||
return [...current.slice(0, at), ...incoming, ...current.slice(at)];
|
||||
}
|
||||
|
||||
export function applySnapshotTurnsToMessages<T extends SnapshotTurnMessage>(
|
||||
@@ -62,3 +83,44 @@ export function applySnapshotTurnsToMessages<T extends SnapshotTurnMessage>(
|
||||
turns,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* BUG-1046 / D3: where the Case's persisted question is drawn when no settled
|
||||
* message shows it live. It hangs on the latest settled assistant message
|
||||
* (BUG-678's intent) and any other copy of the same `focus_id` is taken off, so
|
||||
* one focus is drawn once. `standalone` is true only when there is nothing to
|
||||
* hang it on: no assistant message at all, or the latest one already carries a
|
||||
* different question (then the standalone block is the only place that does
|
||||
* not reorder the transcript, and it cannot duplicate this focus).
|
||||
*/
|
||||
export function placePersistedQuestion<T extends SnapshotTurnMessage>(
|
||||
current: T[],
|
||||
question: TurnQuestion,
|
||||
isSettledAssistant: (message: T) => boolean,
|
||||
): Readonly<{ messages: T[]; standalone: boolean }> {
|
||||
let target = -1;
|
||||
for (let index = current.length - 1; index >= 0; index -= 1) {
|
||||
const message = current[index]!;
|
||||
if (isSettledAssistant(message) && (message.text || message.question)) {
|
||||
target = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const targetQuestion = target >= 0 ? current[target]!.question : undefined;
|
||||
const hang = target >= 0 && (!targetQuestion || targetQuestion.focus_id === question.focus_id);
|
||||
let changed = false;
|
||||
const messages = current.map((message, index) => {
|
||||
if (hang && index === target) {
|
||||
// Already live there: leave it. Otherwise (absent, or a local answered
|
||||
// mark the server does not hold) the server's active question wins.
|
||||
if (message.question && message.question.focus_id === question.focus_id
|
||||
&& message.question.status === "active") return message;
|
||||
changed = true;
|
||||
return { ...message, question };
|
||||
}
|
||||
if (message.question?.focus_id !== question.focus_id) return message;
|
||||
changed = true;
|
||||
return { ...message, question: undefined };
|
||||
});
|
||||
return { messages: changed ? messages : current, standalone: !hang };
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export const RECTIFICATION_DELIVERED_COPY = "再问下去也分不开了。范
|
||||
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问";
|
||||
export const RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY = "这一问还没读到。";
|
||||
export const RECTIFICATION_SNAPSHOT_RETRY_LABEL = "重新读取";
|
||||
/** A tapped option that did not reach the server (BUG-1046): the card is tappable again. */
|
||||
export const RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY = "这次没提交上,请再点一次。";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_FAILED_COPY = "暂时接不上,请新建一次校正。";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_LIMIT = 2;
|
||||
export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
|
||||
|
||||
Reference in New Issue
Block a user