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
@@ -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 = "这段校正还没有开始。";