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:
co-authored by
Claude Opus 5.5
parent
b9215d21df
commit
bdf4c31bc4
@@ -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("");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user