diff --git a/frontend/src/components/rectification-message-entry.tsx b/frontend/src/components/rectification-message-entry.tsx
index d64c8e25..2b02cb47 100644
--- a/frontend/src/components/rectification-message-entry.tsx
+++ b/frontend/src/components/rectification-message-entry.tsx
@@ -27,6 +27,7 @@ import {
} from "@/lib/rectification-agentic/v9/choice-card";
import {
questionIsAnswered,
+ questionIsDeadUnanswered,
type TurnQuestion,
} from "@/lib/rectification-agentic/v9/turn-question";
import { RECTIFICATION_STOPPED_NOTICE } from "@/lib/rectification-surface-state";
@@ -152,7 +153,16 @@ function RectificationMessageEntryView({
const embeddedCard = question && !unansweredDeadChoice
? choiceCardFromQuestion(question, liveQuestion ? liveChoiceCard : null)
: null;
- const afterAnswer = question && displayedMessage.state === "settled"
+ // BUG-1135 (D1, supersedes BUG-917's 「stem without options」): a choice
+ // nobody can answer any more draws nothing on its message — only the live
+ // focus prints a stem, so the same stem never shows twice.
+ const replacedQuestion = Boolean(
+ question
+ && unansweredDeadChoice
+ && (questionIsDeadUnanswered(question)
+ || (currentQuestionFocusId !== null && currentQuestionFocusId !== question.focus_id)),
+ );
+ const afterAnswer = question && displayedMessage.state === "settled" && !replacedQuestion
? (
{question.prompt}
diff --git a/frontend/src/lib/rectification-agentic/v9/turn-question.ts b/frontend/src/lib/rectification-agentic/v9/turn-question.ts
index ee42f666..7b0fbf39 100644
--- a/frontend/src/lib/rectification-agentic/v9/turn-question.ts
+++ b/frontend/src/lib/rectification-agentic/v9/turn-question.ts
@@ -135,6 +135,16 @@ export function turnQuestionFromCurrent(
};
}
+/** A focus that was replaced before anyone answered it: nothing to show. */
+export function focusIsDeadUnanswered(focus: Pick
): boolean {
+ return focus.status === "superseded" && !focus.answerOption;
+}
+
+/** The same judgement on the client's question model (BUG-917 / BUG-1135). */
+export function questionIsDeadUnanswered(question: Pick): boolean {
+ return question.status === "superseded" && !question.answer_option;
+}
+
export function attachQuestionsToTurns(
turns: readonly T[],
focuses: readonly ConversationFocus[],
@@ -149,7 +159,11 @@ export function attachQuestionsToTurns turn.role === "assistant");
- if (hanging[0] && lastAssistant && !byTurn.has(lastAssistant.id)) {
+ // BUG-1135: a re-focused probe leaves its old focus linked to the turn and
+ // superseded unanswered; the live focus takes that slot instead of falling to
+ // a second, standalone copy of the same stem.
+ const linked = lastAssistant ? byTurn.get(lastAssistant.id) : undefined;
+ if (hanging[0] && lastAssistant && (!linked || focusIsDeadUnanswered(linked))) {
byTurn.set(lastAssistant.id, hanging[0]);
}
return turns.map((turn) => {
@@ -325,7 +339,7 @@ export function questionIsAnswered(question: TurnQuestion): boolean {
export function copyTextForMessage(body: string, question: TurnQuestion | null | undefined): string {
const spoken = body.trim();
- if (!question?.prompt) return spoken;
+ if (!question?.prompt || questionIsDeadUnanswered(question)) return spoken;
const lines = [spoken, "", question.prompt];
if (question.options?.length) {
lines.push("");
diff --git a/frontend/src/lib/rectification-chat-view.ts b/frontend/src/lib/rectification-chat-view.ts
index 8cf8bdb5..ff6af71b 100644
--- a/frontend/src/lib/rectification-chat-view.ts
+++ b/frontend/src/lib/rectification-chat-view.ts
@@ -206,7 +206,9 @@ export function deriveRectificationChatView(input: RectificationChatViewInput) {
&& !latestSettledAssistant.question.answer_option
&& (latestSettledAssistant.question.status === "active"
|| latestSettledAssistant.question.status === "superseded")
- && !liveQuestionOnMessages,
+ && !liveQuestionOnMessages
+ // BUG-1135: another, live question exists — it is drawn instead.
+ && !(currentQuestion && currentQuestion.focus_id !== latestSettledAssistant.question.focus_id && !deadChoice),
);
const questionGap = rectificationQuestionGapState({
liveQuestionVisible: liveQuestionOnMessages,
diff --git a/frontend/src/lib/rectification-snapshot-messages.ts b/frontend/src/lib/rectification-snapshot-messages.ts
index efc297c7..6f8e4fc2 100644
--- a/frontend/src/lib/rectification-snapshot-messages.ts
+++ b/frontend/src/lib/rectification-snapshot-messages.ts
@@ -1,6 +1,6 @@
import { parseSegmentSummary, type SegmentSummary } from "./rectification-agentic/core/segment-summary.ts";
import { stripQuestionSentences } from "./rectification-agentic/v9/collect-prompt.ts";
-import { parseTurnQuestion, persistedOfferFromTurn, type TurnQuestion } from "./rectification-agentic/v9/turn-question.ts";
+import { parseTurnQuestion, persistedOfferFromTurn, questionIsDeadUnanswered, type TurnQuestion } from "./rectification-agentic/v9/turn-question.ts";
export type SnapshotTurnMessage = {
role: "assistant" | "user";
@@ -111,7 +111,13 @@ export function placePersistedQuestion(
}
}
const targetQuestion = target >= 0 ? current[target]!.question : undefined;
- const hang = target >= 0 && (!targetQuestion || targetQuestion.focus_id === question.focus_id);
+ // BUG-1135: a dead (superseded, unanswered) question on the target is not a
+ // reason to draw the live one a second time in a standalone block.
+ const hang = target >= 0 && (
+ !targetQuestion
+ || targetQuestion.focus_id === question.focus_id
+ || questionIsDeadUnanswered(targetQuestion)
+ );
let changed = false;
const messages = current.map((message, index) => {
if (hang && index === target) {
@@ -120,7 +126,10 @@ export function placePersistedQuestion(
if (message.question && message.question.focus_id === question.focus_id
&& message.question.status === "active") return message;
changed = true;
- return { ...message, question };
+ // BUG-1135: the stem the body may still end with leaves when the card
+ // attaches — the same strip every other attach path runs.
+ const text = message.text ? stripQuestionSentences(message.text, question.prompt) : message.text;
+ return { ...message, text, question };
}
if (message.question?.focus_id !== question.focus_id) return message;
changed = true;
diff --git a/frontend/tests/rectification-dup-question-20260926.test.tsx b/frontend/tests/rectification-dup-question-20260926.test.tsx
index 6bd5c881..d8d462aa 100644
--- a/frontend/tests/rectification-dup-question-20260926.test.tsx
+++ b/frontend/tests/rectification-dup-question-20260926.test.tsx
@@ -566,3 +566,80 @@ test("mergeTurnQuestions drops the attached stem from the live text; appendUnsee
);
assert.deepEqual(inserted.map((message) => message.renderKey), ["a", "b", "live"]);
});
+
+// 2026-10-01 (TASK-rectification-message-cleanup-20261001, BUG-1135): real
+// device 2026-09-29 showed 「stem, stem, A–D」. Two shapes can produce it.
+const SAME_STEM = "2024 年 1 月前后,你有过搬家或者长期住到外地的情况吗?";
+const RECAP = "记下了:2023 年换过工作。";
+
+test("C · a superseded focus with the same stem on the latest message: the stem shows once, the new card is tappable (BUG-1135)", async () => {
+ const turns: RawTurn[] = [
+ OPENING,
+ { id: "t1", role: "user", text: "2023 年换过工作。", status: "completed" },
+ { id: "t2", role: "assistant", text: RECAP, status: "completed" },
+ ];
+ // Same probe re-focused: the old focus was linked to t2 and superseded
+ // unanswered; the new one has no asked turn yet.
+ const old = focus(FOCUS_1, SAME_STEM, { askedTurnId: "t2", status: "superseded", resolvedAt: "2026-09-29T08:01:00.000Z" });
+ const fresh = focus(FOCUS_2, SAME_STEM);
+ const chat = await mountChat({
+ initialTurns: turns,
+ initialFocuses: [old, fresh],
+ initialCurrent: fresh,
+ route: () => json(snapshot(turns, [old, fresh], fresh)),
+ });
+ try {
+ assert.equal(chat.harness.errors.length, 0, String(chat.harness.errors[0] ?? ""));
+ assert.equal(chat.occurrences(SAME_STEM), 1, `stem must show exactly once:\n${chat.text()}`);
+ assert.equal(chat.clickableOptions().length, 4, `the live focus is tappable:\n${chat.text()}`);
+ } finally {
+ await chat.close();
+ }
+});
+
+test("C · client guard: a message still carrying a dead focus with the same stem never prints it beside the live question (BUG-1135)", async () => {
+ const turns: RawTurn[] = [
+ OPENING,
+ { id: "t1", role: "user", text: "2023 年换过工作。", status: "completed" },
+ { id: "t2", role: "assistant", text: RECAP, status: "completed" },
+ ];
+ const old = focus(FOCUS_1, SAME_STEM, { askedTurnId: "t2", status: "superseded", resolvedAt: "2026-09-29T08:01:00.000Z" });
+ const fresh = focus(FOCUS_2, SAME_STEM);
+ // A server snapshot assembled before the live focus could hang: only the
+ // dead focus is attached, the live one is the case's current question.
+ const assembled = { ...snapshot(turns, [old], fresh), turns: attachQuestionsToTurns(turns, [old]) };
+ const chat = await mountChat({
+ initialTurns: turns,
+ initialFocuses: [old],
+ initialCurrent: fresh,
+ route: () => json(assembled),
+ });
+ try {
+ assert.equal(chat.occurrences(SAME_STEM), 1, `stem must show exactly once:\n${chat.text()}`);
+ assert.equal(chat.clickableOptions().length, 4);
+ } finally {
+ await chat.close();
+ }
+});
+
+test("C · a stem left in the body of the message that receives the persisted question shows once (BUG-1135)", async () => {
+ const turns: RawTurn[] = [
+ OPENING,
+ { id: "t1", role: "user", text: "2023 年换过工作。", status: "completed" },
+ { id: "t2", role: "assistant", text: `${RECAP}${SAME_STEM}`, status: "completed" },
+ ];
+ const fresh = focus(FOCUS_2, SAME_STEM);
+ const chat = await mountChat({
+ initialTurns: turns,
+ initialFocuses: [],
+ initialCurrent: fresh,
+ route: () => json(snapshot(turns, [], fresh)),
+ });
+ try {
+ assert.ok(chat.text().includes(RECAP), "the recap stays");
+ assert.equal(chat.occurrences(SAME_STEM), 1, `stem must show exactly once:\n${chat.text()}`);
+ assert.equal(chat.clickableOptions().length, 4);
+ } finally {
+ await chat.close();
+ }
+});