Files
Jyotisha/frontend/tests/rectification-dup-question-20260926.test.tsx
T
Jesse_ChenandClaude Opus 5.5 e4c1c7a342 fix(rectification): one stem per turn, one card per focus (BUG-1045/1046)
BUG-1045 (recurrence of BUG-585 via BUG-969): the snapshot merge now drops
the attached stem from the streamed "ack + stem" text with the same
stripQuestionSentences the GET route uses; server text unchanged.

BUG-1046: a failed choice submit (409 / network) or typed send withdraws
the local answered mark, remounts the card, re-reads the Case and shows
"这次没提交上,请再点一次。"; the persisted question hangs on the latest
settled assistant message with other copies of the same focus removed
(standalone block only when nothing can carry it); willContinue and the
send() settle merge unseen assistant turns (same gap as BUG-685).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 10:49:25 +08:00

569 lines
25 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"]);
});