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