fix(rectification): one stem on screen — a replaced focus draws nothing and the live one takes its slot (BUG-1135)

- GET attach: a live hanging focus replaces a superseded-unanswered focus
  linked to the last assistant turn instead of falling to a standalone copy.
- Client placement: a dead question on the latest message is replaceable,
  and the stem the body still ends with is stripped (stripQuestionSentences).
- Chat view: a dead question no longer turns the gap into 「没有拿到下一个问题」
  while the case has a different live question.
- Message entry: a superseded or replaced unanswered choice draws nothing
  (no stem without options); the current question keeps its stem while busy.
- Copy text skips a dead question.
- Three DOM tests reproduce 「stem, stem」 and 「dead stem + unavailable」, red then green.

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-10-01 10:40:29 +08:00
co-authored by Claude Opus 5.5
parent b9215d21df
commit bdf4c31bc4
5 changed files with 119 additions and 7 deletions
@@ -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
? (
<div className="rectification-message-question">
<p className="rectification-message-question__prompt">{question.prompt}</p>
@@ -135,6 +135,16 @@ export function turnQuestionFromCurrent(
};
}
/** A focus that was replaced before anyone answered it: nothing to show. */
export function focusIsDeadUnanswered(focus: Pick<ConversationFocus, "status" | "answerOption">): 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<TurnQuestion, "status" | "answer_option">): boolean {
return question.status === "superseded" && !question.answer_option;
}
export function attachQuestionsToTurns<T extends { id: string; role: string; text: string | null }>(
turns: readonly T[],
focuses: readonly ConversationFocus[],
@@ -149,7 +159,11 @@ export function attachQuestionsToTurns<T extends { id: string; role: string; tex
byTurn.set(focus.askedTurnId, focus);
}
const lastAssistant = [...turns].reverse().find((turn) => 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("");
+3 -1
View File
@@ -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,
@@ -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<T extends SnapshotTurnMessage>(
}
}
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<T extends SnapshotTurnMessage>(
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;
@@ -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();
}
});