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(); + } +});