fix(rectification): bind collect-denial follow-up to the asked turn
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Saying no to a spoken collect left the next stem only on current_question, so the preparing spinner never cleared. Persist the ack, link asked_turn_id, and emit run.completed with turnId (BUG-525).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-04 16:25:09 +08:00
parent c140191357
commit 41f973f036
9 changed files with 313 additions and 25 deletions
+17
View File
@@ -8097,3 +8097,20 @@
- 相关记录:无
- 复发自:无
- 修复版本:待发布
## BUG-525 | 采集题答「没有」后「正在准备下一个问题…」不消失
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`applyCollectFocusDenial``persistCollectDenialTurn``/api/rectification/agent` `completedMessageResponse``rectification-agentic-chat` 问题缺口重试
- 用户现象:采集口述题答「没有」后,助手气泡已经给出下一问,底部仍转圈显示「正在准备下一个问题…」,不会变成「没有拿到下一个问题」。范围条「目前范围 … 还在收窄」是采集阶段的只读提示,不是本缺陷。
- 触发条件:当前焦点为 `collect_spoken`;意图分类 `answer_current_focus` + `answer_class: "no"`;走采集拒答快路径(不进 Agent)。
- 根因:拒答快路径把下一问题干整段当成正文流出去,但 turn 上 `question` 为空、新焦点没有 `asked_turn_id``run.completed` 也不带 `turnId`。前端 `liveQuestionOnMessages` 要求已结算消息的 `question.focus_id` 等于 `current_question.focus_id`,缺口因此一直是 `preparing``applyCaseSnapshot` 只要快照里有 `current_question` 就把重试次数清零,定时重拉永远到不了 `unavailable`
- 修复:拒答后先落确定性 turn,再 `linkFocusAskedTurn`。库内正文为确认句 + 精确题干后缀(GET 仍按 `asked_turn_id` detach);直播只推确认句。`run.completed` 带上 `turnId`。快照里出现 `current_question` 不再重置缺口重试。
- 验证:`rectification-collect-stall` 锁 family collect 拒答 → occupation 题干写入 turn、`p_asked_turn_id`、直播确认句;route 源码锁 `persistCollectDenialTurn``finished.turnId``rectification-surface-contract` 禁止 `if (nextQuestion !== null) setQuestionRetryAttempts(0)`
- 防复发:采集拒答快路径建立的下一焦点必须有 `asked_turn_id`,且 `run.completed` 必须带该 turn。不得把「快照已有 current_question」当成缺口已闭合。直播不得把下一问题干当作整段回复(题干走 turn.question)。
- 相关记录:BUG-440、BUG-490、BUG-491、BUG-505、BUG-520
- 复发自:BUG-491
- 修复版本:待发布
- 编号说明:rebase 到 `origin/staging` 时 BUG-524 已被 consultation_workflow 占用,本条落在 BUG-525。
@@ -13,6 +13,7 @@ import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectifica
import {
applyRectificationChoice,
applyCollectFocusDenial,
persistCollectDenialTurn,
persistNextInterviewIfIdle,
} from "@/lib/rectification-agentic/v9/answer-choice";
import { createAdoptNarrationWriter } from "@/lib/rectification-agentic/v9/adopt-narration-agent";
@@ -52,10 +53,18 @@ import {
export const runtime = "nodejs";
export const maxDuration = 240;
function completedMessageResponse(text: string, requestId: string, caseId: string) {
function completedMessageResponse(
text: string,
requestId: string,
caseId: string,
turnId?: string | null,
) {
const completed = turnId
? { type: "run.completed", turnId }
: { type: "run.completed" };
const body = [
JSON.stringify({ type: "answer.delta", text }),
JSON.stringify({ type: "run.completed" }),
JSON.stringify(completed),
"",
].join("\n");
return new Response(body, {
@@ -363,12 +372,12 @@ export async function POST(request: Request) {
}
if (!classified || classified.intent === "unclear") {
const narration = RECTIFICATION_USER_COPY.unclearFocusReply;
await persistV9DeterministicTurn(accounting, userId, caseId, {
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: narration,
});
return completedMessageResponse(narration, requestId, caseId);
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
}
if (classified.intent === "answer_current_focus") {
if (!classified.answer_class) {
@@ -398,7 +407,7 @@ export async function POST(request: Request) {
narrateAdopt,
});
if (!continueToAgent) {
return completedMessageResponse(applied.narration, requestId, caseId);
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
}
if (classified.intent === "stop_rectification") {
@@ -419,7 +428,7 @@ export async function POST(request: Request) {
userDisplay: parsed.data.message ?? null,
});
await transitionV9CaseStatus(accounting, userId, caseId, "paused");
return completedMessageResponse(applied.narration, requestId, caseId);
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
} else if (focus && isCollectFocusSchema(focus.expectedAnswerSchema)) {
let classified = null;
@@ -443,12 +452,15 @@ export async function POST(request: Request) {
narrateAdopt,
});
if (!continueToAgent) {
await persistV9DeterministicTurn(accounting, userId, caseId, {
const finished = await persistCollectDenialTurn({
accounting,
userId,
caseId,
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: applied.narration,
applied,
});
return completedMessageResponse(applied.narration, requestId, caseId);
return completedMessageResponse(finished.streamText, requestId, caseId, finished.turnId);
}
}
} else {
@@ -468,12 +480,12 @@ export async function POST(request: Request) {
narrateAdopt,
});
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision);
await persistV9DeterministicTurn(accounting, userId, caseId, {
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage,
});
return completedMessageResponse(assistantMessage, requestId, caseId);
return completedMessageResponse(assistantMessage, requestId, caseId, turn.turnId);
}
if (decision.nextAction === "ask_candidate_discriminator") {
const catalog = rectificationFollowupCatalog(
@@ -529,16 +541,16 @@ export async function POST(request: Request) {
expectedRevision: previous?.revision ?? 0,
userDisplay: parsed.data.message ?? null,
});
return completedMessageResponse(applied.narration, requestId, caseId);
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
}
const narration = RECTIFICATION_USER_COPY.choicePrompt;
await persistV9DeterministicTurn(accounting, userId, caseId, {
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: narration,
});
return completedMessageResponse(narration, requestId, caseId);
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
}
if (!plan.next_followup) {
const idle = await persistNextInterviewIfIdle({
@@ -548,12 +560,12 @@ export async function POST(request: Request) {
narrateAdopt,
});
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision);
await persistV9DeterministicTurn(accounting, userId, caseId, {
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage,
});
return completedMessageResponse(assistantMessage, requestId, caseId);
return completedMessageResponse(assistantMessage, requestId, caseId, turn.turnId);
}
}
}
@@ -645,7 +645,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
setCandidateResult(nextCandidate);
setCurrentQuestion(nextQuestion);
if (nextQuestion !== null) setQuestionRetryAttempts(0);
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
setChoiceCard(nextChoice);
setCaseStatus(nextCaseStatus);
@@ -62,6 +62,7 @@ export const RECTIFICATION_USER_COPY = {
adoptCue: "可以从下面选一个先用着。",
hostNarrationFallback: "我按现有材料继续往下收。",
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
collectDeclinedAck: "记下了,这方面先跳过。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
} as const;
@@ -216,6 +217,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.adoptCue,
RECTIFICATION_USER_COPY.hostNarrationFallback,
RECTIFICATION_USER_COPY.continueCollectFallback,
RECTIFICATION_USER_COPY.collectDeclinedAck,
RECTIFICATION_USER_COPY.uncertaintyStop,
RECTIFICATION_USER_COPY.tiedFirstStop,
...Object.values(USER_COLLECT_QUESTION),
@@ -53,6 +53,7 @@ import {
type AdoptNarrationWriter,
} from "./adopt-narration.ts";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus";
import { composeCollectSpokenAssistantText } from "./collect-prompt";
import {
blockingMethodsCovered,
buildMethodFollowupPlan,
@@ -192,6 +193,7 @@ export type AppliedChoiceReceipt = Readonly<{
nextAction: ReturnType<typeof publicNextAction>;
nextInterviewPersisted: boolean;
nextChoiceReady: boolean;
turnId: string | null;
}>;
function asText(value: unknown): string | null {
@@ -643,7 +645,12 @@ export async function applyCollectFocusDenial(
deferFollowup?: boolean;
narrateAdopt?: AdoptNarrationWriter;
},
): Promise<{ narration: string; nextInterviewPersisted: boolean; nextChoiceReady: boolean }> {
): Promise<{
narration: string;
nextInterviewPersisted: boolean;
nextChoiceReady: boolean;
focus: ConversationFocus | null;
}> {
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
const focus = dossier.conversationSummary.activeFocus;
if (!focus || focus.id !== input.focusId) {
@@ -656,9 +663,10 @@ export async function applyCollectFocusDenial(
});
if (input.deferFollowup === true) {
return {
narration: "记下了,这方面先跳过。",
narration: RECTIFICATION_USER_COPY.collectDeclinedAck,
nextInterviewPersisted: false,
nextChoiceReady: false,
focus: null,
};
}
let birthDate: string | null = null;
@@ -701,9 +709,57 @@ export async function applyCollectFocusDenial(
narration: nextInterview.hostNarration,
nextInterviewPersisted: nextInterview.persisted === true || nextInterview.choiceReady,
nextChoiceReady: nextInterview.choiceReady,
focus: nextInterview.focus ?? null,
};
}
export type CollectDenialApplied = Awaited<ReturnType<typeof applyCollectFocusDenial>>;
/**
* After a collect "没有", persist the deterministic turn then bind the next
* focus to that turn so the stem can hang on the message. Stream only the
* acknowledgment; the server-owned stem joins via asked_turn_id (BUG-525).
*/
export async function persistCollectDenialTurn(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
requestId: string;
userMessage: string | null;
applied: CollectDenialApplied;
}): Promise<{ streamText: string; turnId: string }> {
const hasNextStem = Boolean(
input.applied.focus
&& (input.applied.nextInterviewPersisted || input.applied.nextChoiceReady)
&& input.applied.narration.trim(),
);
const stored = hasNextStem
? composeCollectSpokenAssistantText(RECTIFICATION_USER_COPY.collectDeclinedAck, input.applied.narration)
: input.applied.narration;
const streamText = hasNextStem ? RECTIFICATION_USER_COPY.collectDeclinedAck : input.applied.narration;
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: input.requestId,
userMessage: input.userMessage,
assistantMessage: stored,
});
if (input.applied.focus) {
try {
await linkFocusAskedTurn({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
focus: input.applied.focus,
askedTurnId: turn.turnId,
});
} catch (error) {
console.warn(
`[rectification-v9] link collect-denial focus to turn failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
);
}
}
return { streamText, turnId: turn.turnId };
}
export function isStalePreAdoptFocus(
acceptedTime: string | null | undefined,
focus: { intent?: string | null } | null | undefined,
@@ -946,6 +1002,7 @@ async function persistApplied(
let hostNarration = input.narration;
let skippedNextInterview = false;
let nextFocus: ConversationFocus | null = null;
let turnId: string | null = null;
if (
command.deferFollowup !== true
&& input.userStopped !== true
@@ -1016,6 +1073,7 @@ ${nonConvergingRangeNarration({
});
narrationPersisted = true;
if (turn.turnId) {
turnId = turn.turnId;
try {
const focus = nextFocus ?? (await loadV9CaseDossier(accounting, command.userId, command.caseId))
.conversationSummary.activeFocus;
@@ -1071,6 +1129,7 @@ ${nonConvergingRangeNarration({
nextAction,
nextInterviewPersisted,
nextChoiceReady,
turnId,
};
}
@@ -96,6 +96,7 @@ test("delivery range narration puts the stop reason before the progress clause",
assert.match(text, /已经从最初的 60 分钟收到 14:0214:43 这 41 分钟/);
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.uncertaintyStop));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.tiedFirstStop));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectDeclinedAck));
});
test("question stem ownership stays on set-focus spokenPrompt, not a slot or a second turn", () => {
@@ -24,12 +24,19 @@ import {
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
import {
applyCollectFocusDenial,
persistCollectDenialTurn,
persistNextInterviewAfterChoice,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
RECTIFICATION_USER_COPY,
USER_COLLECT_QUESTION,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
ATTEMPT_ID,
CASE_ID,
FOCUS_ID,
TURN_ID,
@@ -642,6 +649,96 @@ test("occupation collect denial declines the focus and advances coverage to hora
assert.equal(horary.next_followup?.domain, "horary");
});
test("collect denial persists the next stem on the turn and binds asked_turn_id", async () => {
const familyFocus = {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: "collect:family:collect_method_evidence",
intent: "collect_method_evidence",
target_evidence_id: null,
target_domain: "family",
target_kind: null,
expected_answer_schema: {
prompt: "2021 年前后,家里如果有结婚、添丁或住院这类事,记得大概哪年就行。",
collect: true,
},
status: "active",
asked_at: "2026-08-29T00:00:00.000Z",
resolved_at: null,
};
let loads = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => {
loads += 1;
if (loads === 1) {
return rpcDossier(revision5Dossier(revision5State()), familyFocus);
}
return rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}));
},
get_agentic_rectification_case_compute: () => computeFixture(),
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
focus_id: args.p_focus_id,
status: args.p_status,
evidence_id: null,
idempotent: false,
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: "acacacac-acac-4cac-8cac-acacacacacac",
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-29T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
}),
append_agentic_rectification_turn: () => ({
turn_id: TURN_ID,
idempotent: false,
}),
});
const applied = await applyCollectFocusDenial(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
focusId: FOCUS_ID,
});
assert.equal(applied.nextInterviewPersisted, true);
assert.ok(applied.focus);
assert.equal(applied.narration, USER_COLLECT_QUESTION.occupation);
const finished = await persistCollectDenialTurn({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: ATTEMPT_ID,
userMessage: "没有",
applied,
});
assert.equal(finished.turnId, TURN_ID);
assert.equal(finished.streamText, RECTIFICATION_USER_COPY.collectDeclinedAck);
const stored = composeCollectSpokenAssistantText(
RECTIFICATION_USER_COPY.collectDeclinedAck,
USER_COLLECT_QUESTION.occupation,
);
const append = accounting.calls.find((item) => item.fn === "append_agentic_rectification_turn");
assert.equal(append?.args.p_assistant_message, stored);
assert.ok(accounting.calls.some((item) => (
item.fn === "set_agentic_rectification_conversation_focus"
&& item.args.p_asked_turn_id === TURN_ID
)));
});
test("message and opening turns persist the next followup so current_question is not null", async () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn"));
@@ -1162,10 +1259,19 @@ test("has_new_dated_event continues into the agent after applying the answer", (
fastPath.indexOf("} else {"),
);
assert.ok(collectApply.indexOf("applyCollectFocusDenial") < collectApply.indexOf("if (!continueToAgent)"));
assert.match(collectApply, /persistV9DeterministicTurn/);
assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistV9DeterministicTurn"));
assert.match(collectApply, /persistCollectDenialTurn/);
assert.match(collectApply, /completedMessageResponse\(finished\.streamText, requestId, caseId, finished\.turnId\)/);
assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistCollectDenialTurn"));
assert.ok(route.indexOf("if (action === \"message\")") < route.indexOf("runV9AgentTurn({"));
assert.match(route, /function completedMessageResponse\([\s\S]*?turnId\?: string \| null/);
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const persistDenial = answerChoice.slice(
answerChoice.indexOf("export async function persistCollectDenialTurn"),
answerChoice.indexOf("export function isStalePreAdoptFocus"),
);
assert.match(persistDenial, /composeCollectSpokenAssistantText\(RECTIFICATION_USER_COPY\.collectDeclinedAck/);
assert.match(persistDenial, /linkFocusAskedTurn/);
assert.match(persistDenial, /streamText = hasNextStem \? RECTIFICATION_USER_COPY\.collectDeclinedAck/);
const persistApplied = answerChoice.slice(answerChoice.indexOf("async function persistApplied"));
assert.match(persistApplied, /command\.deferFollowup !== true/);
assert.equal(shouldContinueAgentForDatedEvent({
@@ -1250,6 +1356,8 @@ test("collect denial with a new dated event does not persist the next interview
false,
);
assert.equal(applied.nextInterviewPersisted, false);
assert.equal(applied.focus, null);
assert.equal(applied.narration, RECTIFICATION_USER_COPY.collectDeclinedAck);
});
test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once", async () => {
@@ -7,9 +7,14 @@ import {
copyTextForMessage,
parseTurnQuestion,
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { persistCollectDenialTurn, persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { RECTIFICATION_AGENT_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
GENERIC_COLLECT_QUESTION,
RECTIFICATION_USER_COPY,
USER_COLLECT_QUESTION,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
CASE_ID,
FOCUS_ID,
@@ -264,3 +269,86 @@ test("refresh rebuilds answered and live questions from asked_turn_id only", ()
assert.equal(attached[2]?.question?.answer_option, null);
assert.equal(attached[2]?.text, "2016 年入学记下了。");
});
const DENIAL_REQUEST_ID = "99999999-9999-4999-8999-999999999999";
test("collect denial persists ack+stem then binds asked_turn_id; stream is ack only", async () => {
const prompt = USER_COLLECT_QUESTION.occupation;
const accounting = fakeAccounting({
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
...activeFocusFixture({
questionId: String(args.p_question_id),
intent: String(args.p_intent),
askedTurnId: String(args.p_asked_turn_id ?? ""),
}),
asked_turn_id: args.p_asked_turn_id,
},
idempotent: false,
}),
});
const result = await persistCollectDenialTurn({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: DENIAL_REQUEST_ID,
userMessage: "没有",
applied: {
narration: prompt,
nextInterviewPersisted: true,
nextChoiceReady: false,
focus: {
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:occupation:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "other",
targetKind: null,
expectedAnswerSchema: { prompt, collect: true },
status: "active",
askedAt: "2026-09-04T07:47:24.000Z",
resolvedAt: null,
askedTurnId: null,
answerOption: null,
},
},
});
assert.equal(result.turnId, TURN_ID);
assert.equal(result.streamText, RECTIFICATION_USER_COPY.collectDeclinedAck);
assert.notEqual(result.streamText, prompt);
const stored = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(
stored?.args.p_assistant_message,
composeCollectSpokenAssistantText(RECTIFICATION_USER_COPY.collectDeclinedAck, prompt),
);
const linked = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.equal(linked?.args.p_asked_turn_id, TURN_ID);
assert.ok(accounting.calls.findIndex((call) => call.fn === "append_agentic_rectification_turn")
< accounting.calls.findIndex((call) => call.fn === "set_agentic_rectification_conversation_focus"));
});
test("collect denial without a next stem still writes a turn and does not invent a focus link", async () => {
const accounting = fakeAccounting({
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
});
const result = await persistCollectDenialTurn({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
requestId: DENIAL_REQUEST_ID,
userMessage: "没有",
applied: {
narration: RECTIFICATION_USER_COPY.collectDeclinedAck,
nextInterviewPersisted: false,
nextChoiceReady: false,
focus: null,
},
});
assert.equal(result.streamText, RECTIFICATION_USER_COPY.collectDeclinedAck);
assert.equal(
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
false,
);
});
@@ -80,8 +80,10 @@ test("the question gap is a live row with retries, then a reload; it never tells
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionGap === "preparing",\s*intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,/);
// Attempts reset in handlers (a question arriving, a turn starting), never in an effect.
assert.match(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/);
// Attempts reset when a turn starts, never because the snapshot merely names a
// current_question. Naming a question that no settled message carries live is
// the gap itself (BUG-525); resetting here spun "正在准备下一个问题…" forever.
assert.doesNotMatch(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/);
assert.match(chat, /if \(value\) setQuestionRetryAttempts\(0\);/);
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(currentQuestion !== null/);
// In flow: the gap is the last entry of the transcript, after the message loop, before the saved-time line.