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

15 KiB
Raw Blame History

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 埋点。