Files
Jyotisha/frontend/tests/rectification-dup-question-20260926.test.tsx
T
Jesse_ChenandClaude Opus 5.5 bdf4c31bc4 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
2026-10-01 10:40:29 +08:00

646 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 2026-09-26, staging, real device (TASK-rectification-dup-question-20260926):
*
* A (BUG-1045, recurrence of BUG-585 via BUG-969 ③): a typed answer to a
* pending choice (or a collect denial) streams 「ack + next stem」 as one
* `answer.delta`; the snapshot merge then hangs the same stem as a question
* block under it, so the stem showed twice until a refresh.
* B (BUG-1046): a tapped option was marked answered locally before the POST;
* when the POST failed nothing rolled it back, so the latest message showed
* the answered card and the standalone `persisted_question` block drew the
* very same question a second time.
*
* These tests mount the real `RectificationAgenticChat` on the minimal host DOM
* and drive the LIVE path through a fake `fetch`: stream → settle → snapshot
* merge. They count what the reader would see, not helper return values.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { RectificationAgenticChat } from "../src/components/rectification-agentic-chat.tsx";
import { persistCollectDenialTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import {
RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY,
} from "../src/lib/rectification-surface-state.ts";
import {
appendUnseenAssistantTurns,
mergeTurnQuestions,
type SnapshotTurnMessage,
} from "../src/lib/rectification-snapshot-messages.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
import { fakeAccounting, USER_ID } from "./rectification-v9-test-support.ts";
const CASE_ID = "44444444-4444-4444-8444-444444444444";
const SESSION_ID = "33333333-3333-4333-8333-333333333333";
const FOCUS_1 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
const FOCUS_2 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2";
const STEM_1 = "2023 年前后,有没有换过工作或职责明显变化?";
const STEM_2 = "2024 年 3 月前后,有没有收入明显变化、大笔支出或欠债?";
const RANGE_ACK = "已记录,范围没变;14:58–14:59 领先,14:40–14:43 落后。";
const COLLECT_STEM = "还有吗?比如搬家、升学或家里的大事。";
const OPTIONS = [
{ key: "A" as const, label: "明确发生且时间吻合", answer_class: "yes", role: "primary" },
{ key: "B" as const, label: "发生过但程度较弱", answer_class: "weak_yes", role: "primary" },
{ key: "C" as const, label: "没有这回事", answer_class: "no", role: "primary" },
{ key: "D" as const, label: "这段记不清楚", answer_class: "unsure", role: "primary" },
];
function choiceSchema(prompt: string) {
return {
prompt,
probe_id: `probe:${prompt.slice(0, 4)}`,
choice: {
prompt,
option_a: OPTIONS[0].label,
option_b: OPTIONS[1].label,
option_c: OPTIONS[2].label,
option_d: OPTIONS[3].label,
options: OPTIONS.map(({ key, label, answer_class }) => ({ key, label, answer_class })),
},
};
}
function focus(id: string, prompt: string, overrides: Partial<ConversationFocus> = {}): ConversationFocus {
return {
id,
caseId: CASE_ID,
questionId: `distinguish:${id.slice(-1)}`,
intent: "distinguish_candidates",
targetEvidenceId: null,
targetDomain: "career",
targetKind: null,
expectedAnswerSchema: choiceSchema(prompt),
status: "active",
askedAt: "2026-09-26T08:00:00.000Z",
resolvedAt: null,
askedTurnId: null,
answerOption: null,
...overrides,
};
}
function collectFocus(id: string, prompt: string, askedTurnId: string): ConversationFocus {
return focus(id, prompt, {
questionId: "collect:open",
intent: "collect_fact",
expectedAnswerSchema: { prompt, collect: true },
askedTurnId,
});
}
function choiceCard(focusId: string, prompt: string, questionId: string) {
return {
question_id: questionId,
method_id: "candidate_discriminator",
prompt,
choice_mode: "A/B/C/D",
options: OPTIONS,
focus_id: focusId,
case_revision: 3,
probe_id: `probe:${prompt.slice(0, 4)}`,
};
}
type RawTurn = { id: string; role: "user" | "assistant"; text: string; status: string };
/** The GET route's turn assembly: the real `attachQuestionsToTurns`. */
function snapshot(rawTurns: RawTurn[], focuses: ConversationFocus[], current: ConversationFocus | null) {
const turns = attachQuestionsToTurns(rawTurns, focuses);
const collect = current && current.expectedAnswerSchema.collect === true;
return {
case: { status: "collecting_evidence" },
question_source: current ? "focus" : null,
current_question: current
? {
kind: collect ? "collect_spoken" : "choice",
prompt: current.expectedAnswerSchema.prompt,
focus_id: current.id,
question_id: current.questionId,
}
: null,
choice_card: current && !collect
? choiceCard(current.id, String(current.expectedAnswerSchema.prompt), current.questionId)
: null,
turns,
};
}
function ndjson(events: unknown[]): Response {
return new Response(`${events.map((event) => JSON.stringify(event)).join("\n")}\n`, {
status: 200,
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
});
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
type Route = (request: { url: string; method: string; body: Record<string, unknown> | null }) => Response | Promise<Response>;
async function mountChat(input: {
initialTurns: RawTurn[];
initialFocuses: ConversationFocus[];
initialCurrent: ConversationFocus | null;
route: Route;
}) {
const harness = createClientLifecycleHarness();
const win = (globalThis as unknown as { window: Record<string, unknown> }).window;
win.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} });
win.setInterval = setInterval;
win.clearInterval = clearInterval;
// Host DOM has no layout: the scroll-anchor hook only needs these to exist.
const proto = (harness.container as unknown as { constructor: { prototype: Record<string, unknown> } }).constructor.prototype;
Object.assign(proto, {
querySelector: () => null,
querySelectorAll: () => [],
getBoundingClientRect: () => ({ top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }),
compareDocumentPosition: () => 0,
scrollTo() {},
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
});
const doc = (globalThis as unknown as { document: { createElement: (tag: string) => { style: object } } }).document;
const create = doc.createElement.bind(doc);
const styled = <T extends { style: object }>(element: T): T => {
Object.assign(element.style, { setProperty() {}, removeProperty() {}, getPropertyValue: () => "" });
return element;
};
doc.createElement = (tag: string) => styled(create(tag));
styled(harness.container as unknown as { style: object });
const originalFetch = globalThis.fetch;
const requests: Array<{ url: string; method: string; body: Record<string, unknown> | null }> = [];
globalThis.fetch = (async (resource: RequestInfo | URL, init?: RequestInit) => {
const request = {
url: String(resource),
method: init?.method ?? "GET",
body: typeof init?.body === "string" ? JSON.parse(init.body) as Record<string, unknown> : null,
};
requests.push(request);
return input.route(request);
}) as typeof fetch;
const initial = snapshot(input.initialTurns, input.initialFocuses, input.initialCurrent);
await harness.render(
<RectificationAgenticChat
caseId={CASE_ID}
sessionId={SESSION_ID}
readonly={false}
shouldStartOpening={false}
initialTurns={initial.turns}
initialSnapshot={initial}
declaredTime="14:50"
birthDate="1990-06-15"
models={[]}
selectedModelId="model"
onSelectModel={() => {}}
headerSlot={null}
/>,
);
await harness.idle();
type Host = ReturnType<typeof harness.elements>[number];
const byTag = (tag: string) => harness.elements().filter((node) => node.tagName === tag);
const attr = (node: Host, name: string) => node.getAttribute(name);
const insideDisabledFieldset = (node: Host): boolean => {
for (let at: Host | null = node; at; at = at.parentNode as Host | null) {
if (at.tagName === "FIELDSET" && at.hasAttribute("disabled")) return true;
}
return false;
};
// What a sighted reader sees: screen-reader-only nodes (the card's
// `<legend className="sr-only">`) are not a second visible stem.
const visibleText = (node: Host): string => {
if ((node.props.className as string | undefined)?.split(/\s+/).includes("sr-only")) return "";
return node.textContent + node.childNodes.map((child) => visibleText(child as Host)).join("");
};
const optionButtons = () => byTag("BUTTON").filter((node) => (node.props.className as string | undefined)?.includes("birth-time-choice-option"));
return {
harness,
requests,
text: () => visibleText(harness.container as Host),
occurrences: (needle: string) => visibleText(harness.container as Host).split(needle).length - 1,
cards: () => byTag("SECTION").filter((node) => (node.props.className as string | undefined)?.includes("rectification-choice-card")),
clickableOptions: () => optionButtons().filter((node) => !insideDisabledFieldset(node)),
selectedOptions: () => optionButtons().filter((node) => attr(node, "data-selected") === "true"),
persistedBlocks: () => harness.elements().filter((node) => attr(node, "data-testid") === "persisted-question"),
async type(message: string) {
const textarea = byTag("TEXTAREA")[0];
assert.ok(textarea, "composer textarea");
await harness.event(textarea, "onChange", { target: { value: message } });
const form = byTag("FORM")[0];
assert.ok(form, "composer form");
await harness.event(form, "onSubmit");
for (let i = 0; i < 5; i += 1) await harness.idle();
},
async tap(label: string) {
const button = optionButtons().find((node) => node.text.includes(label) && !insideDisabledFieldset(node));
assert.ok(button, `clickable option ${label}`);
await harness.event(button, "onClick");
for (let i = 0; i < 5; i += 1) await harness.idle();
},
async close() {
globalThis.fetch = originalFetch;
await harness.close();
},
};
}
const OPENING: RawTurn = { id: "t0", role: "assistant", text: "眼下按 14:35–15:05 来核对。", status: "completed" };
test("A · typed answer to a pending choice: streamed ack+stem, then snapshot merge — the stem shows once (BUG-1045)", async () => {
const q1 = focus(FOCUS_1, STEM_1, { askedTurnId: "t0" });
// The server's deterministic reply for a typed answer to a pending choice
// (`persistApplied`): the ack with the next stem appended (BUG-969 ③).
const streamed = composeCollectSpokenAssistantText(RANGE_ACK, STEM_2);
assert.ok(streamed.includes(STEM_2), "fixture: the stream carries the next stem");
const chat = await mountChat({
initialTurns: [OPENING],
initialFocuses: [q1],
initialCurrent: q1,
route: ({ url, method }) => {
if (method === "POST") return ndjson([{ type: "answer.delta", text: streamed }, { type: "run.completed", turnId: "t2" }]);
assert.ok(url.startsWith(`/api/rectification/cases/${CASE_ID}`));
const q1Done = { ...q1, status: "resolved" as const, answerOption: "B" as const };
const q2 = focus(FOCUS_2, STEM_2, { askedTurnId: "t2" });
return json(snapshot([
OPENING,
{ id: "t1", role: "user", text: "换过,2023 年 3 月。", status: "completed" },
{ id: "t2", role: "assistant", text: streamed, status: "completed" },
], [q1Done, q2], q2));
},
});
try {
assert.equal(chat.occurrences(STEM_1), 1);
await chat.type("换过,2023 年 3 月。");
assert.equal(chat.harness.errors.length, 0, String(chat.harness.errors[0] ?? ""));
assert.ok(chat.text().includes(RANGE_ACK), "the ack stays in the bubble");
assert.equal(chat.occurrences(STEM_2), 1, `stem must show exactly once:\n${chat.text()}`);
assert.equal(chat.persistedBlocks().length, 0);
// The one visible stem is the card's: four clickable options for Q2.
assert.equal(chat.clickableOptions().length, 4);
} finally {
await chat.close();
}
});
test("A · collect denial with a next choice: streamed ack+stem from persistCollectDenialTurn shows the stem once (BUG-1045)", async () => {
const collect = collectFocus(FOCUS_1, COLLECT_STEM, "t0");
const q2 = focus(FOCUS_2, STEM_2);
const accounting = fakeAccounting({
append_agentic_rectification_turn: () => ({ turn_id: "t2", idempotent: false }),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: { ...q2, asked_turn_id: args.p_asked_turn_id, askedTurnId: args.p_asked_turn_id },
idempotent: false,
}),
});
// The real server helper produces the streamed text.
const denial = await persistCollectDenialTurn({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: "99999999-9999-4999-8999-999999999999",
userMessage: "想不到了",
applied: {
narration: STEM_2,
ack: RECTIFICATION_USER_COPY.collectDeclinedAck,
closeStatus: "skipped",
nextInterviewPersisted: true,
nextChoiceReady: true,
focus: q2,
},
});
assert.ok(denial.streamText.includes(STEM_2), "fixture: the stream carries the next stem");
const chat = await mountChat({
initialTurns: [{ ...OPENING, text: "记下了。" }],
initialFocuses: [collect],
initialCurrent: collect,
route: ({ method }) => {
if (method === "POST") return ndjson([{ type: "answer.delta", text: denial.streamText }, { type: "run.completed", turnId: "t2" }]);
return json(snapshot([
{ ...OPENING, text: "记下了。" },
{ id: "t1", role: "user", text: "想不到了", status: "completed" },
{ id: "t2", role: "assistant", text: denial.streamText, status: "completed" },
], [{ ...collect, status: "skipped" }, { ...q2, askedTurnId: "t2" }], { ...q2, askedTurnId: "t2" }));
},
});
try {
await chat.type("想不到了");
assert.ok(chat.text().includes(RECTIFICATION_USER_COPY.collectDeclinedAck));
assert.equal(chat.occurrences(STEM_2), 1, `stem must show exactly once:\n${chat.text()}`);
assert.equal(chat.persistedBlocks().length, 0);
assert.equal(chat.clickableOptions().length, 4);
} finally {
await chat.close();
}
});
for (const failure of ["409 stale_question", "network error"] as const) {
test(`B · failed choice submit (${failure}): one clickable card, answer mark withdrawn, error copy (BUG-1046)`, async () => {
const q1 = focus(FOCUS_1, STEM_1, { askedTurnId: "t0" });
let snapshotReads = 0;
const chat = await mountChat({
initialTurns: [OPENING],
initialFocuses: [q1],
initialCurrent: q1,
route: ({ method, body }) => {
if (method === "POST") {
assert.equal(body?.action, "answer_choice");
assert.equal(body?.optionId, "B");
if (failure === "network error") throw new TypeError("Failed to fetch");
return json({ error: "这道题已经过期,请回答当前问题", message: "这道题已经过期,请回答当前问题", code: "stale_question" }, 409);
}
snapshotReads += 1;
// The server never recorded the tap: Q1 is still the active question.
return json(snapshot([OPENING], [q1], q1));
},
});
try {
assert.equal(chat.cards().length, 1);
await chat.tap("发生过但程度较弱");
assert.equal(chat.occurrences(STEM_1), 1, `question must show once:\n${chat.text()}`);
assert.equal(chat.cards().length, 1, "exactly one card");
assert.equal(chat.persistedBlocks().length, 0, "no standalone duplicate");
assert.equal(chat.selectedOptions().length, 0, "the optimistic B mark is withdrawn");
assert.equal(chat.clickableOptions().length, 4, "the card can be tapped again");
assert.ok(chat.text().includes(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY), chat.text());
assert.ok(snapshotReads >= 1, "the snapshot is reloaded after the failure");
// A retry goes through the same card.
await chat.tap("发生过但程度较弱");
assert.equal(chat.requests.filter((request) => request.method === "POST").length, 2);
} finally {
await chat.close();
}
});
}
test("B · a typed answer that never reaches the server withdraws its mark: still one tappable card", async () => {
const q1 = focus(FOCUS_1, STEM_1, { askedTurnId: "t0" });
const chat = await mountChat({
initialTurns: [OPENING],
initialFocuses: [q1],
initialCurrent: q1,
route: ({ method }) => (method === "POST"
? json({ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" }, 409)
: json(snapshot([OPENING], [q1], q1))),
});
try {
await chat.type("换过");
assert.equal(chat.occurrences(STEM_1), 1, chat.text());
assert.equal(chat.cards().length, 1);
assert.equal(chat.persistedBlocks().length, 0);
assert.equal(chat.clickableOptions().length, 4);
} finally {
await chat.close();
}
});
test("D3 · the live focus on an older message moves to the latest one (BUG-635) instead of being drawn twice", async () => {
const q1 = focus(FOCUS_1, STEM_1, { askedTurnId: "t0" });
const later: RawTurn = { id: "t2", role: "assistant", text: "记下了:2023 年 3 月换工作。", status: "completed" };
const turns: RawTurn[] = [OPENING, { id: "t1", role: "user", text: "2023 年 3 月换了工作", status: "completed" }, later];
const chat = await mountChat({
initialTurns: turns,
initialFocuses: [q1],
initialCurrent: q1,
route: () => json(snapshot(turns, [q1], q1)),
});
try {
assert.equal(chat.occurrences(STEM_1), 1, chat.text());
assert.equal(chat.persistedBlocks().length, 0);
assert.equal(chat.cards().length, 1);
assert.equal(chat.clickableOptions().length, 4);
const text = chat.text();
assert.ok(text.indexOf(later.text) < text.indexOf(STEM_1), "the question reads under the latest message");
} finally {
await chat.close();
}
});
test("D3 · a persisted question with no asked turn hangs on the latest assistant message, never a second block", async () => {
// The snapshot names Q1 but no turn carries it (no askedTurnId, GET could not hang it).
const q1 = focus(FOCUS_1, STEM_1);
const chat = await mountChat({
initialTurns: [OPENING],
initialFocuses: [],
initialCurrent: q1,
route: () => json(snapshot([OPENING], [], q1)),
});
try {
assert.equal(chat.occurrences(STEM_1), 1, chat.text());
assert.equal(chat.persistedBlocks().length, 0, "an assistant message exists: no standalone block");
assert.equal(chat.cards().length, 1);
assert.equal(chat.clickableOptions().length, 4);
} finally {
await chat.close();
}
});
test("D3 · with no assistant message at all the standalone block is the fallback, drawn once", async () => {
const q1 = focus(FOCUS_1, STEM_1);
const chat = await mountChat({
initialTurns: [{ id: "u0", role: "user", text: "我记得 2023 年换过工作。", status: "completed" }],
initialFocuses: [],
initialCurrent: q1,
route: () => json(snapshot([], [], q1)),
});
try {
assert.equal(chat.persistedBlocks().length, 1);
assert.equal(chat.occurrences(STEM_1), 1, chat.text());
assert.equal(chat.clickableOptions().length, 4);
} finally {
await chat.close();
}
});
test("D4 · willContinue: a server turn the client never saw is merged in before the follow-up row, so its question is not redrawn", async () => {
const q1 = focus(FOCUS_1, STEM_1, { askedTurnId: "t0" });
const q1Done = { ...q1, status: "resolved" as const, answerOption: "B" as const };
// H2: the server wrote the turn that carries Q2 while applying the tap; the
// follow-up read_only run is a separate turn.
const q2 = focus(FOCUS_2, STEM_2, { askedTurnId: "t2" });
const serverTurns: RawTurn[] = [
OPENING,
{ id: "t2", role: "assistant", text: "已记录,范围没变。", status: "completed" },
];
let posts = 0;
const chat = await mountChat({
initialTurns: [OPENING],
initialFocuses: [q1],
initialCurrent: q1,
route: ({ method }) => {
if (method === "POST") {
posts += 1;
if (posts === 1) {
return json({
type: "choice.applied",
narration: "已记录,范围没变。",
nextAction: { type: "ask_candidate_discriminator" },
nextInterviewPersisted: false,
nextChoiceReady: false,
});
}
serverTurns.push({ id: "t3", role: "assistant", text: "这一步按你的选择继续。", status: "completed" });
return ndjson([{ type: "answer.delta", text: "这一步按你的选择继续。" }, { type: "run.completed", turnId: "t3" }]);
}
return json(snapshot([...serverTurns], [q1Done, q2], q2));
},
});
try {
await chat.tap("发生过但程度较弱");
assert.equal(posts, 2, "the tap and the follow-up run");
assert.ok(chat.text().includes("已记录,范围没变。"), `server turn t2 merged in:\n${chat.text()}`);
assert.equal(chat.occurrences(STEM_2), 1, chat.text());
assert.equal(chat.persistedBlocks().length, 0);
assert.equal(chat.occurrences("已记录,范围没变。"), 1);
const text = chat.text();
assert.ok(text.indexOf("已记录,范围没变。") < text.indexOf("这一步按你的选择继续。"), "t2 sits before the follow-up row");
} finally {
await chat.close();
}
});
test("D4 · send() settle: an extra assistant turn in the snapshot joins the transcript; the live row is not duplicated", async () => {
const q1 = focus(FOCUS_1, STEM_1, { askedTurnId: "t0" });
const q2 = focus(FOCUS_2, STEM_2, { askedTurnId: "t3" });
const chat = await mountChat({
initialTurns: [OPENING],
initialFocuses: [q1],
initialCurrent: q1,
route: ({ method }) => {
if (method === "POST") return ndjson([{ type: "answer.delta", text: "记下了:2023 年 3 月换工作。" }, { type: "run.completed", turnId: "t2" }]);
return json(snapshot([
OPENING,
{ id: "t1", role: "user", text: "2023 年 3 月换了工作", status: "completed" },
{ id: "t2", role: "assistant", text: "记下了:2023 年 3 月换工作。", status: "completed" },
{ id: "t3", role: "assistant", text: "接下来我们继续。", status: "completed" },
], [{ ...q1, status: "resolved" }, q2], q2));
},
});
try {
await chat.type("2023 年 3 月换了工作");
assert.equal(chat.occurrences("记下了:2023 年 3 月换工作。"), 1, chat.text());
assert.ok(chat.text().includes("接下来我们继续。"), chat.text());
assert.equal(chat.occurrences(STEM_2), 1);
assert.equal(chat.persistedBlocks().length, 0);
} finally {
await chat.close();
}
});
test("mergeTurnQuestions drops the attached stem from the live text; appendUnseenAssistantTurns can insert before a live row", () => {
const streamed = composeCollectSpokenAssistantText(RANGE_ACK, STEM_2);
const question = {
focus_id: FOCUS_2, question_id: "q2", kind: "choice" as const, prompt: STEM_2,
options: OPTIONS.map(({ key, label }) => ({ key, label })), status: "active", answer_option: null, probe_id: null,
};
const [merged] = mergeTurnQuestions(
[{ role: "assistant", turnId: "t2", text: streamed, renderKey: "live" } satisfies SnapshotTurnMessage as SnapshotTurnMessage],
[{ id: "t2", text: RANGE_ACK, question }],
);
assert.equal(merged?.text, RANGE_ACK);
assert.equal(merged?.question?.prompt, STEM_2);
// Idempotent: a second merge leaves the text alone.
assert.equal(mergeTurnQuestions([merged!], [{ id: "t2", question }])[0]?.text, RANGE_ACK);
// No question on the turn: the text is never touched.
const plain = mergeTurnQuestions(
[{ role: "assistant" as const, turnId: "t9", text: `${RANGE_ACK}你还记得吗?`, renderKey: "p" }],
[{ id: "t9", question: null }],
);
assert.equal(plain[0]?.text, `${RANGE_ACK}你还记得吗?`);
const inserted = appendUnseenAssistantTurns(
[
{ role: "assistant" as const, turnId: "t0", text: "a", renderKey: "a" },
{ role: "assistant" as const, text: "", renderKey: "live" },
],
[{ role: "assistant" as const, turnId: "t2", text: "b", renderKey: "b" }],
{ beforeRenderKey: "live" },
);
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();
}
});