# TASK · Pass 4 按句放行与思考死链清理(2026-09-18 第三轮) > 基线:`origin/staging` @ `1e553976`(含 `e32ce624`,BUG-945~949 已由 Claude 验收通过)。 > 前序:`TASK-consult-three-channels-20260918.md` → `TASK-consult-three-channels-fix-20260918.md`。 > BUG 编号起点:开工时 `docs/BUG_HISTORY.md` 最大号为 **BUG-949**,本单占 **BUG-950 ~ BUG-953**。 > 本单全部来自 `e32ce624` 的验收 review,不重开设计。 ## 0. 决策记录(产品负责人 2026-09-18 授权) 1. **正文必须逐字流式**。「等 Pass 4 判完再整段发」不可接受。产品原话:「不能直接流式动画输出吗,那就按句放行」。→ BUG-950。 2. **无出生分钟模式的拒绝改成按句丢弃**,不再整段替换。产品已同意。→ BUG-951。 3. **无出生分钟模式下的具体日期要留痕**。→ BUG-952。 4. 校正流的 token 级 thinking 通道按 P2「provider reasoning 永不外发」**删除**,不是接上。→ BUG-953。 ## 1. BUG-950(P1)正文不再逐字出现,整段一次性蹦出来 **实证**(`1e553976` 实跑,三个 `text-delta` 喂进 `streamAgentResponse`): ``` 不设 pass4Mode → answer.delta 3 条:「第一句话。」「第二句话。」「第三句话。」 verified_chart → answer.delta 1 条:「第一句话。第二句话。第三句话。」 declared_birth_window → answer.delta 1 条:同上 ``` 根因:`frontend/src/lib/stream-agent-response.ts` 的 `holdAnswer` 一旦为真,`flushHeld` 就不再 `send`,全部攒进 `fullOutput`,等 `finishPass4` 一次性发出。而 `route.ts:1005 / 1105 / 1228` 三条路径**全部**设了 `pass4Mode`,所以本命、窗口、一般对话的正文都变成整段出现,首字延迟 = 整段写完的时间。`PROGRESS-consult-three-channels-fix-20260918.md` §948 让步 1 记了「正文 hold 到判定结束再发,避免闪两次」,但没记这等于关掉打字机。 **要求:按句放行。** 1. 只缓冲**当前这一句**(到 `。!?\n` 为止),句子闭合就对这一句跑 `classifyPass4`: - 没有 `reject` → 立刻 `send` 这一句,恢复流式; - 有 `reject` → 这一句从不发出(用户看不到闪动),按 §2 的规则处理。 2. `exact-timing` 是 `observe`,**不得**因此阻塞发送。 3. `methodology`(统一参数 / 技法审计表)是整篇结构问题,命中时仍可整篇退回重写——但重写只允许发生在**还没发出任何正文**之前;已经发出过句子就不再整篇重写,改为丢弃后续命中句。这条是「不闪两次」与「要流式」的分界线,必须写进代码注释。 4. 流末 `flush`:不以句号结尾的残句照样过一次 `classifyPass4` 再决定发不发。 5. 验收标准: - 新增合同测试——三个 `text-delta` 在 `pass4Mode: "verified_chart"` 下产出 **≥3 条** `answer.delta`(现在是 1 条); - 含保证句的那一句不出现在任何 `answer.delta` 里,且回执有 `pass4-reject:guarantee`; - 含日期的句子照常逐句发出,回执有 `pass4-observe:exact-timing`; - `e32ce624` 新加的三条 Pass 4 测试改成按句口径,写「原值 / 新值 / 原因」三栏。 ## 2. BUG-951(P1)没有出生分钟时,整段回答被一句拒绝顶掉 `frontend/src/lib/timing-output-guard.ts` 的 `applyPass4Policy`: ```ts if (options?.secondPass && mode === "general_no_birth_time" && rejects.some(...personal-chart)) { next = GENERAL_NO_BIRTH_TIME_REFUSAL; // 整段丢弃 } ``` 而 `route.ts:1005` 的一般路径**没有 `composeAnswer`**,`finishPass4` 里 `report.retry && options.composeAnswer` 不成立,于是直接走 `else if (report.retry)` 落二次——**没有重写机会**,一句个人盘断言就把整段回答换成「这部分需要具体出生分钟才能判断,我不会补造时间」。 旧实现(`createBirthTimeModeOutputGuard`)在这点上更好:只挖掉个人盘句、保留一般知识句。被 `e32ce624` 改掉的那条测试原名就是 `... while preserving general knowledge`。**这是我方修复单 §4c 第 2 条「兜底句替代整段」措辞造成的,执行方照做无过。** **要求**: 1. 二次落地改成**按句丢弃**(`dropGuaranteeClauses` 已经是这个形状,抽成通用的 `dropRejectedClauses(text, mode)`):只丢命中 `guarantee` / `personal-chart` 的句子,其余原样保留。 2. **只有当所有句子都被丢掉、正文为空时**,才发 `GENERAL_NO_BIRTH_TIME_REFUSAL` 兜底句。 3. 保证句在无 `composeAnswer` 的路径上被静默删句——这一点保持(不加「有一句被省略」之类的提示),但回执必须有 `pass4-reject:guarantee` 可查。 4. 验收标准:把 `e32ce624` 删掉的那条断言**恢复**——混合文本(一般知识句 + 个人盘句 + 保证句)过 Pass 4 后,一般知识句仍在,个人盘句与保证句不在,且不出现 `。。` 这类残留标点;三栏说明写「原值 / 新值 / 原因」。 ## 3. BUG-952(P2)没有出生时间时的具体日期既不记录也不拦 `classifyPass4` 对 `exact-timing` 的分支是: ```ts if (mode !== "general_no_birth_time") steps.push({ action: "observe", ... }); continue; ``` 即**恰恰在最没有依据给日期的模式下**,日期一条痕迹都不留。 **要求**:`general_no_birth_time` 下 `exact-timing` 记 `observe`(不拦,保持产品口径:日期不删字)。验收:该模式下含日期的正文原样通过,且回执有 `pass4-observe:exact-timing`。 ## 4. BUG-953(P3)校正流的 token 级 thinking 是死链,按 P2 删掉 `toPublicThinkingDelta` / `mapStreamChunkToThinking`(`frontend/src/lib/rectification-agentic/v9/stream-mapping.ts:189-199`)**在生产代码里没有任何调用方**,只有测试在用。校正 v9 运行时对 `reasoning-delta` 的真实处理是 `step-answer.ts:165-168` 的 `return { kind: "none" }`——直接丢弃。`public-receipt.ts:8` 的模块注释写得很清楚:确定性 public phase「replaces token-level thinking on the browser stream」。 也就是说:`e32ce624` 的 BUG-947 修好的是一条没人走的路。修得对(测试该绿),但结论应当是**删除**而不是保留——上游任务书 P2 的原则是「provider 的 reasoning token 永不外发,展示给用户的思考是产品产物(Pass 2 条目),不是 CoT 抓取」,把 reasoning 分片推给浏览器本身就违反 P2。 **要求**: 1. 删除 `toPublicThinkingDelta`、`mapStreamChunkToThinking`、`InternalThinkingDeltaEvent`,以及 `think-step-gate.ts` 里只服务于它们的 `acceptThinkingFragment`、`createThinkingFragmentAssembler`。`acceptThinkStepText`(Pass 2 条目门)保留。 2. 把 `frontend/tests/rectification-step-answer.test.ts:44`(现在断言「reasoning 能变成 thinking 行」)翻转成**否定合同**:校正流对 `reasoning-delta` 的处理必须是丢弃,源码里不得出现把 `reasoning-delta` 映射成对外事件的函数。`rectification-v9-stream.test.ts` 的两条同类断言一并翻转。 3. 将来校正面板若要显示判断依据,走咨询流同一条路(Pass 2 产品化条目 `think.step`),任务书里另立单;本单不实现。 4. 验收:全仓 grep 不到这几个函数名;测试总数不得下降(翻转不是删除)。 ## 5. 硬红线 1. `tsc --noEmit` 0 错、`npm run lint` 0 error、`npm test` 失败数**不得超过基线 `1e553976` 实测的 31 条**,且失败清单逐条一致。 2. 测试总数不得低于 3491。 3. 改既有断言写「原值 / 新值 / 原因」三栏;不得用弱化断言换绿。 4. 不得回到「先混流再过滤」:按句放行是**门**(整句发或整句不发),不许对句子内部动刀。 5. `next build` 后 `/` 仍 `○ Static`,首屏 js gzip 相对 468,388 B 变化在 ±2% 内。 ## 6. 让步顺序 1. BUG-950 优先(用户每轮都感知)。 2. BUG-951 紧随(与 950 同在 `applyPass4Policy` / `finishPass4`,同一轮做,避免两次改同一函数)。 3. BUG-952 是 950/951 的一行分支,顺手做。 4. BUG-953 与前三条文件不重叠(只碰 `stream-mapping.ts` / `think-step-gate.ts` 与三个测试文件),可并行,也可放最后。 ## 7. 开工前置 ```bash git fetch origin --prune git worktree add -b codex/consult-pass4-streaming-20260918 \ .worktrees/consult-pass4-streaming-20260918 origin/staging cd .worktrees/consult-pass4-streaming-20260918/frontend npm test 2>&1 | grep -E "^# (tests|pass|fail)" # 开工基线:tests 3491 / pass 3445 / fail 31 ``` 收工:`docs/tasks/PROGRESS-consult-pass4-streaming-20260918.md` + `docs/BUG_HISTORY.md`(BUG-950~953)+ `CHANGELOG.md`(正文恢复逐字出现属用户可感知),与代码同一批推 `staging`。