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
This commit is contained in:
Jesse_Chen
2026-09-26 10:49:25 +08:00
co-authored by Claude Opus 5.5
parent 07998972e0
commit e4c1c7a342
18 changed files with 906 additions and 18 deletions
@@ -16,6 +16,7 @@ import {
import { persistCollectDenialTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { mergeTurnQuestions, type SnapshotTurnMessage } from "../src/lib/rectification-snapshot-messages.ts";
import {
RECTIFICATION_SNAPSHOT_RETRY_LABEL,
RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY,
@@ -200,4 +201,14 @@ test("collect denial with a next choice streams ack+stem; attachQuestionsToTurns
assert.equal(attached[0]?.question?.prompt, prompt);
assert.equal(attached[0]?.question?.kind, "choice");
assert.equal((attached[0]?.text ?? "").includes(prompt), false);
// BUG-1045: the refresh path above was the only one this test covered. The
// live transcript keeps the streamed text until the snapshot merge; that
// merge must drop the stem the same way.
const [live] = mergeTurnQuestions(
[{ role: "assistant", turnId: TURN_ID, text: result.streamText, renderKey: "live" } satisfies SnapshotTurnMessage as SnapshotTurnMessage],
attached,
);
assert.equal(live?.text, ack);
assert.equal(live?.question?.prompt, prompt);
});
@@ -4,6 +4,7 @@ import test from "node:test";
import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { mergeTurnQuestions, type SnapshotTurnMessage } from "../src/lib/rectification-snapshot-messages.ts";
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
import {
CHOICE_ACTION,
@@ -827,6 +828,26 @@ test("answering a discriminator persists the next dated card so GET still has a
)),
true,
);
// BUG-1045: the server text keeps the stem (BUG-969 ③ unchanged); the client
// must drop it the moment the snapshot attaches the card to this turn, on the
// live transcript too — not only on a refresh.
const [live] = mergeTurnQuestions(
[{ role: "assistant", turnId: TURN_ID, text: applied.narration, renderKey: "live" } satisfies SnapshotTurnMessage as SnapshotTurnMessage],
[{
id: TURN_ID,
question: {
focus_id: FOCUS_ID,
question_id: String(setFocus.args.p_question_id),
kind: "choice",
prompt: persistedPrompt,
options: ["A", "B", "C", "D"].map((key) => ({ key, label: `选项 ${key}` })),
status: "active",
},
}],
);
assert.equal(live?.question?.prompt, persistedPrompt);
assert.equal(live?.text.includes(persistedPrompt), false);
assert.match(live?.text ?? "", /^已记录,/);
const refreshed = parseV9CaseDossier(twoProbeDossier());
assert.ok(refreshed);
@@ -0,0 +1,568 @@
/**
* 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"]);
});