Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
118 lines
15 KiB
Markdown
118 lines
15 KiB
Markdown
# 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` 埋点。
|