| 提交 | 发生了什么 |
|---|---|
| bdc1ca4(首版按画板实现) | ChatPane 有 kind: "tool" 分支,ToolChip 组件按画板画好,demo-data.ts 里就是那两条 read_file / bash |
| 88dc2ae(R3,对话区切真实数据源) | 历史回放只做 role → kind、content → text 直映;流式只推助手文本。当时 noTools:'all',任务卡明写「不声明任何 defineTool 工具」,没有工具可显示,没人注意到卡片不见了 |
| e6b3e3d(R4) | demo-data.ts 里那两条 tool 项被删 |
| R7 / R-WEBSEARCH / R-TITLE / R-IMAGEGEN | agent 陆续有了真实工具(notes_* / web_search / session_rename / generate_image),没有哪一轮回头补会话区 |
现状:apps/web/components/workbench/Workbench.tsx:120 的 ToolChip 与 :181 的渲染分支还在、样式与画板一致, 但全站没有任何地方生成 kind: "tool" 的项 —— 死代码。messages 表从 001 迁移起就为此留了位: role 允许 'tool',payload JSONB 的列注释写的是「如 R6 工具消息的 {name, preview, dur, error}」 (与 ToolChip 的四个 props 一一对应),但没有任何代码往里写。ROUNDS.md / 各轮任务卡 / BACKLOG 全文没有一条 「去掉会话区工具卡」的裁定,属无意遗漏。
所有者裁定(2026-09-03):按「第三层」做 —— 不只实时显示,重新打开会话时也要还原到与画板一致的精确位置。 这意味着落库形态要改(见「方案」),只对改动之后新写入的轮次生效;已有会话的合并正文拆不开, 按「没有 payload 就只显示正文」退化。访客会话按最后活跃满 3 天清理(007 迁移),所以「已有会话」是一个滚动的 3 天窗口,上线 3 天后线上不再有退化态的会话,不做回填。
画板缺的两个态(所以要先扩设计稿):1a 只画了「一轮进行中」;「跑完后折叠成一行」与「卡片箭头点开是什么」 没画过。参考实现是 pi-web:最终回答出来后,处理过程收成「处理详情 · N 条消息 · M 次工具调用」一行,点开原位展开。 这两个态是画板上没有的新视觉,按 R-TOOLS / R-PERF 的同一顺序 —— 先画 2l / 2m,再进轮次,不是规则 8 的例外; 恢复卡片本身则是画板早就画着的,与规则 8 无关。
保持今天的写法:一轮一条助手消息,seq = userSeq + 1,content = 这一轮全部助手正文按顺序拼接(语义不变, persistAssistant 的幂等 upsert 不动)。新增的只有 payload:
为什么是偏移表而不是「消息按段拆成多行」或「写 role='tool' 行」:
候选 B(段数组 segments: [{kind:"text",text}|{kind:"tool",…}])也成立,代价是正文在 content 与 payload 里存两份。 不推荐,但如果所有者更看重「payload 自洽、不依赖 content」就选它,前端渲染路径一样。
rec.session.subscribe 今天只消费 message_update.text_delta(推 delta)与 message_end(判错 / 计费)。加两种:
把这段累积逻辑抽成一个纯函数模块(例如 agent/turn-recorder.ts:喂 pi 事件、吐 {text, toolCalls, modelRoundTrips}), ask.ts 只负责把它的输出发 SSE 与落库 —— 这样它能被 encore test 直接测(验收 #6 / #7),不必起真实 provider。
脱敏口径不新造:inputPreview / resultPreview 一律经 shared/redact.ts 的 previewText,与轨迹流的 argsPreview / resultPreview 是同一个函数、同一个截断上限、同一套凭据键 / 值清洗(docs/security.md §2)。 对话流的帧里只有摘要字符串,永不带 args / result 的原始结构。
不用轨迹流派生会话区的理由:/agent/ask 与 /trace/stream 是两条独立的 SSE 连接,文本 delta 与工具事件跨连接 没有顺序保证;而回放路径反正要 payload。一份数据、一个来源、两个消费者(实时帧 / 落库)最省。
默认照 pi-web:最终回答之前的一切(中间的话 + 全部卡片)进折叠行,只有最终回答留在外面。 备选是「只折叠卡片、中间的话留在外面」,那样会话区读起来更连续但折叠行的意义变弱。提示词按默认写的, 画板画成什么样就实现成什么样。
| 路径 | 内容 |
|---|---|
| docs/security.md | §2 事件流脱敏补一段 R-TOOLCARDS:对话流新增 tool_start / tool_end 帧,入参 / 出参只以 previewText 摘要出现(与轨迹流同一函数);messages.payload 存的也是同一份摘要,不存原始结构(先于代码) |
| apps/api/agent/turn-recorder.ts(新) | 纯函数:喂 pi 事件 → { text, toolCalls[], modelRoundTrips };偏移、耗时、脱敏摘要都在这里算 |
| apps/api/agent/turn-recorder.test.ts(新) | 验收 #6 / #7 / #8 的用例本体 |
| apps/api/agent/ask.ts | 订阅回调接 recorder;发 tool_start / tool_end 帧;done 帧加 summary;persistAssistant 传 payload |
| apps/api/agent/store.ts | MessageRow 加 payload;listMessages SELECT payload;upsertMessage 已收 payload,不改 |
| apps/api/agent/sessions.ts | ChatMessage 加 toolCalls?: ToolCallRecord[](从 payload 派生,字段白名单序列化,不透传整个 payload) |
| apps/api/agent/README.md | /agent/ask 帧清单更新(session / delta / tool_start / tool_end / error / done) |
| apps/web/lib/api-client.ts | dev.ps1 gen 重新生成(生成物;按 BACKLOG R3 先例还原 app slug 噪音行) |
| apps/web/lib/types.ts · apps/web/lib/agent-api.ts | 见「前端」 |
| apps/web/components/workbench/Workbench.tsx | ChatPane 三态 · ToolChip 展开体 · 折叠行组件(逐像素对照 2l / 2m)· openSession / send 接线 |
| design/README.md · CLAUDE.md · ROUNDS.md | 画板增删记录 2l–2m · 规则 8 计数 18 → 20 · 第十次修订段 + 进度表行 + 拆解段(草稿在本文末尾) |
不交付:迁移(payload 列早已存在)· 新端点 · 新 MCP 工具 · 轨迹流 / 三视图的任何改动 · Timeline 与卡片的互相定位(记 BACKLOG)。
| # | 检查 | 命令 / 期望 | 结果 |
|---|---|---|---|
| 1 | 无回归 | dev.ps1 check + dev.ps1 test 全绿;dev.ps1 gen 后 api-client.ts 的 diff 只有 ChatMessage.toolCalls 一处 | ✅ check 0 错;test api 26 文件 514 用例 + web 15 用例全绿;tsc --noEmit 0 错。gen 的 diff = ChatMessage.turn 一个字段 + ToolCallRecord / TurnRecord 两个新接口(+56 行),无 slug 噪音。字段名从 toolCalls 改成包着三项的 turn,理由见「本轮实测」偏离 ① |
| 2 | 实时三态 | 本机起 dev.ps1 + next dev,新会话问一句会触发 notes_search 的话:流式期间卡片夹在正文中间(对照 1a);done 后收成折叠行 + 最终回答(对照 2l);点折叠行展开、点卡片箭头展开(对照 2m)。截图三张进任务卡 | ✅ 本机假 provider 剧本 A(见「本轮实测」):流式期间「我先在教程库里搜一下。」→ 绿卡 notes_search {"query":"agent loop"} 26ms ˅ → 「再列一下系列。」→ 绿卡 notes_list_series {} 19ms ˅ 内联(1a);done 后收成「› 处理详情 · 3 次模型往返 · 2 次工具调用 · 4.7s」+ 最终回答(2l);点折叠行 → 箭头转 ˅、竖线区展开;点 notes_list_series 卡 → 箭头转 ˄、下接 INPUT / RESULT 展开体(2m)。三张截图在会话内逐一核对;DOM 逐节点记录在「本轮实测」 |
| 3 | 回放与实时同源 | #2 结束后记录会话区 innerHTML;F5 重进同一会话再记一次;逐字节相同(折叠状态以初始态比) | ✅ 收尾后会话区 innerHTML 914 字节、sha256 3cf1f230…1319de;F5 后从左栏重开同一会话再取:同长度、同哈希 |
| 4 | 无工具的一轮零变化 | 问一句不触发工具的话(例如「你好」),会话区 innerHTML 与本轮改动前的构建逐字节相同;库里该助手行 payload IS NULL | ✅ 「你好」一轮:会话区子节点 = 用户气泡 div + div.md-chat,两处 markup 在 diff 里零改动、走的是与改动前同一段 JSX(<AssistantMessage key={i} text>);没有做「同时跑旧构建」的逐字节比对(两个 dev server 共用这棵工作树,stash 会把另一会话的服务也切回旧代码),以 diff 零改动 + DOM 结构为证。库里该行 payload IS NULL |
| 5 | 旧行退化正确 | 手工把一条助手行的 payload 置 NULL(或用本轮之前的库),打开会话:只有正文、无卡片、无折叠行、无报错 | ✅ 把剧本 B 的助手行 payload 置 NULL 后重开:只有一段正文(两段话拼在一起)、无卡片、无折叠行、控制台无错 |
| 6 | 脱敏与体积 | turn-recorder.test.ts:入参 / 结果里塞 apiKey / Authorization: Bearer … → 摘要里是 [redacted];超长入参 / 结果被截到 previewText 的上限;SSE 帧与 payload 里没有 args / result 原始键 | ✅ turn-recorder.test.ts 12 例:apiKey 键与 Bearer … / sk-… 串都成 [redacted];超长截到 400 接 …(已截断);帧与 payload 的键集合逐个断言。浏览器剧本 B 的卡片实显 {"query":12345,"apiKey":"[redacted]"},SSE 抓包无原始 key |
| 7 | 偏移正确 | 用例:文本 → 工具 → 文本 → 工具 → 工具 → 文本 / 一句话没说先调工具 / 以工具收尾 / 工具无 end 事件,断言 at、段切分、isError、durationMs、modelRoundTrips | ✅ 服务端用例五种序列 + 前端 turn-view.test.ts 五种切段;剧本 A 落库 at = 11 / 18,与正文「我先在教程库里搜一下。」(11 字)「再列一下系列。」(7 字)切段一致;剧本 C(先调、以工具收尾)只剩折叠行「3 次模型往返 · 2 次工具调用」、下面不留空段 |
| 8 | 帧里没有配置面 | 抓一轮完整 SSE:tool_start / tool_end / done 帧里不含 model / provider / baseUrl / token 数 / 费用 | ✅ 抓两轮完整 SSE(剧本 A / B):tool_start 只有 toolCallId / name / at / inputPreview,tool_end 只有 toolCallId / resultPreview / isError / durationMs,done 只有 sessionId / modelRoundTrips / turnMs;全文无 "model" / "provider" / "baseUrl" / "tokens" / "cost" / "usage" / "args" / "result" |
| 9 | 幂等不破 | 同一 seq 重复 upsert(模拟重试)后 payload 与 content 都是最后一次的值,jsonb_typeof(payload) = 'object'(规则 4) | ✅ store.test.ts 新增用例:两次 upsert 后 content / payload 都是第二次的值,jsonb_typeof = object、jsonb_array_length(payload->'toolCalls') = 1;不传 payload 的第三次写 SQL NULL(不是 jsonb 'null') |
| 10 | 样式零改动 | git diff 里既有元素没有样式属性改动;新增的折叠行 / 展开体的每个值都能在 2l / 2m 上找到出处;1a 态的卡片 DOM 与改动前一致 | ✅ diff 里既有元素唯一多出的样式属性是 ToolChip 根节点的 cursor:pointer(整卡可点的 affordance,非像素;理由见偏离 ④);折叠行 13px/1.7 --text-muted hover --accent、箭头 12px --text-dim、红点 6px --err-text、竖线 1px --border + marginLeft 5 + paddingLeft 14、展开体 marginTop 4 / r6 / --bg-subtle / 同卡描边 / 小标题 mono 10/600 0.08em / 正文 mono 11/1.6 max-height 106 —— 逐值对应 2l / 2m 的裁定面板 |
| 11 | 发版后复核 | 生产上重跑 #2 / #3 / #4 各一次,docs/releases.md 记一行 | ⏳ 待发版 |
同一验收项针对性整改后连续 2 次验证仍不过 → 写 rounds/round-toolcards/BLOCKED.md,停下呼人。禁止放宽验收标准自我通过。
止损很便宜:无迁移,回退 = revert 提交。回退后已写入的 payload 只是被忽略的 JSONB,不影响任何读路径 (listMessages 回退后不再 SELECT 它)。
前置里「本机 llm_config 配好一个能跑的 provider」没满足(本机开发库 llm_config 为空)。没有等,改用与 apps/api/agent/skills-e2e.test.ts 同款的做法:一个本地 OpenAI chat/completions SSE 假服务(127.0.0.1:4711,每次往返延迟 1.5s 好让「进行中」态停留够久),llm_config 种一行 faux provider(假 key 经本机 ConfigEncryptionKey 加密入库;脚本只输出密文, key 原文不进上下文)。pi loop、notes_* 工具、轨迹采集、落库、SSE 全是真的,只有模型是剧本:
| 剧本 | 触发词 | 序列 | 结果 |
|---|---|---|---|
| A | 「查」 | 文本 → notes_search → 文本 → notes_list_series → 文本 | 3 次往返 · 2 次工具 · 4.7s;at = 11 / 18 |
| B | 「错」 | 文本 → notes_search({query:12345, apiKey:"sk-…"})(schema 校验失败 → isError)→ 文本 | 红卡 + 折叠行红点;卡上 apiKey 显示 [redacted];RESULT 红字 |
| C | 「先调」 | 工具 → 工具 → 空正文收尾 | 只剩折叠行「3 次模型往返 · 2 次工具调用 · 4.6s」,下面无空段(2l 规则 3);content 为空串但行照落 |
| D | 「长」 | notes_search({query: 600 字}) → 文本 | 卡上与展开体 INPUT 都是 400 字 + …(已截断)(406 字符);展开体 clientHeight 106 / scrollHeight 211,超出部分被 overflow:hidden 裁掉 |
| E | 其它 | 纯文本 | 会话区 = 气泡 + div.md-chat;库里 payload IS NULL |
假 provider 行验收完已从本机 llm_config 删除,脚本在会话 scratchpad、不入库。真实 provider 的复核随发版做(验收 #11)。
① ChatMessage.turn 而不是 ChatMessage.toolCalls:折叠行在回放时也要「N 次模型往返 · 总耗时」,只透 toolCalls 的话这两个数没处放。 所以端点透出一个可选的 turn = { modelRoundTrips, turnMs, toolCalls },仍是「一处」字段、仍按白名单投影(turnFromPayload)。 ② 收尾帧的两个数是平铺的(done {sessionId, modelRoundTrips, turnMs}),且 error 帧也带:它们是「收尾帧」的语义,不是「成功帧」的; provider 中途失败时前端同样要把在途的助手项标成 done 并折叠,否则那一轮永远停在内联态。旧客户端两个键都忽略,加法改动。 ③ 卡片与折叠行的耗时沿用 Timeline 的 formatDuration(26ms / 4.7s),而画板示例写的是 0.3s。不为一处再造一个格式器; 小于 1s 显示毫秒也更贴合右栏。 ④ ToolChip 根节点多了 onClick + cursor:pointer:画板只说「箭头可点」,但箭头只有 12px,整卡才是可用的点击面;cursor 不是像素, 是与折叠行同款的 affordance(2l 的折叠行画板自带 cursor:pointer)。这是 diff 里既有元素唯一多出的样式属性。 ⑤ resultPreview 先把 AgentToolResult 的文本块拼起来再过 previewText(与 events.ts 的 summarizeMessage 处理消息正文同一做法), 不然卡片上是 {"content":[{"type":"text","text":"… 的壳;没有文本块的结果(纯图片)整个值原样过 previewText,不猜结构。 脱敏与截断仍是同一个函数、同一上限;唯一的差别是尾标记按画板 2m 换成 …(已截断)(turn-recorder.ts 的 preview)。 ⑥ 有工具调用但正文为空的一轮也落行(content = '' + payload):以前「assistantText 为空就不写行」,现在改成「有正文或有 payload 才写」, 否则剧本 C 那种「以工具收尾」的一轮重新打开就什么都没有,违反 2l 规则 3。两者都没有(provider 直接失败)仍与今天一样不写。 ⑦ 展开体的 RESULT 不做 white-space: pre-wrap:画板 2m 只给了 word-break: break-all,所以多行结果(如 schema 校验错误)会折成一段。 照画板实现;要不要 pre-wrap 记 BACKLOG 等所有者在画布上定。 ⑧ 验收 #4 没有做「与旧构建并排」的逐字节比对:两个 dev server(本会话 + 另一会话)共用这棵工作树,git stash 会把对方的服务也切回旧代码。 以 diff(用户气泡 / AssistantMessage / ChatPane 容器三处 markup 零改动)+ 实测 DOM(气泡 + md-chat)为证。
2026-09-03 第十次修订(R-TOOLCARDS):所有者发现会话区里没有工具调用卡,而画板 1a–1d / 1f–1g 的会话区 一直画着两张(read_file / bash)。核对结论:首版 bdc1ca4 实现过,R3 88dc2ae 把对话区切到真实数据源时 只映射了 role / content,卡片断了来源;当时 noTools:'all' 没有工具可显示,没人发现;R4 e6b3e3d 删掉 demo 数据后 ToolChip 留成死代码。恢复卡片本身不是新功能,与规则 8 无关。新增的是两个态:一轮跑完后把处理过程 折叠成一行(参考 pi-web 的「处理详情 · N 次工具调用」),以及卡片箭头点开的入参 / 结果摘要 —— 画板没画过, 按 R-TOOLS / R-PERF 的同一顺序先画 2l / 2m 再进轮次,不是规则 8 的例外。所有者裁定做到「重新打开会话 也要在同一位置」,所以落库形态要改:一轮仍是一条助手消息,payload 里加工具调用的偏移表(messages.payload 从 001 迁移起就为此留着),无迁移;旧行不回填,3 天保留期自然清空。提示词见 rounds/round-toolcards/design-prompt.md。
| R-TOOLCARDS | 会话区工具调用卡:实时内联(1a)+ 跑完后折叠(2l)/ 展开(2m)+ payload 偏移表落库回放;无迁移、前端一条渲染路径 | ⏳ 文档就绪,待画板与所有者裁定(任务卡) | — |
要点同本文「方案」与「验收」,搬过去时压成 ROUNDS.md 的篇幅:问题(丢了的功能 + 缺的两个态)· 形态裁定(偏移表、不用轨迹流派生、 折叠范围照 pi-web)· 五条交付(recorder 纯函数 / ask.ts 两种帧 / store & sessions 带 payload / 前端三态 / security §2 补记)· 验收五条(实时三态 · 回放同源 · 无工具零变化 · 脱敏 · 无配置面)· 前置(画板 2l/2m、R-PERF 合并)。