Files
Jyotisha/docs/tasks/PROGRESS-consult-first-frame-and-pacing-20260928.md
T
Jesse_ChenandClaude Fable 5.1 79c5e566ec
Independent Staging Quality Gate / validate (push) Successful in 13m25s
Independent Staging Quality Gate / publish (push) Successful in 3m18s
docs(tasks): first-frame and pacing acceptance
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
2026-09-28 09:41:15 +08:00

118 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PROGRESS · 普通对话发出即有反馈,正文按打字节奏(2026-09-28)
任务书:`TASK-consult-first-frame-and-pacing-20260928.md`(产品已确认 D1、D2、D3;D4 160 字阈值不动;D5 工具 / 扣点 / 分类模型不动)。执行方:Claude fork 子代理(直接执行模式)。
## 基线
- `origin/staging` = `584ad05c`(2026-09-28);分支 `codex/consult-first-frame-and-pacing-20260928`,工作树 `.worktrees/consult-first-frame-and-pacing-20260928`。
- 开工实测(Node 22.14,`npm test`):4197 条 / pass 4143 / fail 24 / skipped 30 / cancelled 0;24 条失败均为无 Docker 的 DB / 部署套件(清单存 scratchpad `baseline2-fails.txt`)。
- 首屏 rootMainFiles gzip(9) 基线:130,933 B(`PROGRESS-consult-answer-the-question-20260927` 口径,4 个文件)。
- 分类耗时的真实分位数:无法从本机取得(staging 观测日志需登录后台)。代码上限是 `SMALLTALK_TIMEOUT_MS = 3_000`(fail-open);在 R2 的开流前序列里它前面还有会话读取(1 次查询)、`prepareConsultationRoute`(资料 / 档案 / 区间,2~3 次查询)、`reserve_consultation_usage`(1 次 RPC + 1 次计价查询)、`append_consultation_question`(1 次 RPC)。部署后用 `classification.<outcome>` 与本单新增的 `first_byte` 阶段 `durationMs` 复核。
## 做了什么
### T1 客户端首帧(BUG-1074,提交 `86d99115`)
- `chat-message-row.tsx`:删 `awaitingClassification`,`quiet` 只由 `responseKind === "smalltalk"` 决定。
- `consultation-run-timeline.tsx`:`QUEUED_TIMELINE_ROW.label` 改 `CONSULTATION_RECEIVED_LABEL`(「收到,正在看你的问题…」,新增在 `consultation-activity-labels.ts`,服务端首帧共用)。
- `DESIGN.md` §9、`docs/VOICE.md` 新段。
### T2 服务端先建流(BUG-1074,提交 `0bc66590`,补修 `891e9c62`)
- 新 `frontend/src/lib/stream-first-response.ts`:`streamFirstResponse({ requestId, run, onFirstFrame, onUnhandled })` 先返回 NDJSON Response,`start()` 里同步写首帧 `activity`(phase `received`),再 `await run()`:内层 NDJSON Response 逐字节透传,内层 JSON 拒绝转成一条 `run.failed` `request_rejected`(`consultationRejectionEvent(status, payload)`:句子按 recovery → message → error 取,与客户端 `payloadMessage` 同一优先级;原 `code` 作 `reason`),`run` 抛错则写通用 `run.failed` 并回调日志;外层 `cancel()` 把断连传给内层 reader(内层 `continueAfterDisconnect` 照旧结算)。
- `route.ts`:`const runTurn = async (): Promise<Response> => { … }` 包住从 `prepareConsultationRoute` 到最后 `catch` 的整段(**正文缩进未动**,保持 diff 最小、源码断言不变);`shouldUseAgenticRuntime(user)` 为假走原顺序(legacy 的 text/plain 回复不能走 NDJSON),为真 `return streamFirstResponse({...})`;`onFirstFrame` 记 `consultation-stream-first-v1` / `first_byte`(毫秒,不含原文)。寒暄分支、`streamSmalltalkResponse`、免费结算 RPC 与参数一行未改(任务书写「改成可写 controller 的函数」,实际用逐字节中继达到同一效果、少改一处结算代码;见偏离)。
- `consultation-agent-events.ts`:`publicActivityPhaseSchema` 加 `received`;`run.failed` 的 `code` 加 `request_rejected`,可选 `status`(400–599)与 `reason`(≤ 80),union 级 `superRefine`:`status` / `reason` 只允许 `request_rejected`,且 `request_rejected` 必须带 `status`。
- `use-consultation-run.ts`:删 `x-jyotish-response-kind` 响应头读取(只认 `run.completed.responseKind`);`request_rejected` 事件与 HTTP 非 2xx 走同一个 `rejectRun(status, message, code)`(401 跳登录、402 开充值、抛 `ConsultationResponseError`),后续按 `status` / `code` 的回滚、`session_full`、`session_not_consultation`、`request_conflict` 处理不变;首帧 `activity(received)` 不带已完成轨迹。`chat-message-row.tsx` 的 phase → 状态映射加 `received: "working"`。
#### 开流前 / 流内对照表(agentic 运行时)
| 失败 | 以前 | 现在 | 客户端 |
| --- | --- | --- | --- |
| Supabase 未配置 | JSON 503 | **仍开流前** JSON 503 | 不变 |
| 未登录 | JSON 401 | **仍开流前** 401 | 跳 `/login` |
| 请求体不合法 | JSON 400 | **仍开流前** 400 | 不变 |
| 会话读取失败 / 不存在 / 是校正会话 / 模型已变化 | JSON 503 / 404 / 409(`session_not_consultation`) / 409 | **仍开流前** | 不变(`session_not_consultation` 仍切到校正会话) |
| 会话模型不可用;旧版校正入口;提示词提取 | JSON 503 / 409 / 400 | **仍开流前** | 不变 |
| 计费配置不可用(`pricing_configuration_unavailable`) | JSON 503 | 流内 `request_rejected` status 503 reason 同 | 同句、同状态 |
| 咨询计划不可用(上限 / 模式不一致) | JSON 409 | 流内 status 409 | 409 → 回滚问题到输入框 |
| 主体 / 资料失败(`consultationSubjectFailureResponse`、`ConsultationProfileTruthError`) | JSON 4xx/503 | 流内,status 原值 | 不变 |
| 预留失败 / 模型不可用 | JSON 503 / 409 | 流内 | 不变 |
| 点数不足 | JSON 402 | 流内 status 402 | 开充值 + 回滚 |
| 保存问题失败 / 对话已写满 | JSON 503 / 409(`session_full`) | 流内,`reason: "session_full"` | 「开新对话」提示不变 |
| 生成失败(最后的 catch) | JSON 503(recovery 句) | 流内,句子取 recovery | 不变 |
| 寒暄 | NDJSON + 响应头 | 流内 NDJSON 透传,无响应头 | 只认 `run.completed.responseKind` |
### T3 客户端节奏(BUG-1075,提交 `3f8b8172`,补修 `891e9c62`)
- `stream-frame-buffer.ts`:`STREAM_RELEASE_MAX_CHARS = 4`(普通积压每帧上限)、`STREAM_RELEASE_CATCHUP_CHARS = 600`(超过才按 1/12 追赶)、`STREAM_SETTLE_MAX_FRAMES = 90`;`settle({ paced: true })` 按 `max(4, 剩余/90)` 每帧写完剩余、最后一帧 `settled: true`、返回 Promise;无参 `settle()` 保持一帧全放;页面隐藏一帧全放;写出期间 `dispose()` 延到写完;`reset()` 结束写出。
- `use-consultation-run.ts`:流正常结束 → `settleFrames(false)`:先给 streaming reply 打 `settling: requestId`,`frames.settle({ paced: true })`;停止 / 截断 / 失败 → 无参 settle 立即全放。数据层(入库、`persistSession`、结算、`completeConsultationInterface`)不等动画;`completeConsultationInterface` 不再清掉正在写出的回复(函数式更新);每个写出帧只在 `current.settling === requestId` 时更新,最后一帧清空;新一轮发送覆盖 streaming reply 后旧帧自然落空。
- `chat-message-view.ts`:`settlingChatMessageView(last, loading, streamingText)`,`latestAssistantView` / `chatMessageViews` 在 `loading` 为假、释放文本是已存回复严格前缀时用同一 `renderKey` 渲染前缀(`settling: true`);`chat-message-row.tsx` 写出期间时间线不 live。`home-types.ts` `StreamingReply.settling`。
- 滚动跟随不新写:`useConversationScrollAnchor` 观察内容增长,写出期间与流式期间同一路径。
### T4 记录(本提交)
- `docs/BUG_HISTORY.md`:BUG-1074、BUG-1075(`fixed_pending_acceptance`)。
- `CHANGELOG.md` 一条;`docs/testing/consult-first-frame-and-pacing-20260928.md` 7 条;`BLOCKED.md` 新段;`docs/tasks/README.md` 行状态「已实现待验收」。
## 测试
### 新增
- `frontend/tests/consult-first-frame-and-pacing-20260928.test.tsx`(13 条):T3 前缀覆盖 view / 行渲染、非前缀 / loading / 全文不覆盖、hook 源码(完成走节奏、停止 / 截断 / 失败立即、`completeConsultationInterface` 不清 settling、只一处 `settle({ paced: true })`);T2 ① 分类挂起首帧已出(`onFirstFrame` 40 ms)② 真实 `streamSmalltalkResponse` 流内完成、免费结算 1 次、无响应头 ③ 402 / 409 / 503 三种 JSON → `request_rejected` 字段逐一对照 + schema 约束 ④ NDJSON 首帧后逐字节一致、断连传内层、`run` 抛错通用失败;route 源码开流前 / 流内两张清单、分流、`first_byte`;客户端源码事件判寒暄、`rejectRun` 两处调用、计数不变。
- `stream-frame-buffer.test.ts` +3:160 字 40 帧;600 以上仍 12 帧追赶;剩余 300 字 paced settle ≤ 90 帧、单调、最后一帧 settled、无参 settle 一帧;dispose 延后。
### 改既有断言(原值 / 新值 / 原因,注释也写在测试里)
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `chat-stream-settle-contract.test.ts` | live 时间线含「正在处理…」 | 含「收到,正在看你的问题…」、不含旧句 | D1 / BUG-1074 |
| `consultation-smalltalk-ui.test.tsx` | 分类回来前不渲染时间线、不出现「正在处理 / 正在分析」 | 渲染排队行(唯一一行、无 think / write 步);只有 smalltalk 行静默 | D1 / BUG-1074 |
| `consultation-smalltalk.test.ts` | `/const quiet = smalltalk \|\| awaitingClassification/` | `/const quiet = smalltalk;/` 且无 `awaitingClassification` | D1 / BUG-1074 |
| `consultation-agentic-runtime.test.ts` | `writerToSend` 的 phase 手写四值联合 | `PublicActivityPhase`(多 `received`) | D2 / BUG-1074 |
| `chat-navigation-a11y-contract.test.ts` | `/if \(response\.status === 402\) openAccountDialog(...)/` | `/if \(status === 402\) openAccountDialog(...)/`(`rejectRun` 里) | D2;调用点计数 3 / 2 不变 |
| `consultation-recovery.test.ts` | `/!response\.ok[\s\S]*throw new ConsultationResponseError\([\s\S]*response\.status/` | `rejectRun(status, message, code): never` 抛 `ConsultationResponseError(status, …)`,`!response.ok` 调 `rejectRun(response.status, …)` | D2 / BUG-1074:HTTP 体与流内事件共用一处 |
| `stream-frame-buffer.test.ts` | `settle()` 同步放完 | `settle({ paced: true })` 在 90 帧内写完、最后一帧 settled;无参 settle 仍一帧 | D3 / BUG-1075 |
`chat-stream-layout.test.ts` 的 `/正在处理…/` 仍成立(活动面板兜底文案未改,那是无时间线行的 fallback)。
## 门禁数字(完工实测,Node 22.14,本工作树,顺序执行)
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error(128 条既有 warning 未动) |
| `npm test` | 4212 条(基线 4197,+15)/ pass 4158 / fail 24 / skipped 30 / cancelled 0;退出码 1 = 那 24 条 |
| 失败清单比对 | 24 条失败按名称与基线逐条一致(`diff` 为空,均为无 Docker 的 DB / 部署套件)。全部测试名对比(基线取自 `9d01757f` 同代码的验收全量 TAP):新增 17 个名字;消失 2 个 = 本单按三栏改名的两条(`classification wait does not invent a thinking step before any activity` → `the classification wait shows the queued row from the send frame; only a smalltalk reply is quiet`;`many events collapse into one flush per frame and settle releases everything synchronously` → `… and a paced settle writes the rest out within the budget`),原名在测试注释的原值里 |
| `next build --webpack` | exit 0;`/`、`/chart`、`/ephemeris`、`/people` 均 ○ Static |
| 首屏 rootMainFiles gzip(9) | 130,950 B(4 个文件),基线 130,933 B,+17 B(+0.013%,±2% 内);量于 `891e9c62`,其后只改了测试与文档 |
| `page.tsx` | 未动 |
| `python3 -m pytest tests/test_repo_privacy_markers.py -q` | 通过(写了实测数字的文档推送前必跑) |
## 提交
| SHA | 内容 |
| --- | --- |
| `86d99115` | T1 客户端首帧 |
| `3f8b8172` | T3 放字节奏与 paced settle(首版默认 paced) |
| `0bc66590` | T2 先建流、事件、客户端 |
| `891e9c62` | T2/T3 补修:paced 改 opt-in、`rejectRun` 合并两条拒绝路径(全量跑出 7 条新失败后改) |
| (本提交) | T4 记录 + `consultation-recovery.test.ts` 三栏(第二次全量跑出的最后 1 条源码断言) |
## 偏离与说明
1. **寒暄流没改成「写 controller 的函数」**(任务书 T2 第 1 条):外层流把内层 `streamSmalltalkResponse` 的 Response 逐字节中继,效果相同(先免费结算再发 answer.delta + run.completed),且不动 BUG-976 那组结算代码;本机无 Docker 跑不了它的真实 PG 测试,少改一处更稳。假模型层的断言补在新测试 ②。
2. **paced settle 改成 opt-in**:首版把 `settle()` 默认改成按节奏,全量跑出 7 条新失败——校正面 `rectification-chat-turn-run.ts` 两处 `settle()` 之后同步合并快照(`rectification-dup-question`、`rectification-opening-plain`、`rectification-activity-receipt` 5 条),以及 Node 无 `window.requestAnimationFrame` 的未捕获异常。改成 `settle({ paced: true })` 只在普通对话完成路径用,校正面契约不动;校正面要同样节奏另立单(BUG-1075 防复发写明)。另 2 条是 `chat-navigation-a11y-contract` 数 `window.location.assign("/login")` / `openAccountDialog("billing", …)` 的调用点,首版在事件分支复制了两句,改成 `rejectRun` 共用后计数回到 3 / 2。
3. **legacy 运行时不先建流**:`CONSULTATION_AGENTIC_RUNTIME=legacy` / canary 非成员仍是原顺序(text/plain 回复无法走 NDJSON)。staging / 生产 compose 都是 `enabled`。
4. **`route.ts` 的 `runTurn` 正文没有重新缩进**:包一层箭头函数但保留原缩进,避免上千行的纯缩进 diff 打破十几个按源码文本断言的合同测试;lint 无缩进规则报错。
5. 首帧的 `activity(received)` 在时间线 reducer 里不加行(返回原状态),客户端从发送那一帧起已经显示同一句排队行;这样那一帧到达时字不变、行不跳。
6. `next build` 默认 Turbopack 对软链 `node_modules` 报错(同前几单),用 `--webpack` 量 `/` 与 gzip。
7. 真机、真实耗时、真实 PG 结算测试:见 `BLOCKED.md`。
## Claude 验收(2026-09-28,Node 22.14,本工作树独立复跑)
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error |
| `npm test` | 4212(基线 4197,+15);pass 4158 / fail 24 / skipped 30;24 条失败按名称与基线逐条一致 |
| `next build --webpack` | exit 0;`/` 仍 ○ Static;gzip 以执行方实测 +0.013% 为准 |
| 代码走查 | 先建流只包 agentic 运行时,`runTurn` 在该运行时下只返回 NDJSON(`runAgenticConsultation`)或 JSON 拒绝,无 text/plain 路径落入包装器;客户端原先读的 `x-jyotish-*` 头在 agentic 下本就不存在;`completeConsultationInterface` 在 paced settle 之后立即执行,输入框不等动画 |
| 真机 | 未做(无登录态、无模型凭据),见 `docs/testing/consult-first-frame-and-pacing-20260928.md` |
结论:T1–T4 通过,推 staging 后核对 `/api/health` 的 `deployment.gitCommit` 与 `first_byte` 埋点。