From 41f973f036c7d5138c4c0e66a9d2c18c60afc40e Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 4 Sep 2026 16:25:09 +0800 Subject: [PATCH] fix(rectification): bind collect-denial follow-up to the asked turn 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 --- docs/BUG_HISTORY.md | 17 +++ .../src/app/api/rectification/agent/route.ts | 44 ++++--- .../components/rectification-agentic-chat.tsx | 1 - .../lib/rectification-agentic/user-copy.ts | 2 + .../rectification-agentic/v9/answer-choice.ts | 63 +++++++++- .../tests/agent-voice-copy-contract.test.ts | 1 + .../tests/rectification-collect-stall.test.ts | 112 +++++++++++++++++- .../rectification-question-in-message.test.ts | 92 +++++++++++++- .../rectification-surface-contract.test.ts | 6 +- 9 files changed, 313 insertions(+), 25 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index cb83af6c..24bbacea 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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。 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 34268602..3b0b4e47 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -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); } } } diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 7789c0d9..918f03d9 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -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); diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 5e4df08e..16f6cc47 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -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), diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index ce2d34c1..3a49f31c 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -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; 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>; + +/** + * 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, }; } diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index 3d0af4cf..b6a86097 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -96,6 +96,7 @@ test("delivery range narration puts the stop reason before the progress clause", assert.match(text, /已经从最初的 60 分钟收到 14:02–14: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", () => { diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index c96ea0e3..89fb6e25 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -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 () => { diff --git a/frontend/tests/rectification-question-in-message.test.ts b/frontend/tests/rectification-question-in-message.test.ts index 6343885d..91281093 100644 --- a/frontend/tests/rectification-question-in-message.test.ts +++ b/frontend/tests/rectification-question-in-message.test.ts @@ -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, + ); +}); diff --git a/frontend/tests/rectification-surface-contract.test.ts b/frontend/tests/rectification-surface-contract.test.ts index 01195c64..96b4ccfa 100644 --- a/frontend/tests/rectification-surface-contract.test.ts +++ b/frontend/tests/rectification-surface-contract.test.ts @@ -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.