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
@@ -6,6 +6,13 @@
|
||||
- 生时校正里回答超过一屏时,本轮开头停在屏幕顶部,不再一到就跳到最后一个字;自己往下滑到底之后,再长出来的内容会继续跟随(BUG-1044)。
|
||||
- Skill 版本不 bump。只改聊天滚动逻辑,不改接口、数据库和样式;登录态真机与部署待验收。
|
||||
|
||||
## 2026-09-26 — 生时校正:下一题不再在同一轮里出现两次,点选没提交上不再多画一张卡(待验收)
|
||||
|
||||
- 打字回答一道选择题(或说「想不到了」)之后,这一轮里下一题只出现一次:题干在选项卡上,正文只留「已记录……」那句。以前要刷新才恢复成一遍(BUG-1045,复发自 BUG-585)。
|
||||
- 点了一个选项但没提交上(网络断了、这道题刚被换掉)时,刚才点的选项会取消,页面只留一张可以再点的卡,下面一行「这次没提交上,请再点一次。」。以前会留着已选的卡,下面再画一张一模一样的空卡(BUG-1046)。打字回答没发出去时同样不再多画一张。
|
||||
- 同一道题在对话里只画一张:还没答的题统一挂在最后一条助手消息下面。服务端另写的一轮(比如点选后承载下一题的那条)也会并进对话区,不再由底部单独的问题块替它画。
|
||||
- Skill 版本不 bump。不改服务端选题、打分和回复文本;不改数据库。真机与部署待验收。
|
||||
|
||||
## 2026-09-26 — 回答下面的点赞 / 踩 / 复制 / 重试紧贴正文(待验收)
|
||||
|
||||
- 最后一轮回答很短时,点赞 / 踩 / 复制 / 重试那一排不再被推到屏幕下部,直接跟在正文下面;空白挪到按钮(和追问建议)之后。普通对话和生时校正一样。
|
||||
|
||||
@@ -13988,3 +13988,35 @@
|
||||
- 相关记录:BUG-930、BUG-931(同症状、留白坍塌触发)、BUG-1042、BUG-1043。
|
||||
- 复发自:无(BUG-931 同症状、不同触发条件)。
|
||||
- 修复版本:`codex/scroll-anchor-hook-fixes-20260926`,未部署。
|
||||
|
||||
## BUG-1045 | 生时校正打字回答后,同一轮里下一题出现两次(刷新后只剩一遍)
|
||||
|
||||
- 状态:investigating(修复已实现并有实时路径回归测试,本地 `next start` + 无头 Chrome 虚构数据复现并验证;staging 真机未走,未部署,不写 resolved)
|
||||
- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
|
||||
- 影响面:`frontend/src/lib/rectification-snapshot-messages.ts` `mergeTurnQuestions`(`rectification-agentic-chat.tsx` 的 `send` / `submitStructuredChoice` 结算后快照合并);服务端 `answer-choice.ts` `persistApplied` / `persistCollectDenialTurn` 的确定性回复正文(本轮不改)。
|
||||
- 用户现象:产品 09-26 staging 真机:打字回答一道待答选择题后,助手这一轮正文是「已记录,范围没变;…领先,…落后。」+ 下一题题干,下面又是同一句加粗题干和四个选项;刷新后只剩一遍。
|
||||
- 触发条件:确定性回复路径(打字回答命中待答选择题 `action=message` + choice 分支;或采集拒答),且下一题是可渲染选择题。两条路径都不经模型 runner,BUG-585 在 `agent-run.ts` 的 `stripQuestionSentences` 不执行。
|
||||
- 根因:BUG-969 修复第③项(`824ecff0`)为防「卡片没挂上时看不到题」,让 `persistApplied` / `persistCollectDenialTurn` 用 `composeCollectSpokenAssistantText` 把题干拼进 `assistant_message` 并作为一个 `answer.delta` 下发,声称「挂卡后去重」。去重只存在于 GET 组装的 `attachQuestionsToTurns`;客户端结算后保留流式原文,`mergeTurnQuestions` 只拷 `question` / `candidateOffer`、不动 `text`,`rectification-message-entry` 先画正文再画问题块 → 两遍;刷新走 GET 才去重,所以刷新后消失。
|
||||
- 复发自:**BUG-585**(修复 `9d1c08ca`,防复发写明「不得恢复把题干拼进 `assistant_message`」)。BUG-969 绕过了这条:它有意恢复拼接,用「挂卡后去重」代替原防线,但只在刷新路径实现了去重。为什么测试没拦住:`account-dialog-inert-20260918.test.ts` 的 BUG-969 用例只断言 `persistCollectDenialTurn` 流文本与 `attachQuestionsToTurns`(刷新路径)结果,没有走「流式结算 → 快照合并」的实时路径;同一提交把 `rectification-answer-choice.test.ts` 的断言从「正文不含题干」改成「正文含题干」(三栏注释写了原因,但没有对应的客户端去重断言);BUG-585 的防复发只锁在模型 runner 的 `agent-run.ts`,确定性回复路径没有源码合同。
|
||||
- 修复:D1(产品授权保留 BUG-969 的服务端兜底文本):`mergeTurnQuestions` 在快照回合带问题时,对消息正文执行与 GET 相同的 `stripQuestionSentences(text, question.prompt)`,幂等;实时对话与刷新读到同一段正文。服务端文本不改。
|
||||
- 验证:`frontend/tests/rectification-dup-question-20260926.test.tsx` 挂载真实 `RectificationAgenticChat`、假 `fetch` 走实时路径:打字回答命中选择题(流 = `composeCollectSpokenAssistantText(ack, stem)`)与采集拒答(流 = 真实 `persistCollectDenialTurn` 输出)两条,各断言可见文本里题干只出现 1 次、4 个选项可点;修复前两条都是 2 次。浏览器同场景:修复前可见题干 2 次(`A-before-1280.png`),修复后 1 次(`A-after-1280.png`)。`rectification-answer-choice.test.ts`、`account-dialog-inert-20260918.test.ts` 在服务端断言后追加客户端合并断言。浏览器:`docs/testing/rectification-dup-question-20260926/`。
|
||||
- 防复发:确定性回复若把题干拼进正文,客户端结算后的快照合并必须去掉同一句——回归测试走「流式 → 结算 → 合并」而不是只测 GET 组装。任何改「题干是否进正文」的提交必须同时有实时路径断言。
|
||||
- 相关记录:BUG-585、BUG-969、BUG-488、BUG-525。
|
||||
- 修复版本:`codex/rectification-dup-question-20260926`,未部署。
|
||||
|
||||
## BUG-1046 | 点选一道选择题后,同一道题又完整画了一张(未选中)
|
||||
|
||||
- 状态:investigating(H1 在虚构数据上已复现并修复;staging 真机当时的 POST 状态码未取证,H1/H2 以哪条为准待真机日志;未部署)
|
||||
- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
|
||||
- 影响面:`rectification-agentic-chat.tsx` `submitStructuredChoice` 失败分支、`send` 失败分支、`persisted_question` 兜底块;`rectification-snapshot-messages.ts` `appendUnseenAssistantTurns` / `placePersistedQuestion`。
|
||||
- 用户现象:产品 09-26 staging 真机:第 N 轮点选「B 发生过但程度较弱」(高亮),下面是操作按钮、「目前范围 14:35-15:05,还在核对」,再下面又出现完全相同的题干和四个选项(未选中)。
|
||||
- 触发条件与根因:
|
||||
- H1(虚构数据已复现):`submitStructuredChoice` 先 `markQuestionAnswered` 本地标 B 再 POST;`!response.ok` / `catch` 只移除占位、`choiceNonce++`、`setError`,不撤回已答标记、不重读快照。最新助手消息的题变成「已答」→ `liveQuestionOnMessages` 为假;快照里的 `current_question` 仍是这道题 → 缺口判定 `persisted_question` → 独立问题块再画一张可点卡。409 `stale_question` 与网络错误两种失败在修复前都得到「题干 2 次、两张卡」。打字回答失败(`send` 的 `!response.ok` / 网络错误)同型:`typed` 标记也不撤回。
|
||||
- H2(虚构数据已复现机制):点选成功、`willContinue` 分支只跑 `mergeTurnQuestions`,不跑 `appendUnseenAssistantTurns`(BUG-685 在 `requestTieBreak` 修过同型缺口);`send()` 结算后同样只合并。服务端另写的、承载下一题的回合进不了对话区,兜底块替它把题画出来。若题目完全相同还意味着选题重复:BUG-540(`pickProbe` 先 `probe_id` → `semantic_key`)、BUG-559 / 592(`sameYearProbeAsked`、`same_year_asked` 硬排除)守卫已核对仍在代码里;本单未改选题。
|
||||
- 兜底块条件过宽:BUG-678 的意图是「只在没有助手消息时兜底」,但闸门没强制;BUG-635 又规定旧消息上的同 `focus_id` 不算仍在显示,于是旧消息一张 + 兜底一张。
|
||||
- 修复:D2:选择题提交失败撤回本次本地标记(题回到 active、无 `answer_option`),卡片 key 随之变化重新挂载,重读快照(`applySnapshotTurnsToMessages`),错误行统一「这次没提交上,请再点一次。」(VOICE 已加);打字回答失败同样撤回 `typed` 标记。D3:`placePersistedQuestion` 把快照当前题挂到最新一条已结算助手消息上,并摘掉其它消息上同 `focus_id` 的副本;独立问题块只在没有助手消息、或最新助手消息已挂着另一道题(不同 focus,不会重复)时出现;缺口状态机本身不改。D4:`willContinue` 分支在占位行之前并入未见回合,`send()` 成功结算后改用 `applySnapshotTurnsToMessages`。
|
||||
- 验证:`frontend/tests/rectification-dup-question-20260926.test.tsx`:409 `stale_question` 与网络错误各一条(一张卡、0 个已选、4 个可点、错误文案、快照重读、可再次提交);打字失败一条;旧消息同焦点挪到最新消息一条;无助手消息时兜底一次一条;`willContinue` 未见回合并入且位于跟进行之前、`send()` 结算并入额外回合且不重复各一条。本文件 10 条在 `origin/staging`(`509987b9`)代码上 9 条失败(「无助手消息兜底」一条本来就对)。浏览器(`next start` + Chrome 151 无头,CDP 回放虚构接口):点 B、POST 回 409 `stale_question`,修复前可见题干 2 次、2 张卡、B 仍高亮、兜底块 1 个、错误行是服务端原句——与真机截图同形(`B-before-1280.png`);修复后 1 张卡、0 个已选、4 个可点、错误行「这次没提交上,请再点一次。」(`B-after-1280.png`)。
|
||||
- 防复发:任何先改本地状态再发请求的动作,失败分支必须撤回并重读;同一 `focus_id` 在对话区只能画一张卡;新写回合的前端动作一律并入对话区(BUG-685)。
|
||||
- 相关记录:BUG-675、BUG-678、BUG-685、BUG-635、BUG-917、BUG-540、BUG-559、BUG-592。
|
||||
- 复发自:BUG-678(兜底块「只在没有助手消息时」没有闸门);D4 同型于 BUG-685。
|
||||
- 修复版本:`codex/rectification-dup-question-20260926`,未部署。
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# PROGRESS · 生时校正:同一轮问题两次 + 同一道题两张卡(2026-09-26)
|
||||
|
||||
- 任务书:`TASK-rectification-dup-question-20260926.md`
|
||||
- 执行:Claude 子代理(直接执行模式,产品授权),分支 `codex/rectification-dup-question-20260926`,worktree `.worktrees/rectification-dup-question-20260926`
|
||||
- 基线:开工 `ac224cef`;BUG-1042 合入后变基到 `origin/staging` `509987b9`(按任务书「以新 head 为准」)。本单未推送。
|
||||
- BUG:BUG-1045(复发自 BUG-585)、BUG-1046;开工时 staging 最大号 BUG-1044,核对无冲突。状态均 investigating(真机与部署未做)。
|
||||
|
||||
## 做了什么
|
||||
|
||||
| 项 | 实现 | 位置 |
|
||||
|---|---|---|
|
||||
| T1 / D1 实时去重 | `mergeTurnQuestions` 在快照回合带问题时对正文跑 GET 同款 `stripQuestionSentences`,幂等。服务端 BUG-969 兜底文本**不改** | `src/lib/rectification-snapshot-messages.ts` |
|
||||
| T2 / D2 失败回滚 | `submitStructuredChoice` 的 `!ok` 与网络错误:撤回本次本地标记(`withdrawAnswerMark`,题回 active、无 `answer_option`)、移除占位、`choiceNonce++`(卡片 key 变化 → 重新挂载、清掉卡内本地选中)、重读快照(`applySnapshotTurnsToMessages`)、错误行「这次没提交上,请再点一次。」(`RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY`,VOICE 已加一行)。`send` 打字回答的 `!ok` / 无 body / 网络错误同样撤回 `typed` 标记 | `rectification-agentic-chat.tsx`、`rectification-surface-state.ts`、`docs/VOICE.md` |
|
||||
| T2 / D3 兜底闸门 | `placePersistedQuestion` + `turnQuestionFromCurrent`:缺口为 `persisted_question` 时,把快照当前题挂到最新一条已结算助手消息上,其它消息上同 `focus_id` 的副本摘掉;独立问题块只在没有助手消息、或最新助手消息已挂另一道题时出现。缺口状态机、`liveQuestionOnMessages` 只看最新消息(BUG-635 合同)都不改 | `rectification-snapshot-messages.ts`、`turn-question.ts`、`rectification-agentic-chat.tsx` |
|
||||
| T3 / D4 漏收回合 | `willContinue` 分支用 `appendUnseenAssistantTurns(..., { beforeRenderKey })` 把未见回合插在跟进行之前;`send()` 成功结算后改用 `applySnapshotTurnsToMessages` | 同上 |
|
||||
| T4 记录 | BUG-1045 / 1046、CHANGELOG、DESIGN 状态表两行、VOICE 一行、真机清单、状态板 | `docs/` |
|
||||
|
||||
## 偏离与说明
|
||||
|
||||
1. **D3 的一个例外**:最新助手消息已经挂着*另一道*题(不同 `focus_id`)时,当前题无处可挂,仍用独立问题块。这时不会重复同一 focus;硬挂到更早的消息会让题出现在已答题上方、打乱顺序。任务书写的是「兜底块只在没有任何助手消息时渲染」,此处放宽一格,写进 DESIGN 状态表。
|
||||
2. **BUG-635 合同保留**:旧消息上的同焦点仍不算「仍在显示」(`liveQuestionOnMessages` 只读最新消息,源码合同未动);D3 的做法是把题**挪到**最新消息、摘掉旧副本,所以「只画一张」与 BUG-635 同时成立。
|
||||
3. **最新消息上同焦点但本地已答、服务端仍 active** 时(失败未回滚的残留),D3 用服务端的 active 题替换本地标记。只在缺口为 `persisted_question`(非 busy)时发生,不会在提交途中闪回。
|
||||
4. 为不改 7 处按 `{messages.map((message) => {` 切片的源码合同,渲染循环保留 `messages.map`,循环内按 `renderKey` 取放置后的消息(`entryMessage`)。
|
||||
5. `rectification-agentic-chat.tsx` 注释里提到 `requestTieBreak` 会撞两条禁用该标识符的源码合同,已改写措辞(第一次全量跑出 2 条新失败,修正后通过)。
|
||||
6. 未改 `use-conversation-scroll-anchor.ts`(另一单在改)。H2 若真机确认服务端出了完全相同的题:BUG-540 / 559 / 592 守卫已核对仍在(`pickProbe` 先 `probe_id` → `semantic_key`;`sameYearProbeAsked` / `same_year_asked` 硬排除),本单不改选题。
|
||||
|
||||
## 改动的既有断言
|
||||
|
||||
没有改任何既有断言。`rectification-answer-choice.test.ts`(BUG-969 改成「正文含题干」的那条)与 `account-dialog-inert-20260918.test.ts` 的服务端断言保持原样——服务端文本没变,按任务书只**追加**客户端合并断言(正文不再含题干、问题块是这道题)。DESIGN.md 状态表改写时保留了 `rectification-unwritten-evidence.test.ts` 锁定的两句。
|
||||
|
||||
## 验证
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error,126 warnings(与基线 126 相同;改动文件无新增) |
|
||||
| 新测试 `tests/rectification-dup-question-20260926.test.tsx` | 11/11 通过;挂载真实 `RectificationAgenticChat`,假 `fetch` 走「流式 → 结算 → 快照合并」。前 10 条在 `509987b9` 代码上 9 条失败(「无助手消息兜底」本来就对) |
|
||||
| 全量 `npm test`(Node 20.19.2,无 Docker) | 3972 条:3884 通过 / 61 失败 / 27 跳过。失败名单与基线 `test11.log`(3960 / 61)**逐条一致**,新增失败 0;测试名消失 0、新增 12(本单 11 条 + BUG-1042 合入的 1 条) |
|
||||
| `npm run build -- --webpack` | 通过;`┌ ○ /` Static |
|
||||
| 首屏 gzip | `rootMainFiles` 4 个文件 130933 B → 130933 B(0%);`index.html` 引用的 36 个静态文件 656741 → 657563 B(+0.13%) |
|
||||
| 浏览器(`next start` + Chrome 151 无头,CDP 回放虚构 `/api/*`) | 见下表 |
|
||||
|
||||
浏览器,同一份虚构数据,基线 `509987b9` 构建 vs 本分支构建(1280 宽;计数排除 `legend.sr-only`):
|
||||
|
||||
| 场景 | 修复前 | 修复后 | 截图 |
|
||||
|---|---|---|---|
|
||||
| A 打字回答命中选择题 → 流 ack+stem → 快照挂卡 | 下一题可见 2 次 | 1 次;4 个可点选项 | `docs/testing/rectification-dup-question-20260926/A-before-1280.png` / `A-after-1280.png` |
|
||||
| B 点 B → POST 409 `stale_question` → 快照不变 | 题干 2 次、2 张卡、B 仍高亮、兜底块 1 个、错误行为服务端原句 | 1 张卡、0 个已选、4 个可点、错误行「这次没提交上,请再点一次。」 | `B-before-1280.png` / `B-after-1280.png` |
|
||||
|
||||
B 修复前截图与产品真机截图同形(已选 B 的卡 → 操作按钮 → 同题空白卡),即 H1 在虚构数据上可复现。截图右下「合盘历史暂时无法读取」是回放脚本没 mock `/api/synastry-reports`,与本单无关。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- 无登录态真机、无真实模型:真机清单 `docs/testing/rectification-dup-question-20260926.md`。H1 / H2 以哪条为准需真机当时 `/api/rectification/agent` 的状态码。
|
||||
- Node 20:`mock.module` 类测试 ERR_MODULE_NOT_FOUND;无 Docker:DB 套件。失败清单与基线逐条一致(61 条)。
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `TASK-rectification-dup-question-20260926.md` | — | **同一轮问题出现两次(BUG-1045,复发自 BUG-585,BUG-969 拼回题干、去重只在刷新路径)+ 同一道选择题连画两张(BUG-1046:提交失败不回滚本地已答 + 兜底问题块条件过宽;H2 漏收回合)**。先于 latency 单 | 待领取 | — |
|
||||
| `TASK-rectification-dup-question-20260926.md` | `PROGRESS-rectification-dup-question-20260926.md` | **同一轮问题出现两次(BUG-1045,复发自 BUG-585,BUG-969 拼回题干、去重只在刷新路径)+ 同一道选择题连画两张(BUG-1046:提交失败不回滚本地已答 + 兜底问题块条件过宽;H2 漏收回合)**。先于 latency 单 | 待验收 | `codex/rectification-dup-question-20260926`(未推送;BUG-1045/1046) |
|
||||
| `TASK-rectification-latency-20260926.md` | — | **每轮等待过长(BUG-1047)**:一轮打字回答串行 5 次开思考的模型调用,分类在开流前且无超时。产品定:收尾两步不动、分类保持思考只加 10 秒超时、发出后立即出确定性进度句并按阶段更新;补埋点;服务端无口吻优化须 A/B 逐位一致。排在 dup-question 之后 | 待领取 | — |
|
||||
| `TASK-rectification-session-title-result-20260922.md` | `PROGRESS-rectification-session-title-result-20260922.md` | **校正会话标题改写结果(BUG-1001)**:BUG-988 把副标题改成最后活动时间后,标题里的日期成了重复;而 BUG-929 删掉 `uniquifySessionTitle` 之后,`resolveSessionTitle` 对校正入口只返回 `生时校正 · M月D日`,**同一天多条标题完全相同**(真机截图:9/17 三条同名、9/16 两条同名、9/14 一天 5 条);旧标题的钟点后缀是创建时间、副标题是最后活动时间,同一行两个对不上的时间。**产品 09-22 拍板**:D1 标题改为承载结果——有 `accepted_time`/`confirmed_time` 写 `生时校正 · HH:MM`,否则写 `candidate_range` 的 `生时校正 · HH:MM–HH:MM`(推翻 BUG-929 的日期口径,但「不得再加墙钟去重后缀」保留);D2 存量批量重算(推翻 BUG-929「旧标题不批量改」,先例 `20260916020000_rectification_session_title_repair.sql`);D3 今日节奏保留日期(我判定的例外);D4 只在结果变化时改 title,`open` 路径不动(对 BUG-699 边界的有限扩展)。**红线**:任何写标题的路径都不得 bump `updated_at`(否则 13 条历史会话集体跳顶、毁掉 BUG-988);标题算式必须只有一处实现(一个 SQL 函数,触发器与回填共用)——BUG-987/992 都栽在「两层规则各写一份」。T4 跨层对断不得让步。已 grep 出真正受影响的只有 7 个文件,Python 侧只查裸字符串、无需改。BUG 段 1001 起 | 已验收(Claude 09-25:单一 SQL 函数、不动 updated_at、手改名不覆盖、回填幂等) | `88fe67df`/`a94a1d67`(已部署) |
|
||||
| `TASK-rectification-convergence-20260830.md` | `PROGRESS-rectification-convergence-20260830.md` | 收敛重构 v2 | 已验收 | `3a4396a4`、`86ba17ee` |
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# 生时校正:同一轮问题两次 / 同一道题两张卡 · 真机清单(2026-09-26)
|
||||
|
||||
本轮在本地 `next start` + 无头 Chrome 里用虚构接口数据截过图(`rectification-dup-question-20260926/`),没有登录态真机,也没有真实模型。下面留给真人,用自己的账户在 staging 上照做(部署后):
|
||||
|
||||
1. 开一次生时校正,答到出现 A/B/C/D 选择题。**不点选项**,在输入框打字回答(例如「有,2023 年 3 月」)。这一轮回答里:正文只有「已记录……」一类的话,下一题的题干只出现一次(在选项卡上方),不会正文里一遍、卡上又一遍。
|
||||
2. 采集题(「还有吗?比如……」)下打「想不到了」。如果下一步是选择题:题干同样只出现一次。
|
||||
3. 第 1、2 步各刷新一次页面:刷新前后看到的内容一致(都是一遍)。
|
||||
4. 选择题下点一个选项的同时断网(手机开飞行模式,或电脑拔网线后立刻点):刚点的选项取消高亮,页面只有一张可以点的卡,下面一行「这次没提交上,请再点一次。」。恢复网络后再点一次,正常进入下一步。不会出现「上面一张已选、下面又一张空白」的两张卡。
|
||||
5. 同一局里连答五六道选择题:每道题只画一张卡;「目前范围 …,还在核对」下面不会再出现一张同样题干的卡。
|
||||
6. 手机宽度(375px)上重复第 1、4 步:卡片与错误行不溢出、不遮挡输入框。
|
||||
|
||||
如果第 4 步真机复现了两张卡,请把当时浏览器开发者工具 Network 里 `/api/rectification/agent` 那次请求的状态码记下来(不需要请求内容),用于判定 BUG-1046 的 H1 / H2。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
+3
-2
@@ -291,8 +291,9 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
|---|---|---|
|
||||
| `opening` | live row “正在读取你的出生资料,准备第一个问题…”, then tool labels | enabled (typing queues), stop visible |
|
||||
| `empty` | “这段校正还没有开始。” and one primary action “开始提问” | enabled |
|
||||
| `question-live` | the asking message carries the embedded card or the spoken stem. An active spoken or choice focus without `asked_turn_id` hangs on the last assistant message, same avatar column | enabled, placeholder points at the card |
|
||||
| `question-gap`, persisted question | only when no assistant message can carry the prompt; a host question row (`data-testid="persisted-question"`) | enabled, placeholder “请回答上面的问题…” |
|
||||
| `question-live` | the asking message carries the embedded card or the spoken stem. An active spoken or choice focus without `asked_turn_id` hangs on the last assistant message, same avatar column. A deterministic reply streams “ack + stem” so the stem is readable before the card arrives; once the snapshot attaches the card, the bubble drops that sentence on the live transcript as well as on refresh (BUG-1045) | enabled, placeholder points at the card |
|
||||
| `question-gap`, persisted question | the snapshot's current question hangs on the latest settled assistant message (the client does this too, not only GET), and any other copy of the same `focus_id` comes off: one focus, one card (BUG-1046). The host question row (`data-testid="persisted-question"`) appears only when no assistant message can carry the prompt: there is none, or the latest one already carries a different question | enabled, placeholder “请回答上面的问题…” |
|
||||
| `question-live`, after a failed tap | the tapped option is taken back (question active again, nothing selected), the card remounts tappable, the Case is re-read, and the error line is “这次没提交上,请再点一次。” — never a second copy of the card below (BUG-1046) | enabled |
|
||||
| `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled |
|
||||
| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “接着问” | enabled |
|
||||
| `question-gap`, collect waiting | no “没有拿到下一个问题”; the last assistant line already has the precise gap. Never while the latest assistant message still carries an unanswerable tap question — that is the repair exit below (BUG-917) | enabled, placeholder “再说一件带年月的事” |
|
||||
|
||||
@@ -99,6 +99,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 采用后会拿盘外核对来验证。 / 已采用 04:53 · 范围 … / 改选 / 之后新建对话即按此时间排盘。 | 没有核对题:已采用 04:53,新建对话即按这个时间排盘。有核对题:前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 采用状态在该列按钮上;整张卡留着。收尾句跟卡片同一列,不单独贴左,也不回输入框上方。 |
|
||||
| (采用后只念代表分钟)代表分钟是 05:12。 | 这 27 分钟里,事业方向、性格底色的判断是稳定的;婚恋(D9)会随分钟变,看盘时按范围读。这不是已确认的唯一出生分钟。 | 采用后先讲区间里什么稳、什么随分钟变;两句来自服务端,不是模型编的。 |
|
||||
| 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 |
|
||||
| 选择题处理失败,请稍后重试。 / 这道题已经过期,请回答当前问题(点选没提交上,下面又画出同一张卡) | 这次没提交上,请再点一次。 | 点选没到服务器时撤回已选,只留一张可点的卡,错误行就这一句。不说「稍后」,不把服务端状态码翻成话。 |
|
||||
| 好的,记下了。 | 记下了:2016 年 9 月入学、2020 年 6 月毕业。 | 只复述年-月和事件短语,不评价价值。 |
|
||||
| 记下了,这方面先跳过。 | 答「这类事都没有过」回「记下了,这条按没有发生过记。」答「记不太清楚 / 这条先跳过」回「记下了,这题先放着,后面换个问法再问一次。」 | 拒绝和跳过不是同一句话。 |
|
||||
| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在同一条消息里正文之后出现,过渡用中性句。 |
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_SNAPSHOT_RETRY_LABEL,
|
||||
RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY,
|
||||
RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
|
||||
RECTIFICATION_DELIVERED_COPY,
|
||||
@@ -125,6 +126,7 @@ import {
|
||||
parseTurnQuestion,
|
||||
questionIsAnswered,
|
||||
resolveSelectionCardMessageKey,
|
||||
turnQuestionFromCurrent,
|
||||
type SelectionCardLock,
|
||||
} from "@/lib/rectification-agentic/v9/turn-question";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -132,6 +134,7 @@ import {
|
||||
appendUnseenAssistantTurns,
|
||||
applySnapshotTurnsToMessages,
|
||||
mergeTurnQuestions,
|
||||
placePersistedQuestion,
|
||||
} from "@/lib/rectification-snapshot-messages";
|
||||
|
||||
type PersistedTurn = Readonly<{
|
||||
@@ -261,6 +264,29 @@ function markQuestionAnswered(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* BUG-1046: undo `markQuestionAnswered` when the answer never reached the
|
||||
* server, so the question is active again with no option picked. Only the mark
|
||||
* this action made is undone; a server-resolved question is left alone.
|
||||
*/
|
||||
function withdrawAnswerMark(
|
||||
current: RenderMessage[],
|
||||
focusId: string,
|
||||
selected: ChoiceKey | "stop" | "skip_probe" | "typed",
|
||||
): RenderMessage[] {
|
||||
const marked = selected === "typed" ? null : selected;
|
||||
return current.map((message) => {
|
||||
const question = message.question;
|
||||
if (!question || question.focus_id !== focusId) return message;
|
||||
if (question.status !== "resolved" || question.answer_option !== marked) return message;
|
||||
return { ...message, question: { ...question, status: "active", answer_option: null } };
|
||||
});
|
||||
}
|
||||
|
||||
function isSettledAssistantMessage(message: RenderMessage): boolean {
|
||||
return message.role === "assistant" && message.state === "settled" && !message.failed;
|
||||
}
|
||||
|
||||
function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonly unknown[] {
|
||||
return Array.isArray(payload?.turns) ? payload.turns : [];
|
||||
}
|
||||
@@ -734,6 +760,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
liveRow,
|
||||
]));
|
||||
if (!continuation) conversationAnchor.pinLatestTurn();
|
||||
// The typed answer never reached the server: the question it marked is
|
||||
// still the pending one (BUG-1046, same as a failed tap).
|
||||
const withdrawTypedMark = (current: RenderMessage[]) => (
|
||||
!continuation && action === "message" && pendingFocusId
|
||||
? withdrawAnswerMark(current, pendingFocusId, "typed")
|
||||
: current
|
||||
);
|
||||
let raw = "";
|
||||
let runOutcome: "succeeded" | "stopped" | "failed" = "failed";
|
||||
let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace();
|
||||
@@ -785,7 +818,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
const message = payload?.message || payload?.error || `请求失败(${response.status})`;
|
||||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||||
setMessages((current) => withdrawTypedMark(
|
||||
current.filter((message) => message.renderKey !== assistantRenderKey),
|
||||
));
|
||||
if (payload?.code === "profile_incomplete") {
|
||||
onProfileIncomplete?.();
|
||||
return;
|
||||
@@ -801,7 +836,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return;
|
||||
}
|
||||
if (!response.body) {
|
||||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||||
setMessages((current) => withdrawTypedMark(
|
||||
current.filter((message) => message.renderKey !== assistantRenderKey),
|
||||
));
|
||||
setError("服务暂时不可用,请稍后再试。");
|
||||
return;
|
||||
}
|
||||
@@ -970,7 +1007,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
runOutcome = "succeeded";
|
||||
await loadCaseSnapshot((turns) => {
|
||||
if (turns.length) {
|
||||
setMessages((current) => mergeTurnQuestions(current, turns));
|
||||
// D4 (BUG-1046): a turn the server wrote alongside this one (the
|
||||
// one carrying the next question) joins the transcript too.
|
||||
setMessages((current) => applySnapshotTurnsToMessages(
|
||||
current,
|
||||
turns,
|
||||
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
|
||||
));
|
||||
}
|
||||
});
|
||||
onMessagesChange?.([
|
||||
@@ -1007,7 +1050,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return;
|
||||
}
|
||||
setError("生时校正暂时不可用,请稍后再试。");
|
||||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||||
setMessages((current) => withdrawTypedMark(current).flatMap((message): RenderMessage[] => {
|
||||
if (message.renderKey !== assistantRenderKey) return [message];
|
||||
return hasActivityReceipt(completedReceipt)
|
||||
? [{
|
||||
@@ -1098,6 +1141,26 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
]);
|
||||
conversationAnchor.pinLatestTurn();
|
||||
setPending(true);
|
||||
// BUG-1046 (D2): a tap that did not land takes back its local answered
|
||||
// mark and placeholder; the card key changes (answer_option → nonce), so
|
||||
// the card remounts clean and one tappable card is left.
|
||||
const rollBackFailedChoice = () => {
|
||||
setMessages((current) => withdrawAnswerMark(
|
||||
current.filter((message) => message.renderKey !== assistantRenderKey),
|
||||
focusId ?? "",
|
||||
optionId,
|
||||
));
|
||||
setChoiceNonce((current) => current + 1);
|
||||
};
|
||||
// The server may have moved on (stale_question, a dropped response after
|
||||
// it applied): re-read the Case so the card reflects what it holds.
|
||||
const reloadAfterFailedChoice = () => loadCaseSnapshot((turns) => {
|
||||
setMessages((current) => applySnapshotTurnsToMessages(
|
||||
current,
|
||||
turns,
|
||||
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
|
||||
));
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
runAbort.current = abortController;
|
||||
try {
|
||||
@@ -1122,13 +1185,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||||
setChoiceNonce((current) => current + 1);
|
||||
rollBackFailedChoice();
|
||||
if (payload?.code === "profile_incomplete") {
|
||||
onProfileIncomplete?.();
|
||||
return;
|
||||
}
|
||||
setError(payload?.message || payload?.error || `请求失败(${response.status})`);
|
||||
setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY);
|
||||
await reloadAfterFailedChoice();
|
||||
return;
|
||||
}
|
||||
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
|
||||
@@ -1139,7 +1202,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
onCompleted?.();
|
||||
await loadCaseSnapshot((turns) => {
|
||||
if (willContinue) {
|
||||
setMessages((current) => mergeTurnQuestions(current, turns));
|
||||
// D4 (BUG-1046, the same gap BUG-685 closed for the tie-break entry): a turn
|
||||
// the server wrote while applying the tap joins the transcript,
|
||||
// above the row the follow-up run continues on.
|
||||
setMessages((current) => mergeTurnQuestions(
|
||||
appendUnseenAssistantTurns(
|
||||
current,
|
||||
messagesFromTurns(turns as readonly PersistedTurn[]),
|
||||
{ beforeRenderKey: assistantRenderKey },
|
||||
),
|
||||
turns,
|
||||
));
|
||||
return;
|
||||
}
|
||||
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
||||
@@ -1192,9 +1265,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return;
|
||||
}
|
||||
lastRunOutcome.current = "failed";
|
||||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||||
setChoiceNonce((current) => current + 1);
|
||||
setError("选择题处理失败,请稍后重试。");
|
||||
rollBackFailedChoice();
|
||||
setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY);
|
||||
await reloadAfterFailedChoice();
|
||||
} finally {
|
||||
if (runAbort.current === abortController) runAbort.current = null;
|
||||
if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded";
|
||||
@@ -1541,6 +1614,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
retryAttempts: questionRetryAttempts,
|
||||
retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
});
|
||||
// BUG-1046 (D3): the persisted question is drawn once. With an assistant
|
||||
// message to carry it, it hangs there (BUG-678's intent) and other copies of
|
||||
// the same focus come off; the standalone block is only the fallback when
|
||||
// nothing can carry it. The gap state itself is unchanged.
|
||||
const persistedTurnQuestion = questionGap === "persisted_question"
|
||||
? turnQuestionFromCurrent(currentQuestion, choiceCard)
|
||||
: null;
|
||||
const placedQuestion = persistedTurnQuestion
|
||||
? placePersistedQuestion(messages, persistedTurnQuestion, isSettledAssistantMessage)
|
||||
: null;
|
||||
const placedByKey = placedQuestion && placedQuestion.messages !== messages
|
||||
? new Map(placedQuestion.messages.map((message) => [message.renderKey, message]))
|
||||
: null;
|
||||
const showStandaloneQuestion = questionGap === "persisted_question"
|
||||
&& (placedQuestion ? placedQuestion.standalone : true);
|
||||
const verifiedIdleCopy = questionGap === "verified_idle"
|
||||
? postAdoptVerifyDoneCopy(
|
||||
savedTime,
|
||||
@@ -1711,6 +1799,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
</div>
|
||||
)}
|
||||
{messages.map((message) => {
|
||||
// D3: the same message with the persisted question placed on it.
|
||||
const entryMessage = placedByKey?.get(message.renderKey) ?? message;
|
||||
const regenerating = regeneratingMessageKey === message.renderKey;
|
||||
const canRegenerate = message.renderKey === latestRegeneratableKey
|
||||
&& !busy
|
||||
@@ -1722,7 +1812,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
className="rectification-message-wrap rectification-message-entry"
|
||||
>
|
||||
<RectificationMessageEntry
|
||||
message={message}
|
||||
message={entryMessage}
|
||||
busy={busy}
|
||||
readonly={readonly}
|
||||
regenerating={regenerating}
|
||||
@@ -1763,7 +1853,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{questionGap === "persisted_question" && currentQuestion?.prompt && (
|
||||
{showStandaloneQuestion && currentQuestion?.prompt && (
|
||||
<div className="rectification-message-wrap rectification-message-entry" data-testid="persisted-question">
|
||||
<div className="rectification-message-question is-standalone">
|
||||
{persistedQuestionSurface({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { stripQuestionSentences } from "./collect-prompt";
|
||||
import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card";
|
||||
import { parseAgentChoiceCopy, type ChoiceKey, type RectificationChoiceCard } from "./choice-card";
|
||||
import type { ConversationFocus } from "./tool-service";
|
||||
|
||||
export type TurnQuestionKind = "choice" | "collect_spoken" | "reverse_verify";
|
||||
@@ -109,6 +109,32 @@ export function parseTurnQuestion(value: unknown): TurnQuestion | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot's `current_question` (+ its GET `choice_card`) as the question a
|
||||
* message carries, for hanging it on the latest assistant message (BUG-1046).
|
||||
*/
|
||||
export function turnQuestionFromCurrent(
|
||||
current: Readonly<{ kind: "choice" | "collect_spoken"; prompt: string | null; focus_id: string | null; question_id: string | null }> | null,
|
||||
card: RectificationChoiceCard | null,
|
||||
): TurnQuestion | null {
|
||||
if (!current?.prompt || !current.focus_id) return null;
|
||||
const matchingCard = card && card.focus_id === current.focus_id ? card : null;
|
||||
const questionId = current.question_id ?? matchingCard?.question_id ?? null;
|
||||
if (!questionId) return null;
|
||||
return {
|
||||
focus_id: current.focus_id,
|
||||
question_id: questionId,
|
||||
kind: current.kind,
|
||||
prompt: current.prompt,
|
||||
options: current.kind === "choice" && matchingCard
|
||||
? matchingCard.options.map((option) => ({ key: option.key, label: option.label }))
|
||||
: null,
|
||||
status: "active",
|
||||
answer_option: null,
|
||||
probe_id: matchingCard?.probe_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function attachQuestionsToTurns<T extends { id: string; role: string; text: string | null }>(
|
||||
turns: readonly T[],
|
||||
focuses: readonly ConversationFocus[],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stripQuestionSentences } from "./rectification-agentic/v9/collect-prompt.ts";
|
||||
import { parseTurnQuestion, persistedOfferFromTurn, type TurnQuestion } from "./rectification-agentic/v9/turn-question.ts";
|
||||
|
||||
export type SnapshotTurnMessage = {
|
||||
@@ -27,8 +28,17 @@ export function mergeTurnQuestions<T extends SnapshotTurnMessage>(
|
||||
if (!message.turnId || !byId.has(message.turnId)) return message;
|
||||
const next = byId.get(message.turnId);
|
||||
const question = next?.question ?? undefined;
|
||||
// BUG-1045: a deterministic reply streams 「ack + next stem」 so the stem is
|
||||
// readable even if the card never attaches (BUG-969 ③). Once the card is
|
||||
// attached here the bubble drops the same sentence — the same
|
||||
// `stripQuestionSentences` the GET route's `attachQuestionsToTurns` runs,
|
||||
// so the live transcript and a refresh read the same. Idempotent.
|
||||
const text = question && message.text
|
||||
? stripQuestionSentences(message.text, question.prompt)
|
||||
: message.text;
|
||||
return {
|
||||
...message,
|
||||
text,
|
||||
question: question ?? undefined,
|
||||
candidateOffer: persistedOfferFromTurn(
|
||||
next?.offerResultId,
|
||||
@@ -39,9 +49,15 @@ export function mergeTurnQuestions<T extends SnapshotTurnMessage>(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assistant turns the server wrote that the transcript does not hold yet.
|
||||
* `beforeRenderKey` keeps a live row (a tap's follow-up run) last, so a turn
|
||||
* the server wrote while applying the tap reads above it (BUG-1046 / D4).
|
||||
*/
|
||||
export function appendUnseenAssistantTurns<T extends SnapshotTurnMessage>(
|
||||
current: T[],
|
||||
extras: readonly T[],
|
||||
options?: Readonly<{ beforeRenderKey?: string }>,
|
||||
): T[] {
|
||||
const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : []));
|
||||
const incoming = extras.filter((message) => (
|
||||
@@ -49,7 +65,12 @@ export function appendUnseenAssistantTurns<T extends SnapshotTurnMessage>(
|
||||
&& message.turnId
|
||||
&& !known.has(message.turnId)
|
||||
));
|
||||
return incoming.length ? [...current, ...incoming] : current;
|
||||
if (!incoming.length) return current;
|
||||
const at = options?.beforeRenderKey
|
||||
? current.findIndex((message) => message.renderKey === options.beforeRenderKey)
|
||||
: -1;
|
||||
if (at < 0) return [...current, ...incoming];
|
||||
return [...current.slice(0, at), ...incoming, ...current.slice(at)];
|
||||
}
|
||||
|
||||
export function applySnapshotTurnsToMessages<T extends SnapshotTurnMessage>(
|
||||
@@ -62,3 +83,44 @@ export function applySnapshotTurnsToMessages<T extends SnapshotTurnMessage>(
|
||||
turns,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* BUG-1046 / D3: where the Case's persisted question is drawn when no settled
|
||||
* message shows it live. It hangs on the latest settled assistant message
|
||||
* (BUG-678's intent) and any other copy of the same `focus_id` is taken off, so
|
||||
* one focus is drawn once. `standalone` is true only when there is nothing to
|
||||
* hang it on: no assistant message at all, or the latest one already carries a
|
||||
* different question (then the standalone block is the only place that does
|
||||
* not reorder the transcript, and it cannot duplicate this focus).
|
||||
*/
|
||||
export function placePersistedQuestion<T extends SnapshotTurnMessage>(
|
||||
current: T[],
|
||||
question: TurnQuestion,
|
||||
isSettledAssistant: (message: T) => boolean,
|
||||
): Readonly<{ messages: T[]; standalone: boolean }> {
|
||||
let target = -1;
|
||||
for (let index = current.length - 1; index >= 0; index -= 1) {
|
||||
const message = current[index]!;
|
||||
if (isSettledAssistant(message) && (message.text || message.question)) {
|
||||
target = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const targetQuestion = target >= 0 ? current[target]!.question : undefined;
|
||||
const hang = target >= 0 && (!targetQuestion || targetQuestion.focus_id === question.focus_id);
|
||||
let changed = false;
|
||||
const messages = current.map((message, index) => {
|
||||
if (hang && index === target) {
|
||||
// Already live there: leave it. Otherwise (absent, or a local answered
|
||||
// mark the server does not hold) the server's active question wins.
|
||||
if (message.question && message.question.focus_id === question.focus_id
|
||||
&& message.question.status === "active") return message;
|
||||
changed = true;
|
||||
return { ...message, question };
|
||||
}
|
||||
if (message.question?.focus_id !== question.focus_id) return message;
|
||||
changed = true;
|
||||
return { ...message, question: undefined };
|
||||
});
|
||||
return { messages: changed ? messages : current, standalone: !hang };
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export const RECTIFICATION_DELIVERED_COPY = "再问下去也分不开了。范
|
||||
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问";
|
||||
export const RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY = "这一问还没读到。";
|
||||
export const RECTIFICATION_SNAPSHOT_RETRY_LABEL = "重新读取";
|
||||
/** A tapped option that did not reach the server (BUG-1046): the card is tappable again. */
|
||||
export const RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY = "这次没提交上,请再点一次。";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_FAILED_COPY = "暂时接不上,请新建一次校正。";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_LIMIT = 2;
|
||||
export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
|
||||
|
||||
@@ -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