Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
6.8 KiB
6.8 KiB
TASK · 生时校正:同一轮问题出现两次 + 同一道选择题连画两张(2026-09-26)
基线
origin/staging当前 head(a57c4310代码;BUG-1042 修复单合入后以新 head 为准)。分支codex/rectification-dup-question-20260926。- 与
TASK-rectification-latency-20260926(改 route / agent-run / 校正面活动状态)冲突:本单先做,那单后做。与TASK-scroll-anchor-hook-fixes-20260926不交叉。
事故实证(产品 09-26 真机,staging)
A · 同一轮里问题出现两次:用户打字回答后,助手这一轮先是正文「已记录,范围没变;14:58–14:59 领先,14:40–14:43 落后。」「2024 年 3 月前后,有没有收入明显变化、大笔支出或欠债?」,下面又是加粗的同一句问题块和选项。刷新后只剩一遍。
frontend/src/lib/rectification-agentic/v9/answer-choice.tspersistApplied:下一问是选择题时hostNarration = composeCollectSpokenAssistantText(body, choiceStem),把题干拼进assistant_message并作为applied.narration返回;persistCollectDenialTurn同样。frontend/src/app/api/rectification/agent/route.ts:打字回答命中待答选择题(action === "message"+choice分支)→applyRectificationChoice→completedMessageResponse(applied.narration)整段作为一个answer.delta下发;不经模型 runner,agent-run.ts里 BUG-585 的stripQuestionSentences(...)不执行。rectification-agentic-chat.tsxsend结算后保留原始流式正文;loadCaseSnapshot→frontend/src/lib/rectification-snapshot-messages.tsmergeTurnQuestions只拷question/candidateOffer,不替换text。rectification-message-entry.tsx先渲染message.text再渲染question.prompt→ 两遍。去重只在 GET 组装的attachQuestionsToTurns(turn-question.ts)里,所以刷新后消失。- 排查已在 tsx 里复现:合并快照后正文仍含题干;
stripQuestionSentences对同一文本能正确去掉。 - 复发:BUG-585(修复
9d1c08ca,防复发"不得恢复把题干拼进 assistant_message")被 BUG-969 第③项(824ecff0,2026-09-18)带回:为防卡片加载失败时看不到题而拼回题干,声称"挂卡后去重",但测试(account-dialog-inert-20260918.test.ts)只覆盖刷新路径,并把rectification-answer-choice.test.ts断言改成期望题干在正文里。
B · 同一道选择题连画两张:用户在第 N 轮点选 B 发生过但程度较弱(高亮),其下是操作按钮、「目前范围 14:35-15:05,还在核对」,再下面又出现完全相同的题干和四个选项(未选中)。
- 第二张不是新一轮助手回答,而是独立的"持久化问题块":
rectification-agentic-chat.tsx中questionGap === "persisted_question"时渲染currentQuestion.prompt+ 新的RectificationChoiceCard(selectedKey="")。条件是rectificationQuestionGapState判定"没有任何消息挂着与currentQuestion.focus_id匹配的活题,但快照里仍有持久化问题"。同类:BUG-675 / 678(「目前范围」下挂裸题);BUG-678 意图"只在没有助手消息时兜底",但闸门没强制。 - 两个假设(需真实会话区分):
- H1(更可能):
submitStructuredChoice先markQuestionAnswered(本地标 B)再 POST/api/rectification/agent(action: "answer_choice");!response.ok/catch只移除占位、choiceNonce++、setError,不撤回已答标记、不重载快照 → 活题判定落空 → 兜底块再画一张。服务端可能的错误:stale_question/revision_conflict/stale_probe/ 500。 - H2:POST 成功但
nextInterviewPersisted为假且nextAction为ask_candidate_discriminator的willContinue分支只跑mergeTurnQuestions、没跑appendUnseenAssistantTurns(BUG-685 在requestTieBreak修过同型缺口),承载下一问的那轮没进客户端,兜底块替它画了出来;若题目完全相同,还说明选题重复了(BUG-540 / 559 / 592 守卫仍在代码里,需确认)。
- H1(更可能):
根因
A:服务端为兜底把题干拼进正文,客户端在实时路径上没有"挂卡即去重"。B:选择题提交失败不回滚本地状态 + 兜底问题块的出现条件过宽(H2 另有漏收回合)。
决策记录
- D1 保留 BUG-969 的兜底意图(卡片没挂上时用户仍能在正文看到题),但卡片一挂上就去掉正文里的同一句:
mergeTurnQuestions在快照回合带问题时,用服务端已去重的turn.text覆盖,或对message.text执行stripQuestionSentences(text, question.prompt);实时流里若同一轮已收到问题卡,也立即去重。 - D2 选择题提交失败:撤回本地已答标记(题目回到 active、无
answer_option)、重载快照、只保留一张可点的卡 + 错误提示「这次没提交上,请再点一次。」(对照 VOICE)。 - D3 兜底问题块只在没有任何助手消息时渲染;已有助手消息时把问题挂到最新助手消息上(BUG-678 原意),且同
focus_id的题永远只画一张。 - D4 H2 分支:
willContinue与send()结算后的快照合并同时执行appendUnseenAssistantTurns(同 BUG-685)。若复现确认服务端真出了重复题,另开 BUG 关联 BUG-540 / 592,不在本单改选题。
硬红线
- 回归测试必须走实时路径:流式收到"确认句 + 题干"→ 合并快照 → 断言题干在正文与问题块合计只出现一次;打字回答命中选择题、采集拒答两条路径各一条。
- 提交失败的回滚测试:模拟 409
stale_question与网络错误,断言页面只有一张可点卡 + 错误提示,已答标记被撤回。 - 不改服务端选题与打分;不改 BUG-969 的服务端兜底文本(只改客户端去重)——若必须改服务端,写明理由。
- 全量测试与基线逐条一致、新增 0;改动断言写三栏(含把 BUG-969 改成"期望题干在正文"的那条,改回并说明);真实浏览器截图(CDP 假数据)验证 A、B 两个场景。
- 不改
useConversationScrollAnchor(另一单在改)。
任务分解
- T1 实时去重(D1)+ 测试。
- T2 提交失败回滚(D2)+ 兜底块闸门(D3)+ 测试。
- T3 H2 漏收回合(D4)+ 测试。
- T4 记录:BUG-1045(A,复发自 BUG-585,写明 BUG-969 如何绕过防复发、为何测试没拦住);BUG-1046(B,关联 BUG-675 / 678 / 685);PROGRESS;
docs/testing/真机清单(打字回答后问题只出现一遍;选择题提交失败时只有一张卡 + 提示;刷新前后一致)。
BUG 编号
写单时最大 BUG-1044(1042–1044 由按钮间距单预留);本单 BUG-1045 / 1046,开工时核对。