Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
15 KiB
15 KiB
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 / 部署套件(清单存 scratchpadbaseline2-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(phasereceived),再await run():内层 NDJSON Response 逐字节透传,内层 JSON 拒绝转成一条run.failedrequest_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.tsStreamingReply.settling。- 滚动跟随不新写:
useConversationScrollAnchor观察内容增长,写出期间与流式期间同一路径。
T4 记录(本提交)
docs/BUG_HISTORY.md:BUG-1074、BUG-1075(fixed_pending_acceptance)。CHANGELOG.md一条;docs/testing/consult-first-frame-and-pacing-20260928.md7 条;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 ① 分类挂起首帧已出(onFirstFrame40 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 条源码断言) |
偏离与说明
- 寒暄流没改成「写 controller 的函数」(任务书 T2 第 1 条):外层流把内层
streamSmalltalkResponse的 Response 逐字节中继,效果相同(先免费结算再发 answer.delta + run.completed),且不动 BUG-976 那组结算代码;本机无 Docker 跑不了它的真实 PG 测试,少改一处更稳。假模型层的断言补在新测试 ②。 - paced settle 改成 opt-in:首版把
settle()默认改成按节奏,全量跑出 7 条新失败——校正面rectification-chat-turn-run.ts两处settle()之后同步合并快照(rectification-dup-question、rectification-opening-plain、rectification-activity-receipt5 条),以及 Node 无window.requestAnimationFrame的未捕获异常。改成settle({ paced: true })只在普通对话完成路径用,校正面契约不动;校正面要同样节奏另立单(BUG-1075 防复发写明)。另 2 条是chat-navigation-a11y-contract数window.location.assign("/login")/openAccountDialog("billing", …)的调用点,首版在事件分支复制了两句,改成rejectRun共用后计数回到 3 / 2。 - legacy 运行时不先建流:
CONSULTATION_AGENTIC_RUNTIME=legacy/ canary 非成员仍是原顺序(text/plain 回复无法走 NDJSON)。staging / 生产 compose 都是enabled。 route.ts的runTurn正文没有重新缩进:包一层箭头函数但保留原缩进,避免上千行的纯缩进 diff 打破十几个按源码文本断言的合同测试;lint 无缩进规则报错。- 首帧的
activity(received)在时间线 reducer 里不加行(返回原状态),客户端从发送那一帧起已经显示同一句排队行;这样那一帧到达时字不变、行不跳。 next build默认 Turbopack 对软链node_modules报错(同前几单),用--webpack量/与 gzip。- 真机、真实耗时、真实 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 埋点。