Trajectory、Query 和 Replay
你读session log就是读JSONL文件。错。SessionQueryEngine做的不仅是读取——它用Session.create()构造detached session做重放验证,traceEvent追踪替换链和派生事件。
查历史会话时,最容易想到的是直接 cat 那个 JSONL 文件,或者 JSON.parse() 反序列化成对象。但这只能看到字节和字段,看不到这条日志是否还能被系统接受。
在 Harness 里,readSession() 做的事情比“读取”多得多。它不只是把 JSONL 解析成事件数组,而是用 Session.create() 构造一个 detached session,从 seed 开始重放所有事件,顺手验证日志完整性。如果日志损坏、seq 断裂、事件顺序不对,重放会直接失败,而不是返回一个“看起来没问题”的残缺对象。
历史不是用来”看”的,是用来”重放验证”的。
SessionQueryEngine抽象层
SessionQueryEngine定义了六个核心操作,不是简单的CRUD:
- searchSessions():按条件搜索会话元数据
- searchEvents():搜索事件内容(支持全文)
- listSessions():列出会话列表(分页)
- readSession():读取并重放验证一个会话
- traceEvent():追溯事件血缘(替换链+派生事件)
- traceLineage():追踪完整会话血缘(fork/parent关系)
这六个操作背后可以有不同后端。SQLite后端提供FTS5全文搜索;内存后端用于测试;tool-session-query把查询能力暴露给agent作为工具使用(带workspace访问控制,agent不能随便查其他workspace的会话)。
session-log-export支持UI导出JSONL——但导出前同样要经过重放验证,不会导出损坏的日志。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "searchSessions\|readSession\|traceEvent\|Session.create" "$repo/packages/session-query/session-query/src/index.ts" | head -30
你会看到readSession的返回不是原始事件数组,而是重放后的Session实例。
readSession():不只是读取,是重放验证
这是最反直觉的设计。当你调用readSession(id),系统做了这些事:
- 从持久化介质读取该会话的所有事件(JSONL)
- 解析header和seed events
- 调用Session.create(id, seed, header)构造一个detached session(不连接运行时服务)
- 按seq顺序逐个append事件到这个detached session
- 如果append过程中任何invariant检查失败(seq不连续、turn嵌套错误、tool result没有对应call等),整个readSession失败
- 重放成功后,返回这个Session实例——它的surface投影、context、goal等状态都和当时运行时一致
为什么这么麻烦?因为直接读JSONL你不知道日志是否损坏。一个事件缺了seq,或者surfaceOp: replace的事件顺序错了,你直接读可能看到一个”看起来合理”的对话,但实际上和真实运行状态不一致。重放验证是唯一能证明”这份日志确实代表了一个合法运行状态”的方法。
assistant/chunk事件是逐条持久化的——不只是final message,连中间的token chunks都存了。这支持token级别的重放保真度。
traceEvent():追踪事件血缘
Compaction会替换旧消息,fork会创建子会话,工具调用会cite sourceEventSeqs。一个事件不是孤立存在的——它有来龙去脉。traceEvent()给你两个维度的血缘:
replacement chain(替换链): 这个事件被哪些compaction replace了?compaction本身又可能被后续compaction replace。traceEvent()沿着surfaceOp: replace链一直追到当前可见的版本。
derived events(派生事件): 哪些后续事件cite了这个事件的sourceEventSeqs?比如一个tool result引用了tool call,一个assistant message引用了它的context来源。
analyzeEventLog()执行规范surface折叠,建立三个索引:
- replacedBy:每个事件被哪个事件替换了
- replacedEventSeqs:每个替换事件替换了哪些事件
- currentSeqs:当前可见(未被替换)的事件seq集合
flowchart TD
A[原始事件 seq:5] -->|surfaceOp: replace| B[Compaction事件 seq:42]
B -->|surfaceOp: replace| C[后续Compaction seq:88]
C --> D[currentSeqs: 88]
E[Tool Call seq:10] -->|sourceEventSeqs| F[Tool Result seq:15]
E -->|sourceEventSeqs| G[Assistant Summary seq:20]
H[Session JSONL] --> I[readSession]
I --> J[Session.create 构造detached]
J --> K[逐个append重放]
K -->|invariant失败| L[拒绝: 日志损坏]
K -->|成功| M[返回重放后的Session]
style L fill:#8b0000,stroke:#fff,color:#fff
style D fill:#2e7d32,stroke:#fff,color:#fff
Replay能力:三层重放机制
Harness有三层重放能力,不是只有一种:
第一层:Session重放。 就是上面说的readSession(),用Session.create()从seed+events重放,验证日志完整性,重建当时的surface状态。
第二层:LLM Replay适配器。 packages/test-support/llm-replay提供一个LLM服务适配器,它不调用真实API,而是从记录好的session log里读LLM响应,按原时序重放。这用于快照测试——你记录一次真实运行,以后重放时LLM输出完全一致,不会因为模型版本变化导致测试flaky。
第三层:ACP Snapshot Harness重放。 这是最高保真度的——启动真实agent bin子进程,通过ACP JSON-RPC over stdio驱动,重放输入步骤,对比stdout和session log snapshot。下一章详细讲。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
ls "$repo/packages/test-support/llm-replay/src/"
你会看到llm-replay的核心就是匹配记录的请求顺序,依次返回预存响应。
容易踩的坑
坑一:把readSession当JSON.parse。 直接解析JSONL不验证重放,你可能得到一个seq断裂、turn不闭合的”残废会话”,但代码不会报错。readSession的重放验证是安全网,不要绕过。
坑二:不理解compaction后的事件引用。 Compaction后旧事件还在日志里(只是被标记为replaced),currentSeqs才是模型当前看到的版本。traceEvent()帮你找对版本。
坑三:以为FTS索引覆盖所有内容。 SQLite FTS索引的是事件的text内容,但tool arguments、结构化metadata不一定在索引里。精确查询要用searchEvents的filter条件,不能只靠关键词。
坑四:tool-session-query没有workspace隔离。 不,它有——agent只能查询当前workspace的会话,不能跨workspace访问。这是强制的访问控制,不是可选配置。
坑五:重放会连接真实服务。 不,Session.create()构造的是detached session——不连接LLM、不连接工具、不连接任何运行时服务。它只是纯状态重放。
你能重放历史、追溯血缘、搜索过往,但怎么保证你记录的snapshot是可信的?怎么系统地测试各种故障场景?下一章讲Mock LLM和Snapshot测试。