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