scripted-sessions:给 daemon 装一个「从 JSON 脚本回放事件」的假 provider
buffin(agent/scripted-sessions) · base 74909f74 (origin/main) · PR #153 · 2026-07-18 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自本分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。只做理解,不做 code review。
1TL;DR
这个 PR 给 daemon 新增了一个内置 provider builtin-scripted:它不真跑 LLM,而是从磁盘上的一份 JSON 脚本逐条回放归一化的 agent 事件,喂进真实的会话栈,专供开发 desktop 会话界面时使用。
关键设计是「栓点尽量低」:整条链路里只有最底层的 agent 进程是假的,它上面的一切都走真代码——事件补全/增量合并的 sink、落 SQLite 的 event-log、WebSocket 广播、客户端缓存、reducer、renderer 组件,连 approval / input(审批 / 输入请求)的往返也走真实 tRPC mutation。好处:流式打字、工具穿插、审批往返、崩溃中断这些事件形态都能被快速、可控、可复现地喂出来,而前端零改动、零 mock 分叉。
PR 里含两笔实质提交:81469841(初版实现,daemon + cli + desktop + docs 一把梭)和 d6d2da68(修一轮 Codex 自动 review 揪出的 6 个 P2 问题,全部经核实是真 bug)。本文把两者当一个整体走读,并在第 9 节把「初版错在哪 → 改成什么」摊开。
2变更地图(称重)
改动高度集中在 daemon(约 75%)。desktop / cli / docs 都是薄薄一层接入。
| 设计重心(要细读) | 可放心略过(机械 / 搬运 / 生成物) |
|---|---|
provider 核心三件套 runner.ts / validation.ts / schema.ts;auto-answer 的「真实认领」回路(runner-manager.ts + service.ts + types.ts);desktop 的 Echo 门控;CLI 的 session play 驱动器。 |
session-projection.ts(+339)是从 testing/toolkit/projection.ts(−336)逐字节搬家,除 import 路径外一字未改,旧文件降为 12 行 re-export 垫片;turn-queue.ts 是从测试 spy 里原样提取的游标类;0000_init.sql / snapshot 是 drizzle 生成物,跟随 packages/db/src/index.ts。 |
测试占比诚实:新增/改动里约 891 行是测试、2850 行非测试(≈24%)。但要注意——初版恰恰漏了 auto-answer 的端到端测试,正是这个空白掩盖了后面第 9 节 B1 的两个崩溃/静默丢弃 bug。
3架构一图流
结构上有两处位移:provider 种类从 3 种变 4 种;事件投影折叠器从「测试工具包」升到「生产 agent 层」。下图画的是那条「只有最左端是假的」的回放链——右边每一节都是真实会话用的同一套代码。
以前 · 3 种 provider,投影住在 testing/
现在 · 4 种,投影升为生产、供运行时校验
第二处结构变化不在图里但同样重要:scripted 的 auto-answer 会反向调回 AgentService 的认领 API(以前 runner 与 service 是单向的),用结构化类型 + 后绑定 setter 破掉「RunnerManager import service 类」的循环依赖——这是第 7 节旅程 B 的主题。
4数据与状态先行
先把后面旅程会反复出现的四个形状摆出来——只看形状不讲行为。
脚本的字面结构:一个 turn 里排着一串 step
脚本顶层是 title + turns[];每个 turn 有一句 user 文本和一串 steps。step 是一个七选一的联合,type 做判别式。这是「脚本能表达什么」的词汇表。
// 一个可执行的脚本动作
export type ScriptedStep =
| ScriptedEmitStep // emit:发一条归一化 provider 事件(payload 直接用事件 union 的 JSON 形状)
| ScriptedWaitStep // wait:延迟 N 毫秒,复现流式节奏
| ScriptedApprovalPauseStep // pause-for:停下来等真人回答 approval
| ScriptedInputPauseStep // pause-for:停下来等真人回答 input
| ScriptedApprovalAutoAnswerStep // auto-answer:不停播,自动应答 approval
| ScriptedInputAutoAnswerStep // auto-answer:自动应答 input
| ScriptedCloseStep // close:以 completed/crashed/killed 结束本 turn
export type ScriptedSession = {
title: string // 人读的会话标题
turns: ScriptedSessionTurn[] // 有序的对话回合
}
两个「请求解析器」类型:破循环依赖用的一对面
auto-answer 要回调 service 走真实认领。为了让 RunnerManager 不必 import AgentService 这个类(否则循环依赖),声明成两个结构化类型:一个是 service 满足的「带 sessionId」宽面,一个是交给单个 runner 的「已把 sessionId 咬死」窄面。
// AgentService 结构上就满足它——RunnerManager 只认这个形状,不认那个类
export type SessionRequestResolver = {
respondToApproval(sessionId: string, approvalId: string, response: ApprovalResponse): Promise<void>
respondToInputRequest(sessionId: string, inputRequestId: string, response: AgentInputResponse): Promise<void>
}
// 已经绑定好 sessionId 的窄视图:runner 只能答自己这个 session 的请求
export type SessionScopedRequestResolver = {
approval(approvalId: string, response: ApprovalResponse): Promise<void>
input(inputRequestId: string, response: AgentInputResponse): Promise<void>
}
对外的校验结果:两层各自成败
scripts validate 和 UI 列模型都吃这个形状:format(JSON + zod 结构)和 semantic(事件投影 + 请求门)两层,各带逐条 {path, message}。
export type ScriptValidationReport = {
valid: boolean // 仅当两层都过才 true
format: ScriptValidationLayer // JSON 与 zod schema 校验
semantic: ScriptValidationLayer // daemon 事件投影 + 请求门校验
}
投影:把事件流折叠成「客户端能看到的状态」
这是本 PR 里位移最大的一块(从 testing 搬到生产)。它把一个 session 的事件信封流折叠成 timeline / runs / sessionStatus——每个 run 里 assemble 出 assistant 文本、tool / approval / input 的生命周期、终态。脚本语义校验就靠「把脚本声明的事件序列折一遍、能折完不炸」来判对错。
export type SessionProjection = {
timeline: ProjectedEvent[] // 每个事件的游标 + 判别式,按投递序——丢/重/乱序直接现形
runs: ProjectedRun[] // 按 runId 聚合的每 run 状态,首见序
sessionStatus: SessionStatus | null // 流把 session 推到的终态,无 run.completed 时为 null
}
5底座:真实事件管线 + 两层校验
两条旅程都踩在这块底座上,先单独走一遍。
5.1假 runner 喂的是真管线
scripted runner 唯一的特殊之处是「事件从哪来」——它从内存里的脚本步骤取,而不是从子进程 stdout 取。取到之后,事件走的是和 claude/codex/acp 完全相同的一条落库路:sink.emit → EventLogFacade.append → encodeEvent → repo.appendEvent(一次 SQLite 事务,含外键前置行 + 投影)→ EventBroadcaster → tRPC-over-WS。
正因为如此,runner 刻意把回放推迟到下一个宏任务再开始:真实 transport 都是在 startTurn 返回给 service 之后、从 reader 回调里才吐事件的;假 runner 若同步吐,就会跨越和真 provider 不同的异步边界,掩盖真实时序 bug。
// startTurn 返回后才开播,让 sink 事件与真 provider 跨过同一个异步边界
async startTurn(input: AgentInput): Promise<void> {
if (this.active) throw new Error('A scripted turn is already running.')
const turn = this.turns.consume(input)
this.turnAbort = new AbortController()
this.active = nextMacrotask() // setImmediate:模拟真 transport 的 reader 回调时机
.then(() => (turn ? this.playSteps(turn.steps) : this.playEcho(input)))
.catch((error) => this.handlePlaybackError(error))
.finally(() => { this.active = null; this.turnAbort = null })
}
这条「返回后才开播」的性质在旅程 B 里会变成一个安全保证:withSessionRunnerLock 在 startTurn 返回时就已释放,所以回放期间没有任何锁被持有——auto-answer 回调 service 不会自锁。
5.2两层校验:格式层 + 语义层,语义层复用生产投影
脚本在被列进模型选择器、或被 createRunner 加载前,都要过两层校验。格式层用 zod 校验 JSON 结构,其中 emit step 里的事件 payload 交给一个注入进来的「生产事件解析器」逐个校验——即真实 daemon 用的那套事件 schema,按 type 派发。
// 拿一个 unknown,看 type 字段派发到已注册事件的 schema;不认识就用 z.never() 稳定报错
export function safeParseAgentProviderEvent(input: unknown) {
if (!input || typeof input !== 'object' || !('type' in input)) return z.never().safeParse(input)
const type = input.type
if (typeof type !== 'string' || !isRegisteredEventType(type)) return z.never().safeParse(input)
return eventRegistry[type].schema.safeParse(input) // 脚本里的 emit 事件用真实事件契约校验
}
语义层更狠:它把脚本声明的整条事件序列合成信封,过一遍真实的 projectSession 折叠器——能折完不抛错,才算语义合法。加上「请求 id 必须是响应 mutation 认的 UUID、且全 session 唯一」「gate 必须紧跟它要等的那条 request 事件」等跨步规则。装配处把这两个生产函数注入 validator:
const scriptedValidator = new ScriptedValidator({
parseEvent: (input) => { // 格式层:真实事件 schema
const result = safeParseAgentProviderEvent(input)
return result.success ? { success: true, data: result.data }
: { success: false, issues: [...result.error.issues] }
},
projectEvents: projectSession, // 语义层:真实投影折叠器
})
这就是为什么 projectSession 必须从 testing/toolkit/ 搬到 agent/:生产运行时(registry)现在要调它,而生产代码不能 import testing/(会把 Vitest 依赖图拖进运行时)。旧路径留一个 12 行 re-export 垫片,让既有测试 import 不破。
types.ts 的 AgentProviderKind union 与运行时校验集各加一个字面量;② packages/db/src/index.ts 的 agent_providers_kind_valid CHECK 加白名单值(SQL / snapshot 是生成物,跑 db:generate 跟随);③ 写一个 ProviderModule(factory 必须无 I/O,否则会被探测逻辑静默判为不可用)+ builtinSeed;④ push 进 registry.ts 的 PROVIDER_MODULES 数组,builtin 种子行由既有 ensureBuiltinAgentProviders 自动落库。scripted 完全照这条路走,没开任何特例。
6旅程 A:一条脚本如何变成落库的事件流
走通这条,你就知道「选了个脚本、发一句话」之后,事件是怎么从磁盘 JSON 一路变成 SQLite 里的行、再推给 renderer 的。
model=脚本名→ startTurn→ 加载脚本
loader.ts→ 回放步骤
runner.ts→ sink → codec → SQLite → WS
A.1选哪个脚本:脚本名塞进 model 字段,加载时严守目录
CLI / desktop 建会话时固定 providerId: 'builtin-scripted',把脚本文件名当作 model 传进去。provider 在 createRunner 时按这个 model 反查要回放哪个脚本;model 省略(空)则进「回显模式」。
加载这一步最要紧的是不让 model 输入逃出脚本目录——它来自用户/前端,必须当不可信输入。loadScript 先查名字合法性,再解析绝对路径并断言它仍在目录内。
export async function loadScript(buffinHome, scriptId, validator, signal) {
if (!isSafeScriptId(scriptId)) throw new Error(`Invalid scripted session id "${scriptId}".`)
const directory = resolve(scriptedSessionsDirectory(buffinHome))
const file = resolve(directory, `${scriptId}.json`)
if (!file.startsWith(`${directory}${sep}`)) // 解析后仍必须在目录内,挡住 ../ 逃逸
throw new Error('Script path escapes the script directory.')
const result = await validator.validateFile(file, signal) // 只回放过了两层校验的脚本
if (result.script) return result.script
// ... 校验失败:抛第一条 issue
}
// bare name:无路径段、不带 .json 后缀
function isSafeScriptId(scriptId) {
return scriptId.length > 0 && basename(scriptId) === scriptId && !scriptId.endsWith('.json')
}
A.2回放:一个 step 一次派发,run.completed 之后立刻收手
回放是一个「步骤游标」循环:每步派发一个声明动作,返回「游标前进几格」或「停」。emit 发事件、wait 延时、close 结束、pause-for 停等真人、auto-answer 自动应答(旅程 B 详述)。
这里有一处「防御性收手」值得记住:发完 run.completed 后立即 return 'stop'。因为一旦 run 离开 Running 状态,持久化编解码器会把后续事件全部回滚——继续发只会被静默丢光。这是第 9 节 B4 修复的运行时兜底(校验层同样会拒绝终态之后的步骤)。
case 'emit':
await this.emit(step.event)
rememberRequest(step.event, requests)
// run.completed 让 run 变为非 Running,后续任何事件都会被持久化层回滚;就此结束本 turn
if (step.event.type === 'run.completed') return 'stop'
return this.afterEmittedRequest(step.event, nextStep, requests)
emit 还顺手做一件事:只有 run.started 会被改写 providerSessionId(把 run 身份归一),其余事件原样转发给 sink。
A.3没脚本 / 脚本耗尽:退化成回显
不选脚本(model 空),或脚本的 turns 已被逐一消费完,runner 就退化成「回显」:把用户输入按字符切块流式吐回,模拟一次普通 turn。脚本第一次耗尽时会先播一条一次性通知。
private async playEcho(input: AgentInput): Promise<void> {
await this.emit({ type: 'run.started' })
if (this.script && !this.exhaustedNoticeSent) {
this.exhaustedNoticeSent = true // 「脚本耗尽」只播一次
await emitMessage(this.sink, EXHAUSTED_MESSAGE, 'script-exhausted')
}
await emitMessage(this.sink, input.text, 'script-echo') // 原样回显用户输入
await this.sink.emit({ type: 'run.completed', status: 'completed' })
}
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 选了脚本却在回显 / 报「脚本无效」 | loader.ts:loadScript 的校验分支与目录逃逸断言;再看 validation.ts 两层报告 |
run.completed 之后声明的步骤没生效 | runner.ts:playStep 的 'stop' 返回;这是有意的,不是 bug |
| 事件落库时序 / 数量不对 | runner.ts startTurn 的 nextMacrotask 推迟;再顺着 SessionSink → event-log → appendEvent 查 |
| 选择器里看不到某个脚本 | loader.ts listScriptModels:无效文件会 warn 一次并跳过(看 daemon 日志) |
7旅程 B:auto-answer 为什么必须走真实认领路径
脚本可以声明「这个 approval 自动批准」。直觉做法是让 runner 自己发一条 approval.resolved 事件了事——初版就是这么做的,结果 approval 直接崩、input 被静默丢弃。这条旅程讲清楚为什么直觉做法行不通,以及最终怎么改。
runner.ts→ 会话作用域 resolver
runner-manager.ts→ 真实认领 API
service.ts→ CAS 抢占 + 发 resolved
B.1陷阱:直接发 resolved 事件,approval 崩、input 静默回滚
要看懂这个坑,得先知道持久化编解码器对这两类「已解决」事件的投影规则不一样,而且都不容忍「凭空解决一个还处于 Pending 的请求」。
approval 场景:脚本先 emit 了 approval.requested——这已经插了一行 Pending 状态、主键就是 approvalId。若 runner 再直接发 approval.resolved 带 resolutionSource:'auto',而 'auto' 这个分支在编解码器里是为「没有前置请求的审批」设计的自包含 INSERT。于是同一主键被 INSERT 两次 → SQLite 唯一约束报错 → 抛异常 → 整个 turn 崩掉。
input 场景:直接发的 input.resolved 唯一的投影是一个「要求当前行处于 Resolving 状态」的条件更新(CAS)。但没有任何东西把那行从 Pending 移到 Resolving(只有真实 service 方法会做)→ CAS 命中 0 行 → 事件被静默回滚,从没落库/广播;请求随后被 run.completed 一并取消。
emit approval.requested(落 Pending 行)approval.resolved{source:'auto'}emit approval.requested(落 Pending 行)respondToApproval(与用户点「批准」同一入口)resolved{source:'user'} → 落库广播B.2三环接线:让 runner 反向调回 service,又不引入循环依赖
解法(用户拍板选的 Option A)是:runner 不自己发事件,而是调用 UI 用的同一组 AgentService 方法。难点是 RunnerManager 不能 import AgentService 类(循环依赖),于是用「结构化类型 + 后绑定 setter」破环,分三环。
环 1 — service 把自己注册进去。AgentService 天生就有 respondToApproval / respondToInputRequest,结构上直接满足 SessionRequestResolver,构造尾部一行把 this 交给 manager。
// scripted 的 auto-answer 走和真人应答同一条认领路;manager 会把 sessionId 绑到这些方法上
this.runnerManager.setRequestResolver(this)
环 2 — manager 按 sessionId 绑定。它把「宽面」包成「窄面」,咬死一个 sessionId,这样每个 runner 只能答自己 session 的请求。
private buildRequestResolver(sessionId: string): SessionScopedRequestResolver | undefined {
const resolver = this.requestResolver
if (!resolver) return undefined // 非 scripted 场景没注册 resolver,返回 undefined
return {
approval: (approvalId, response) => resolver.respondToApproval(sessionId, approvalId, response),
input: (inputRequestId, response) => resolver.respondToInputRequest(sessionId, inputRequestId, response),
}
}
环 3 — 建 runner 时把绑定好的窄面传进去(createRunner 的 options 里,无 resolver 就干脆不带这个 key),runner 最终在 emitAutoAnswer 处调它。
private async emitAutoAnswer(step, requests): Promise<void> {
const request = requests.get(requestId(step))
if (!this.resolveRequest) throw new Error('Scripted auto-answer requires a session request resolver.')
if (step.request === 'approval' && request?.type === 'approval.requested') {
await this.resolveRequest.approval(step.approvalId, step.response) // 不再 sink.emit,改走认领
return
}
if (step.request === 'input' && request?.type === 'input.requested') {
await this.resolveRequest.input(step.inputRequestId, step.response)
return
}
throw new Error(`Scripted ${step.request} auto-answer has no matching request.`)
}
B.3为什么这条再入回路不会死锁
这条路径其实是个回环:runner → resolveRequest → service.respondToApproval →(CAS 认领 + 发 resolved)→ runner.respondToApproval。它安全,靠两个性质:其一,service 回调到 runner 的 respondToApproval 只是 releaseGate——没有挂起的 gate 时它是 no-op(auto-answer 场景没人在等 gate);其二,回放已被推迟到 startTurn 返回之后(见 5.1),withSessionRunnerLock 那会儿早已释放,认领路径也从不取那把锁。所以不存在自锁。
还有一处「诚实」的细节:语义校验在把自动应答折进投影 oracle 时,approval 的 resolutionSource 也写 'user'——因为运行时走的就是用户那条路,校验产出的转录必须和真实持久化流一致。
return {
type: 'approval.resolved', approvalId: step.approvalId,
optionId: option.optionId, effect: option.effect, kind: option.kind,
// 运行时经用户认领路径解决,这里镜像它,好让投影转录与持久化流一致
resolutionSource: 'user',
}
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
脚本里 auto-answer 的 turn 崩掉 / run.completed 前少了 approval.resolved | runner.ts emitAutoAnswer:确认走的是 resolveRequest.approval 而非直接 sink.emit |
| 报「requires a session request resolver」 | service.ts 构造是否调了 setRequestResolver(this);测试里 mock 的 manager 是否有该方法 |
input.resolved 从没落库 | 持久化层的 Pending→Resolving CAS;确认认领确实经 respondToInputRequest 抢占了行 |
| auto-answer 后 turn 卡住不动 | runner.ts releaseGate / waitForResponse;再核 5.1 的锁与宏任务时序 |
8旅程 C:造脚本、开会话、看徽章
这条是「人怎么用它」的旅程,横跨 CLI 和 desktop 两个用户面。
~/.buffin/scripted-sessions/→ 校验
scripts.ts→ 自动回放 / UI 选
session.ts · use-composer-config.ts→ Scripted 徽章
C.1造脚本靠手写,scripts 只负责校验
注意一个范围事实:scripts 命令目前只有 validate 一个子命令,没有脚手架。用户「造脚本」= 手写一个 <name>.json 放进 BUFFIN_HOME/scripted-sessions/(默认 ~/.buffin/scripted-sessions/)。校验逻辑不在 CLI——CLI 只解析路径、把绝对路径喂给 daemon 的 debug.validateScript(local-only query),拿回两层报告排版打印。
const report = await trpc.client.debug.validateScript.query({
file: resolveScriptArg(args.file), // bare name 或显式路径都收
})
emit({ mode: resolveOutputMode(args.json === true), json: report, human: printValidationReport })
if (!report.valid) process.exitCode = 1
路径解析有个口径差异值得记一笔:scripts validate 用 resolveScriptArg(bare name 或 任意路径都行),而 session play --script 用 scriptFileFromName(只收 bare name,给路径直接抛错)。同一份 script-paths.ts 里两个入口,语义不同。
C.2session play:一键建会话并逐 turn 跑完
自动驱动器刻意放在 CLI 而非 daemon:真实会话「每条用户消息 = 一个 run」,假 runner 单 run 连播多轮会让回合间状态失真。所以 play 先订阅事件,再逐 turn 发真实 startTurn、等本轮 run.completed 再发下一轮。为对付「完成事件比等待者先到」的乱序,它用一个 runId→promise 的配对队列。
for (const [index, turn] of script.turns.entries()) {
const started = await trpc.client.agent.startTurn.mutate({
sessionId: session.id, input: [{ type: 'text', text: turn.user }],
})
await completions.wait(started.runId) // 严格串行:等这一轮 run 完再发下一轮
}
play 开跑前还会本地 assertAutoPlayable:若某 turn 含 pause-for、或 emit 了请求却没紧跟 auto-answer,就 fail fast 报「Add an auto-answer step, or drive this script manually in the app.」——避免在只有真人能回答的 run 上无限等待。
C.3desktop:Echo 选项只在创建态给,标题从脚本名派生
desktop 的核心是一条 gating:scripted provider 的 model picker 多一个「Echo (no script)」伪项(空 id → 省略 model → 回显模式),但只在「创建会话」时出现。因为运行时 reconfigure 拒绝空 model(会永远报错),已就绪会话不能给一个「选了必然报错」的项。判据就是 hook 有没有拿到 onConfigurationChange 这个 runtime writer——有=就绪,无=草稿。
function pickerModels(provider, allowEcho): AgentModelOptionDto[] {
const base = providerModels(provider)
if (!allowEcho || provider?.providerId !== 'builtin-scripted') return base
return [...base, { id: '', name: 'Echo (no script)' }] // 空 id = echo;仅创建态追加
}
// runtime writer 存在 = 已有会话(reconfigure);不存在 = 创建草稿
const models = useMemo(() => pickerModels(provider, !onConfigurationChange), [provider, onConfigurationChange])
选中某个脚本(非空 model)后,建会话时前端还会把该脚本的展示名预派生成会话标题传给 daemon,让 tab 有意义(echo 和其它 provider 回退 daemon 默认标题)。会话就绪后,useSessionDescriptor 认出 builtin-scripted 就产出一个 tag,最终在 tab 和预览头渲染成一个 "Scripted" pill(走 rounded-pill / text-10 / --color-* token,合规)。这个 tag 机制本身是通用的「内容自定义身份标签」,只是当前唯一填充者是 scripted。
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| 创建会话看不到 / 看得到 Echo 选项 | use-composer-config.ts pickerModels 的 allowEcho;它绑 !onConfigurationChange(就绪态无) |
| scripted 会话 tab 标题是默认名不是脚本名 | SessionView.tsx scriptedSessionTitle:是否选了非空脚本 model |
session play 卡住不返回 | session.ts assertAutoPlayable(脚本含未自动应答的门)+ 完成队列 completedRunId |
| tab 上没有 "Scripted" pill | use-session-descriptor.ts scriptedTag → session-registration.ts → TabStrip.tsx |
9计划 vs 实现的偏差
这是自带设计稿的 PR 才有的章节。照计划做成的部分你已经知道,偏差才是认知裂缝。素材来自设计稿 scripted-sessions-design.md 与修复轮实现计划。
结构 / 范围偏差
| 计划 | 实际做成 | 为什么变 |
|---|---|---|
| 拆 4 个 PR 顺序推进(daemon 核心 / CLI / desktop / docs) | 全部内容在单个提交 81469841 里一把梭,走一个 PR #153 |
无明确记录;推测单人连续开发、拆 PR 收益不大 |
| desktop 只做两件:脚本名填 title + "Scripted" 徽章;设计稿明确写「回显不做独立模式」(回显是「不选脚本」的隐式退化态) | 做了四件:title、身份标签、新增一个显式的「Echo (no script)」下拉项、composer「脚本推进」提示 | 无直接记录。而正是这个多加的显式 Echo 项后来直接引出了 B2 的 bug——加戏、加的戏又埋了雷 |
| (未预期) | 中途吞下 main 的 #152 渲染层重构(91b94ca0),做语义级重新安置:Echo 项搬进 useComposerConfig.pickerModels,title 改在 SessionView 边界现推,徽章药丸改用 #152 的圆角 token |
#152 重写了 composer 架构,scripted 行为被重装进新架构而非逐行合并 |
| (未预期) | 最后又合入 #146(4930e734),唯一相关动作是把 'scripted' 重新加回 sqlite 的 agent_providers_kind_valid CHECK |
#146 重生成了迁移基线,会冲掉这一行 |
认知裂缝:Codex review 揪出的 6 个 P2(初版错在哪 → 改成什么)
这 6 个都由 d6d2da68 修复,全部经源码核实是真 bug,根因扎堆在「scripted runner 与 daemon 持久化编解码 / reconfigure 机制的交互」。
| 编号 | 初版错在哪 | 改成什么 |
|---|---|---|
| B1 CR #1+#3 |
auto-answer 直接发 resolved 事件:approval 的 'auto' 分支再 INSERT 一次已存在的前置行 → 唯一约束报错 崩 turn;input 的 CAS 因行仍是 Pending 命中 0 行 → 静默回滚,永不落库 |
改走真实 service 认领路径(第 7 节旅程 B):由 respondToApproval/respondToInputRequest 完成 Pending→Resolving 认领并发 source:'user' 的 resolved,与真人应答逐字节一致。这是本轮唯一上抛用户拍板的决策 |
| B2 CR #2 |
那个新加的显式 Echo 项永远追加进 model 下拉,包括已就绪会话;在就绪会话上选它 → 发 model:'' 走 reconfigure → normalizeSessionModel 对空串抛 validation.failed(建会话容忍空串走回显,reconfigure 不容忍) |
pickerModels(provider, allowEcho) 只在创建态追加 Echo;hook 用「是否有 reconfigure 回调」判就绪与否(第 8.3 节) |
| B3 CR #4 |
reconfigure 永远 deferReconfigure → failed/unsupported 皆空 → service 不标记 runner 陈旧 → 换脚本后 DB 变了、内存 runner 仍放老脚本 |
参数含 model 时报 failed:['model'](照抄 ACP 自愈写法),逼 service 标记 handle 陈旧、下一轮 turn 重建 runner 加载新脚本 |
| B4 CR #5 |
校验只拦「显式 close 之后」的步骤;跟在 run.completed 后的步骤被放行,运行时被持久化层静默回滚丢光 |
校验把守卫从 closed 泛化成 terminal(run.completed 或 close 都算终态),拒绝其后任何步骤;runner 运行时也在发完 run.completed 立即收手(第 6.2 节) |
| B5 CR #6 |
waitForResponse 无条件给 abort signal 挂监听;若中断在「发完请求」到「装好 gate」的窄窗口里 abort,监听器挂到已 abort 的 signal 上永不触发 → gate 永久挂起 → 后续 turn 全抛「already running」 |
执行器开头先判 if (signal.aborted) 立即 reject 再挂监听(照抄 delay 里同款前置守卫) |
一个旁证:修复计划里提到 releaseGate 空 gate 时为 no-op 是「上一轮的修复」——说明在这 6 个 Codex 问题之前,可能还有过至少一轮 review / 修复。
10心智模型补丁
读完之后,对这个项目的理解需要改这几处。
scripted,它贯穿 types.ts 的 union、packages/db 的 CHECK、registry.ts 的 PROVIDER_MODULES;builtin id 是 builtin-scripted,常驻注册(发布前需加 env 门禁)。
projectSession 是测试专用的 oracle,住在 testing/toolkit/。
它升为生产模块 agent/session-projection.ts,运行时脚本语义校验直接调它;生产代码禁 import testing/,所以旧路径只剩 re-export 垫片。
respondToApproval/respondToInputRequest);用结构化类型 + 后绑定 setter 破循环依赖。
provider.models 原样。
scripted 在创建态多一个空 id 的「Echo (no script)」伪项;就绪态没有(reconfigure 拒空 model)。空串 id 同时是 echo 的判据和「回退到省略 model」的触发。
providers.list 一律 lazy,不碰 provider 内部。
显式 modelsInAvailability 的 module(scripted 这类文件后端 catalog)会在 list 时急切扫盘拉模型,好让本地脚本文件出现在创建选择器里;进程后端仍 lazy。
11新词表
| 脚本域 | |
|---|---|
builtin-scripted / scripted session | 从磁盘 JSON 脚本回放预录 agent 事件的内置 provider / 会话,供 UI 调试;不真跑 LLM。 |
step(emit/wait/pause-for/auto-answer/close) | 一个 turn 里的可执行动作:发事件 / 延时 / 停等真人 / 自动应答 / 结束。 |
| bare script name | 无路径分隔符、不带 .json 后缀的纯文件名;解析到 BUFFIN_HOME/scripted-sessions/<name>.json。 |
| echo mode / Echo (no script) | 不选脚本(空 model id)时的退化态:原样流式回显用户输入。 |
| 校验域 | |
|---|---|
ScriptValidationReport | 对外的校验结果:valid + format 层 + semantic 层,各带逐条 {path, message}。 |
| format 层 / semantic 层 | 格式层 = JSON + zod 结构;语义层 = 把事件序列过一遍真实 projectSession 折叠 + 请求门规则。 |
safeParseAgentProviderEvent | 按 type 派发到已注册事件 schema 的安全 parse 入口;格式层用它校验 emit 里的事件。 |
| 运行域 | |
|---|---|
ScriptedTurnQueue | runner 无关的「turn 一次性消费」游标类:先按 match 谓词认领、认领不到再按顺序 fallback;生产 provider 与测试 spy 共用。 |
SessionRequestResolver / SessionScopedRequestResolver | 前者是 service 满足的「带 sessionId」宽面;后者是已绑定 sessionId 的窄面,交给单个 runner。 |
| markStale | 把 runner handle 标记陈旧,逼下一次 getOrCreate 从持久化配置重建(scripted 换脚本靠它)。 |
| UI 域 | |
|---|---|
pickerModels(provider, allowEcho) | 把 provider 原始 model 列表加工成 UI picker 列表;唯一副作用是给 scripted + 创建态追加 echo 伪项。 |
scriptedSessionTitle | 建 scripted 会话时从选中脚本的展示名派生会话标题。 |
identity tag(session.scripted = "Scripted") | descriptor 层的「内容自定义身份标签」,渲染成标题旁 pill;当前唯一实例是 scripted。 |
12测试与风险地图
纯事实陈述:哪些行为被测试钉住,哪些是薄冰。
有兜底
- auto-answer 端到端(第 7 节的核心回归守卫)——
scripted-integration.test.ts用真实 tRPC + SQLite 跑 approval / input 自动应答,断言resolved事件落在 request 与 completion 之间、resolutionSource:'user'、turn 不崩不回滚。这正是初版漏掉、从而掩盖 B1 的那块测试。 - provider 契约一致性——
scripted-conformance.test.ts用真实 provider + 生产 validator 跑共享的runProviderConformance(streaming / approval / input / interrupt)。 - 两层校验——
scripted-validation.test.ts:接受正常脚本、拒绝run.completed之后的步骤(B4)、按 JSON path 报告畸形事件。 - runner 边角——
runner.test.ts:停在run.completed(B4 运行时)、换 model 报failed:['model'](B3)、中断落在 gate 窗口后仍可复用下一轮(B5)。 - desktop 两态——
use-composer-config.test.tsx:创建态有 Echo、就绪态无(B2);SessionView标题派生、use-session-descriptor/TabStrip的 tag 渲染各有一例。
薄冰
- 🟠发布阻断级 follow-up:provider 现在无条件常驻注册(开发期刻意如此),公开发布前必须加 env 门禁(设计稿建议
BUFFIN_SCRIPTED_PROVIDER),已记入open-followups.md。 - 🟡
session-projection.ts/turn-queue.ts搬家后没有搬对应的单测,其正确性靠 conformance / validation / integration 间接覆盖。 - 🟡
registry.ts的withAvailabilityModels(flatMap→for-await、失败降级 warn 分支)无针对性单测。 - 🟡
debug.validateScript的 tRPC 条件透传与「未配置抛 INTERNAL_SERVER_ERROR」分支无直接测试(校验测试走 validator 层,不经 procedure)。 - ⚪CLI 的
scripts validate命令、script-paths.ts路径解析、assertAutoPlayable的拒绝路径无单测(只测了纯 emit 的 happy 脚本)。 - ⚪
FloatingComposer占位符分叉、选 Echo 后「create 入参model:undefined」的落地无测试。
13验收提示
别被这些吓到——它们看着像缺陷,其实是有意为之或 git 的错觉。
testing/toolkit/projection.ts−336 行不是删功能,是逐字节搬到session-projection.ts(除 import 路径外一字未改),旧文件留 12 行 re-export 垫片,既有测试 import 不破。- tag 机制通用、当前单点使用:
TabDescriptor.tag注释写「content-defined identity tag」,但唯一填充者是scriptedTag,只产出 "Scripted"。通用外壳单点用,不是半成品。 - 就绪 scripted 会话切不回 echo:这是第 8.3 节 gating 的刻意结果(reconfigure 拒空 model),不是遗漏。
'builtin-scripted'在 renderer 里硬编码 4 处(pickerModels/scriptedSessionTitle/scriptedTag/FloatingComposer):是把各处接起来的「连接键」,不是重复 bug;改 id 要同步这几处(外加 daemon 侧)。- 提交历史里的
74909f74与4930e734是从 main 合进来的 #146(task-template 生命周期),与本 PR 内容无关;用74909f74...HEAD三点 diff 看时它们不计入,别当本 PR 的改动读。
14覆盖声明
全量精读,59 个改动文件全部落在某个 agent 的视野内,无抽样、无略读。daemon provider 核心(runner / validation / schema / provider / loader / module)、resolver 三环接线(types / runner-manager / service)、投影折叠器、CLI 的 scripts / session play、desktop 的 use-composer-config 均由主笔亲自 Read 源文件后亲手裁剪片段——报告中每段代码都出自一手阅读,非二手转述。CLI / desktop / daemon 集成层 / 设计文档四块的广度由四个子 agent 并行建图(定位重点与结论),其中的关键代码再经主笔二次精读核实。计划 vs 实现偏差取自设计稿 scripted-sessions-design.md 与修复轮实现计划。