#65 ACP v1 agent provider:给 daemon 接上第三套 agent 协议
figuretu/eyrie · main...phase2-acp · 2026-06-14 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自 phase2-acp 分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。这是一份「理解报告」,只帮你重建心智模型,不做 code review、不挑刺。
1TL;DR
Eyrie daemon 此前已经能接两种 coding agent:claude(拉起 Claude CLI 子进程 + 它自己的控制协议)和 codex(JSON-RPC 的 app-server)。两者都实现同一套内部契约 AgentProvider / AgentRunner / AgentEventSink。
这个 PR 加了第三套:ACP(Agent Client Protocol)v1。它让 daemon 能跟任何遵守 ACP 标准的 agent 通信——握手、开会话、发问答、流式收结果、处理 agent 反过来要的审批和表单输入,全部走 stdio 上的 newline-delimited JSON-RPC。
动机(从 PR 描述与 commit 推断):ACP 是一套公开标准协议,接它一次就能接一整类 agent,不必像 claude/codex 那样为每个 agent 手写一套适配。本 PR 没有改动上层 agent 接口类型(types.ts 一行未动),全部新增逻辑都收在 apps/daemon/src/agent/acp/ 这一个新模块里,再用一行接线把它挂进既有的 provider 注册表。
2变更地图(称重)
变更高度集中:3940 改动行里,1887 行是新模块源码、1708 行是它的测试,两者加起来 91%。剩下约 345 行是顺带做的跨平台(Windows / Bun)加固,与 ACP 功能本身无关(见验收提示)。
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
agent/acp/ 源码 |
runner.ts(641,状态机 + 生命周期)、event-mapper.ts(484,双向翻译)、json-rpc-connection.ts(304,传输) |
protocol.ts(266,纯类型,照 ACP spec 抄)、provider.ts/registry.ts/module.ts/index.ts(接线样板) |
| ACP 测试 | acp-runner.test.ts(705)、acp-event-mapper.test.ts(369)、acp-runner-integration.test.ts(242,唯一过真实 JSON-RPC 编解码) |
acp-json-rpc-connection.test.ts、acp-provider-registry.test.ts、acp-real-server-smoke.test.ts(默认 skip) |
| 跨平台/CI 顺带 | — | 5 个 scripts/check-*.mjs(Windows 入口判定)、services/path-canonicalization.ts、migrations.test.ts(Bun bin 解析)、terminal-ws.test.ts(FakePty 断言) |
称重诚实结论:这几乎全是设计承载代码。新模块没有生成物、没有 lockfile、没有大段机械搬运;protocol.ts 虽是纯类型,但它承载的是 ACP 协议契约,不是无脑样板。测试占比 49%,与源码几乎 1:1。
3架构一图流
架构面变化是「多了一条并列的进程通道」。claude/codex 各有自己的握手与翻译;ACP 新增了一条走标准 JSON-RPC 的通道,但和前两者共享同一个出口(AgentEventSink)和同一套上层契约。
以前 · 两套 provider
现在 · 第三套并列接入
注意 ACP 这条通道是双向的(图中 ◀▶):不只是 daemon 调 agent,agent 也会反过来调 daemon——要工具执行许可(approval)、要用户填表单(elicitation)。claude/codex 也有审批,但 ACP 把它做成了协议级的「agent 发请求、client 回响应」往返,这是后面旅程 C 的主题。
agent/<kind>/ 下写 provider.ts(实现 AgentProvider,把一行 provider row 变成 runner)+ runner.ts(实现 AgentRunner);② 写一个 module.ts 导出 ProviderModule({ kind, factory });③ 在 agent/registry.ts 的 PROVIDER_MODULES 数组里加上它——就这一行,daemon 的 DbAgentRegistry 会自动 detect/lookup 全部已注册 provider。
4数据与状态形状
先把后面旅程要用到的词汇预载一遍——只看形状不讲行为。三组:ACP 进来的线协议、Eyrie 出去的事件、runner 内部的状态。
① ACP 线协议:一个可判别联合 + 开放逃生口
agent 在一次问答里流式发回的所有增量,都是 session/update 通知里的一个 AcpSessionUpdate。它是按 sessionUpdate 字段判别的联合,末尾挂一支 AcpRecord(裸 record)兜底未知类型——这样 daemon 能跟比它实现得更新的 agent 通信而不崩。
// agent 在 prompt turn 内流式发回的增量
export type AcpSessionUpdate =
| AcpContentChunkUpdate // 消息/思考文本片段
| AcpToolCall // 工具调用开始
| AcpToolCallUpdate // 工具调用进展(可带结构化 diff)
| AcpPlanUpdate
| AcpUsageUpdate
| AcpAvailableCommandsUpdate
| AcpRecord // ← 逃生口:不认识的 update 当裸对象收着,不报错
② Eyrie 事件:本 PR 一行没改的既有契约
关键事实:mapper 的所有产出(message.delta、tool.completed、approval.requested……)都是 types.ts 里早就存在的事件,本 PR 没有为 ACP 发明任何新事件类型。ACP 适配的工作是「把自己的形状对齐到既有事件」,不是「让既有契约迁就自己」。
export type AgentProviderEvent = AgentProviderEventBase & (
| { type: 'message.delta'; role: 'assistant' | 'reasoning'; text: string; itemId?: string }
| { type: 'message.completed'; itemId?: string; text?: string }
| { type: 'tool.started'; toolCallId: string; name: string; input: unknown }
| { type: 'tool.completed'; toolCallId: string; output: unknown; status: 'completed' | 'failed' }
| { type: 'approval.requested'; approvalId: string; /* ... */ }
| { type: 'input.requested'; inputRequestId: string; kind: 'questions'; /* ... */ }
| { type: 'diff.updated'; unifiedDiff: string }
// …还有 plan.updated / usage.updated / commands.updated / run.started / run.completed
)
③ runner 内部状态:所有「记忆」都在这里
这是全 PR 最该记住的一张形状表。AcpRunner 持有运行期的全部可变状态——会话 id、能力缓存、挂起的审批/输入、聚合中的助手消息、状态机当前态。后面会反复回看它。
export class AcpRunner implements AgentRunner {
private readonly pendingPermissions = new Map<string, PendingPermission>() // 等用户拍板的审批
private readonly pendingInputRequests = new Map<string, PendingInputRequest>() // 等用户填的表单
private activeAssistantMessages: Map<string, BufferedAssistantMessage> | null = null // 本轮助手文本聚合 buffer
private commandSnapshot: AgentControlCommandDef[] = [] // agent 广播的 slash 命令
private activePromptCancellation: ActivePromptCancellation | null = null // 在飞 prompt 的本地中断闩
private initialized: Promise<void> | null = null // initialize 只跑一次的 latch
private sessionReady: Promise<string> | null = null // 建会话只跑一次的 latch
private providerSessionId: string | null = null
private agentCapabilities: AcpAgentCapabilities | undefined // initialize 握手回的能力,门禁全查它
private state: AcpRunnerState = AcpRunnerState.Idle // 四态状态机
readonly closed: Promise<AcpConnectionExit>
}
状态机只有四态,构成两类转换:每个问答 Idle ⇄ Running;关闭走 * → Closing → Closed(显式 dispose)或 * → Closed(transport 自己挂了)。
export const AcpRunnerState = {
Idle: 'idle', Running: 'running', Closing: 'closing', Closed: 'closed',
} as const
5底座:JSON-RPC 传输层
AcpJsonRpcConnection 是所有旅程脚下的地基——它只懂「在 stdio 上收发一行一个 JSON-RPC 对象」,完全不懂 ACP 语义。runner 在它之上发请求、注册回调。底座有三个值得走通的机制:分帧、串行化、关闭收尾。
分帧 + 串行化:为什么帧处理被排成一条队
stdout 是字节流,一次 data 事件给到的 chunk 不保证刚好一行——可能半行、可能多行。acceptChunk 把 chunk 累进 buffer,反复找 \n 切帧。
关键在切出帧之后:每帧不是直接 acceptLine(line) 跑掉,而是链到 lineQueue 这条 promise 链上。这是 commit「serialize ACP JSON-RPC frame handling」的核心——保证一帧的 async handler 完全跑完,才开始下一帧。可观察后果:即使 session/update 的处理是异步的,多条 update 也严格按到达顺序串行处理,不会交错。
private acceptChunk(chunk: Buffer | string): void {
this.buffer += chunk.toString()
let newline = this.buffer.indexOf('\n')
while (newline >= 0) {
const line = this.buffer.slice(0, newline).trim()
this.buffer = this.buffer.slice(newline + 1)
if (line) {
this.lineQueue = this.lineQueue
.then(async () => {
if (this.failed) return // 连接已失败 → 短路掉后续所有帧
await this.acceptLine(line)
})
.catch((err: unknown) => { this.failConnection(err) }) // handler 抛错 → 整条连接进失败态
}
newline = this.buffer.indexOf('\n')
}
}
发请求:pending map 关联响应
本端发请求时分配一个自增 id,把 {resolve, reject} 存进 pending,返回 promise。响应帧回来时按 id 查 pending、resolve 或 reject、删除。注意传输层不管超时——超时是上层 runner 的责任(旅程 A.3)。
request<T = unknown>(method: string, params?: unknown): Promise<T> {
const id = this.nextId
this.nextId += 1
const response = new Promise<T>((resolve, reject) => {
this.pending.set(idKey(id), { resolve: (value) => resolve(value as T), reject })
})
this.write({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) })
return response
}
入站方向,acceptLine 按固定顺序判别三类消息:先看是不是 response(有 id 且有 result/error),再看 method——有 id 的是 agent 发来的请求(要回响应),没 id 的是通知(单向)。
关闭收尾:四种退出原因,pending 一律拒掉
连接终结有几条路径,每条给 closed 一个不同的 reason,且都先把全部 pending 请求拒掉(避免上层永久挂起)。显式 close() 是其中一条:
async close(): Promise<void> {
this.closing = true
this.rejectPending(new Error('ACP JSON-RPC connection closed.')) // 先拒掉所有在飞请求
if (this.closeTransport) { await this.closeTransport() } else { this.input.end() }
this.resolveClosed({ reason: 'killed' })
}
private failConnection(err: unknown): void {
if (this.failed) return // 一次性:后续失败被吞
this.failed = true
this.rejectPending(err instanceof Error ? err : new Error(errorMessage(err)))
this.resolveClosed({ reason: 'crashed' })
}
四种 reason 来源:stdout 正常 end → completed(或本端正在关时 killed);stdout error → crashed;JSON 解析失败 / handler 抛错 → failConnection 的 crashed;真实子进程退出(spawn 路径)则以进程 exit code 为准(0=completed、非0=crashed、有 signal=killed)。
排查路标 · 底座
| 症状 | 从哪下手 |
|---|---|
| agent 发的 update 顺序乱了 / 交错执行 | json-rpc-connection.ts:acceptChunk 的 lineQueue 链 |
| 某个请求永远不返回(卡死) | pending map:响应 id 没匹配上,或连接已 failed 但没触发 rejectPending |
| agent 崩了但上层不知道 | connect() 的 end/error 回调 + childExit 的 reason 映射 |
| 收到畸形 JSON 行 | acceptLine 的 JSON.parse catch → failConnection(整条连接 crashed) |
6旅程 A:一次问答(prompt turn)
这是脊梁旅程。用户发一句话,到 Eyrie 收到 run.completed,中间穿过 runner 的懒初始化、建会话、发 prompt、流式收结果。走通它,ACP provider 的主干就清楚了。
runner.ts→ initialize
runner.ts→ session/new|resume|load
runner.ts→ session/prompt
json-rpc-connection.ts→ 流式 update(见旅程 B)→ run.completed
event-mapper.ts
先看入口的整体骨架。startTurn 用 enterPromptTurn() / finally leavePromptTurn() 把整轮夹起来(这就是并发守卫,下面 A.3 讲),中间分三步:确保会话、断言能力、发 prompt 并收尾。
async startTurn(input: AgentInput): Promise<void> {
this.enterPromptTurn() // 进 Running 态,拒并发
try {
const sessionId = await this.ensureSession() // A.1 + A.2:懒初始化 + 建/恢复会话
this.assertPromptCapabilities(input) // A.3:image 门禁
const prompt = await this.mapper.toPrompt(input) // Eyrie input → ACP content blocks
await this.sink.emit(this.mapper.toRunStarted(sessionId))
try {
const response = await this.requestPrompt({ sessionId, prompt }) // A.3:四方竞速
await this.emitMessageCompletions(response) // 旅程 B:flush 聚合的 message.completed
await this.sink.emit(this.mapper.toRunCompleted(response, { response }))
} catch (err) {
await this.sink.emit({ type: 'run.completed', status: 'failed', summary: errorMessage(err), /* ... */ })
}
} finally {
this.leavePromptTurn() // 回 Idle
}
}
A.1懒初始化 + 能力协商
runner 构造时不发任何请求。第一次 startTurn 才触发 initialize,且用 promise latch 保证整个 runner 生命周期只握手一次。握手做两件事:把 Eyrie 自己「能干什么」告诉 agent,把 agent 回的能力缓存进 agentCapabilities(后续所有门禁都查它)。
Eyrie 宣告的 client 能力是写死的常量——明确告诉 agent:我不暴露文件读写、不暴露终端、只支持 form 模式的结构化输入。
const eyrieClientCapabilities = {
fs: { readTextFile: false, writeTextFile: false }, // 不把本地文件读写暴露给 agent
terminal: false, // 不暴露终端
elicitation: { form: {} }, // 只支持 form 模式的表单输入
} as const satisfies AcpClientCapabilities
private async initialize(): Promise<void> {
const response = await this.connection.request<AcpInitializeResponse>('initialize', {
protocolVersion: 1,
clientCapabilities: eyrieClientCapabilities,
clientInfo: { name: 'eyrie', title: 'Eyrie', version: '0.0.0' },
})
if (response.protocolVersion !== 1) {
throw new Error(`Unsupported ACP protocol version: ${response.protocolVersion}`) // 只认 v1
}
this.agentCapabilities = response.agentCapabilities
}
A.2建会话:new / resume / load 三路径 + 能力降级
会话怎么建,取决于上层有没有给「持久化句柄」以及 agent 宣告了哪些能力。没有句柄就 session/new 全新开;有句柄就走恢复,并在 restoreMethodFor 里按能力选方法。
这里有个不直觉的设计点:resume 句柄(不重放历史)如果 agent 不支持 resume,会自动降级到 session/load(重放历史)——能恢复比恢复语义精确更重要。反过来 load 句柄要求 agent 必须支持 load,否则直接抛。
private async createOrResumeSession(): Promise<string> {
await this.ensureInitialized()
const existing = restorableHandle(this.resume)
if (existing) return this.restoreSession(existing) // 有句柄 → 恢复
const response = await this.connection.request<{ sessionId: string }>('session/new', {
...this.sessionBaseParams(), // 无句柄 → 全新
})
this.providerSessionId = response.sessionId
return response.sessionId
}
private restoreMethodFor(handle: RestorablePersistenceHandle): AcpRestoreMethod | null {
if (handle.kind === 'load') return this.canLoadSession() ? 'session/load' : null
if (this.canResumeSession()) return 'session/resume' // resume 优先
if (this.canLoadSession()) return 'session/load' // 不支持 resume → 降级到 load
return null // 都不支持 → restoreSession 抛错
}
三条路径共享 sessionBaseParams()(cwd + 经门禁的 mcpServers),恢复路径额外带上原 sessionId。MCP server 列表在传出前还要过一道门禁:HTTP/SSE 传输的 MCP server 只在 agent 宣告对应能力时才放行,否则抛——stdio 永远放行。
A.3四方竞速收尾 + 并发守卫
一次 session/prompt 发出去后,runner 不傻等响应。它让四件事赛跑,谁先到谁决定这轮的结局。这是整个 runner 设计最精巧的地方,分两层 Promise.race。
内层:agent 真实响应 vs 本地中断闩(cancellation.promise,被 interrupt() 解析成 { stopReason: 'cancelled' })。外层:再叠上「transport 关闭」和「超时」两条失败路径。
private async requestPrompt(params: AcpPromptRequestParams): Promise<AcpPromptResponse> {
const request = this.connection.request<AcpPromptResponse>('session/prompt', params)
const cancellation = this.createActivePromptCancellation(params.sessionId)
try {
return await this.withPromptDeadline(
Promise.race([request, cancellation.promise]), // 内层:agent 响应 vs 本地中断
params.sessionId,
)
} finally {
if (this.activePromptCancellation === cancellation) this.activePromptCancellation = null
}
}
private async withPromptDeadline<T>(request: Promise<T>, sessionId: string): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
request,
this.closed.then((exit) => { // transport 先挂 → 抛
throw new Error(`ACP transport closed before prompt completed: ${exit.reason}.`)
}),
new Promise<T>((_, reject) => { // 超时 → best-effort cancel + 抛
timeout = setTimeout(() => {
this.cancelProviderPrompt(sessionId)
reject(new Error(`ACP prompt timed out after ${this.promptTimeoutMs}ms.`))
}, this.promptTimeoutMs)
}),
])
} finally { if (timeout) clearTimeout(timeout) }
}
四条结局对照如下。注意一个细节:成功走 mapper.toRunCompleted(按 stopReason 映射),而失败(超时/transport 关闭/中断后)走 startTurn 里手写的 run.completed status:'failed',不经 mapper。
run.completed 按 stopReason 映射 completed/cancelled/failedrun.completed cancelled + 另发 session/cancel 通知session/cancel + run.completed failedrun.completed failed,state 已被推到 Closed并发守卫(commit「add ACP runner state guard」):一个 runner 同时只允许一个问答。第二个 startTurn 进来直接抛,不排队。这就是 enterPromptTurn 干的事——Running 态再进就抛「already running」,Closing/Closed 态进则抛对应状态名。
private enterPromptTurn(): void {
if (this.state === AcpRunnerState.Running) {
throw new Error('ACP runner is already running a prompt turn.') // 拒并发问答
}
if (this.state !== AcpRunnerState.Idle) throw new Error(`ACP runner is ${this.state}.`)
this.activeAssistantMessages = new Map() // 开一个新的本轮聚合 buffer
this.state = AcpRunnerState.Running
}
另外,发 prompt 前还有一道 image 门禁:如果这轮带图片但 agent 没宣告 promptCapabilities.image,直接抛、state 退回 Idle、sink 一个事件都不发——不会把 agent 不认识的内容硬塞过去。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 第一次问答前卡很久 | runner.ts:ensureInitialized → initialize(initialize 请求本身无超时兜底) |
| 问答报「already running a prompt turn」 | enterPromptTurn:上一轮没 leavePromptTurn,state 卡在 Running |
| 恢复会话报「not supported by this agent」 | restoreMethodFor:句柄 kind 与 agentCapabilities 不匹配 |
| 问答超时但 agent 还在跑 | withPromptDeadline 的 setTimeout(promptTimeoutMs,默认 10 分钟,可被 providerConfig.promptTimeoutMs 覆盖) |
| 带图片的问答被拒 | assertPromptCapabilities:agent 的 promptCapabilities.image 未宣告 |
7旅程 B:update → 事件流
旅程 A 的「流式 update」那一跳,展开就是这条旅程:agent 经 session/update 通知发回的每条增量,怎么变成 Eyrie 的事件。这里有一个最容易搞错的心智,先纠偏。
B.1无状态 mapper vs 有状态 runner
关键心智:AcpEventMapper 是一个完全无状态的纯翻译类——通读全文没有一个实例字段,每个方法都是「输入一个结构 → 输出一个结构」的纯函数。聚合、pending 关联、命令快照这些有记忆的事,全在 runner 身上。mapper 只是 runner 在各边界点调用的「翻译函数库」。
runner 收到一条 update,把它丢给 mapper.toEvents` 拿到 0..N 条 Eyrie 事件,然后逐条 emit;途中顺手做两件有状态的事:刷新命令快照、把 assistant delta 喂进聚合 buffer。
private async handleSessionUpdate(notification: AcpSessionNotification): Promise<void> {
for (const event of this.mapper.toEvents(notification)) { // mapper:纯翻译
if (event.type === 'commands.updated') this.commandSnapshot = event.commands // runner:记忆
if (event.type === 'message.delta' && event.role === 'assistant') {
this.bufferAssistantMessage(event) // runner:聚合
}
await this.sink.emit(event)
}
}
mapper 这边纯到什么程度——一条 update 进来,按 sessionUpdate 字段分派,未知类型直接返回空数组(静默丢弃,不报错):
toEvents(notification: AcpSessionNotification): AgentProviderEvent[] {
const update = notification.update
switch (update.sessionUpdate) {
case 'agent_message_chunk': return this.textChunkEvent(notification, /* ... */, 'assistant')
case 'agent_thought_chunk': return this.textChunkEvent(notification, /* ... */, 'reasoning')
case 'tool_call': return [toolStartedEvent(/* ... */)]
case 'tool_call_update': return toolCallUpdateEvents(/* ... */) // 可能多条
case 'plan': return [planEvent(/* ... */)]
case 'usage_update': return [usageEvent(/* ... */)]
case 'available_commands_update': return [commandsEvent(/* ... */)]
default: return [] // 未知 update:静默丢弃
}
}
顺带一个复用而非新造的设计:agent 的「正文」和「思考过程」走同一个 message.delta 出口,只靠既有的 role 轴区分('assistant' vs 'reasoning')。非文本 chunk 直接丢空数组。
B.2chunk 聚合成 completed —— 逻辑在 runner
流式 delta 一片片来,最后要给前端一条完整的 message.completed。这个聚合不在 mapper。runner 每收到一条 assistant delta,就按 itemId(无 id 用空串兜底)累加进 activeAssistantMessages;等整轮正常结束才一次性 flush。
private bufferAssistantMessage(event: Extract<AgentProviderEvent, { type: 'message.delta' }>): void {
if (!this.activeAssistantMessages) return
const key = event.itemId ?? ''
const existing = this.activeAssistantMessages.get(key)
this.activeAssistantMessages.set(key, {
...(event.itemId ? { itemId: event.itemId } : {}),
text: `${existing?.text ?? ''}${event.text}`, // 同一 itemId 的文本拼起来
})
}
private async emitMessageCompletions(response: AcpPromptResponse): Promise<void> {
if (response.stopReason === 'cancelled') return // 被取消的轮次不补 completed
const messages = [...(this.activeAssistantMessages?.values() ?? [])]
for (const message of messages) {
await this.sink.emit({ type: 'message.completed',
...(message.itemId ? { itemId: message.itemId } : {}), text: message.text })
}
this.activeAssistantMessages?.clear()
}
两个可观察后果:reasoning(思考)的 delta 不进 buffer,所以不会产生思考的 completed;被 cancel 的轮次也不补 completed。buffer 的生命周期由 enterPromptTurn 开、leavePromptTurn(置 null)关,严格 per-turn。
B.3tool fan-out + 结构化 diff 拼装
这是 mapper 里少数有真实逻辑(非字段搬运)的地方。一条 tool_call_update 可能同时产出多条事件:状态是 completed/failed 就发一条 tool.completed,并且 content 数组里每个 diff 各发一条 diff.updated。mapper 不跨更新跟踪 tool 状态——每条 update 独立处理,靠事件里都带 toolCallId 让下游自己合。
// 一条 tool 更新既可能结束工具、又可能携带结构化 diff,所以一条 update 可 fan-out 成多条事件
function toolCallUpdateEvents(notification, update: AcpToolCallUpdate): AgentProviderEvent[] {
const events: AgentProviderEvent[] = []
if (update.status === 'completed' || update.status === 'failed') {
events.push({ type: 'tool.completed', toolCallId: update.toolCallId, status: update.status,
output: update.rawOutput ?? update.content ?? fallbackToolInput(update), raw: /* ... */ }) // 三级回退
}
for (const diff of structuredDiffs(update.content)) {
events.push({ type: 'diff.updated', unifiedDiff: toUnifiedDiff(diff), raw: /* ... */ })
}
return events
}
ACP 给的 diff 是结构化的 {path, oldText, newText}。mapper 把它整块替换式地拼成一段 unified diff 文本——固定同名 ---/+++ 头,旧文本每行前缀 -、新文本每行前缀 +。注意它不做逐行最小 diff:对大文件的小改动,产出的会是「整文件全删 + 整文件全加」。
function toUnifiedDiff(diff: AcpDiffToolCallContent): string {
const oldLines = textLines(diff.oldText ?? '') // oldText 缺省/null 当空串 → 0 行(纯新增)
const newLines = textLines(diff.newText)
return [
`--- ${diff.path}`,
`+++ ${diff.path}`,
`@@ -1,${oldLines.length} +1,${newLines.length} @@`,
...oldLines.map((line) => `-${line}`),
...newLines.map((line) => `+${line}`),
'',
].join('\n')
}
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 前端没收到完整消息(只有 delta 没 completed) | runner.ts:emitMessageCompletions(被 cancel 了?或聚合 buffer 为空) |
| 思考内容混进了正文 | event-mapper.ts:textChunkEvent 的 role 参数;bufferAssistantMessage 只收 assistant |
| diff 把整个文件标成全删全加 | toUnifiedDiff:本就是整块替换,不是 bug |
| 工具完成事件丢了 / 重复 | toolCallUpdateEvents:只有 completed/failed 才发 tool.completed;diff 独立计数 |
| 某类 update 完全没反应 | toEvents 的 default: return [](未知类型静默丢弃) |
8旅程 C:agent 反向索取(审批 + 输入)
前两条旅程是 daemon 调 agent。这条反过来:agent 主动调 daemon——要工具执行许可,或要用户填一份表单。这是 ACP 双向性的体现,也是 pending map 的用武之地。
json-rpc-connection.ts→ runner 存 pending
runner.ts→ mapper 造事件
event-mapper.ts→ 用户决策(Eyrie UI)→ runner resolve pending
runner.ts→ 回 ACP 响应
C.1审批 approval
agent 发来 session/request_permission(这是一个请求,要回响应)。runner 不能立刻回——得等用户拍板。所以它的做法是:新建一个 promise、把它的 resolve 存进 pendingPermissions、然后把这个未兑现的 promise 直接 return 给传输层。传输层会一直挂着不写响应帧,直到这个 promise 被 resolve。
private async handlePermissionRequest(
requestId: AcpJsonRpcId, params: AcpRequestPermissionParams,
): Promise<AcpRequestPermissionResponse> {
const event = this.mapper.toApprovalRequested(requestId, params) // mapper 造 approvalId + 事件体
const response = new Promise<AcpRequestPermissionResponse>((resolve) => {
this.pendingPermissions.set(event.approvalId, { resolve }) // 把 resolve 存起来
})
await this.sink.emit(event) // 通知 Eyrie 去问用户
return response // 这个 promise 悬着,传输层不写响应
}
关联键怎么来的?mapper 用 acp-permission- 前缀拼上 JSON-RPC id 的文本化,造出一个稳定的 approvalId,两端靠它对上。mapper 还把 ACP 的 option kind 推导成 Eyrie 的 effect(approve/deny),并把 reject_always 归一成 reject_once。
toApprovalRequested(requestId, params): Extract<AgentProviderEvent, { type: 'approval.requested' }> {
const providerRequestId = jsonRpcIdText(requestId)
return {
type: 'approval.requested',
approvalId: `acp-permission-${providerRequestId}`, // 稳定关联键
providerRequestId,
toolName: params.toolCall.title ?? params.toolCall.kind ?? 'ACP permission',
detail: params.toolCall,
options: params.options.map(toApprovalOption), // kind → effect 推导
raw: { requestId, params },
}
}
用户拍板后,上层调 respondToApproval(approvalId, response),runner 取出对应 pending、用 mapper.toPermissionSelected 翻成 ACP 的 {outcome:{outcome:'selected', optionId}}、resolve 它——这一刻传输层才把响应帧写回 agent,agent 的 request_permission 调用兑现。
C.2结构化输入 elicitation
结构同审批,但多了 schema 翻译。agent 发 elicitation/create(form 模式 + 一份 JSON-schema 子集),runner 同样存 pending——但存的时候要连 params 一起存,因为反向把用户答案强转回去时需要原 schema。
private async handleElicitationRequest(requestId, params): Promise<AcpCreateElicitationResponse> {
const event = this.mapper.toInputRequested(requestId, params)
const response = new Promise<AcpCreateElicitationResponse>((resolve) => {
this.pendingInputRequests.set(event.inputRequestId, { params, resolve }) // 连 params 一起存
})
await this.sink.emit(event)
return response
}
正向 mapper 把 schema 的每个 property 翻成一个 Eyrie 问题(title→header、description→prompt,array 类型置 multiple,枚举/oneOf/anyOf 的 const 变 options)。注意 toInputRequested 对非 form 模式或缺 schema 直接 throw(不是静默丢),所以遇到不支持的 elicitation 会硬失败。
反向把字符串答案按 property 的 type 强转回 number/integer/boolean/array——数字转不出来就回退原字符串:
function elicitationValue(property, values: string[]): string | number | boolean | string[] {
if (property?.type === 'array') return values
const value = values[0] ?? ''
return scalarElicitationValue[property?.type ?? '']?.(value) ?? value // 未知类型原样回字符串
}
const scalarElicitationValue: Record<string, (value: string) => string | number | boolean> = {
number: (value) => finiteNumberOrText(Number(value), value),
integer: (value) => finiteNumberOrText(Number.parseInt(value, 10), value),
boolean: (value) => value === 'true',
}
C.3中断 / 关闭时全部取消
两个 pending map 的存在意味着一个风险:用户还没拍板时如果中断或 agent 挂了,这些悬着的 promise 会让 agent 那端永久等待。所以 interrupt() 和关闭路径都会把 pending 全部 resolve 成「取消」。
async interrupt(): Promise<void> {
const activePrompt = this.activePromptCancellation
if (activePrompt) {
if (activePrompt.cancel()) this.cancelProviderPrompt(activePrompt.sessionId) // 赢下问答竞速 + 通知 agent
} else if (this.providerSessionId) {
this.cancelProviderPrompt(this.providerSessionId)
}
this.cancelPendingPermissions() // 挂起审批 → {outcome:'cancelled'}
this.cancelPendingInputRequests() // 挂起输入 → {action:'cancel'}
}
private cancelPendingPermissions(): void {
const cancelled = this.mapper.toPermissionCancelled()
for (const pending of this.pendingPermissions.values()) pending.resolve(cancelled)
this.pendingPermissions.clear()
}
同样的 cancelPendingPermissions / cancelPendingInputRequests 也在两条关闭路径里被调:显式 dispose()(走 Closing → Closed)和 transport 自己挂掉(markTransportClosed,直接 Closed)。dispose 用一个 disposePromise latch 做幂等——调两次只真正关一次。
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| agent 卡在等审批 / 等输入,永不继续 | runner.ts:pendingPermissions/pendingInputRequests 里的 promise 没被 resolve |
| 「Unknown ACP approval request」 | respondToApproval:approvalId 对不上(前缀 + RPC id 文本化) |
| 用户填的数字变成了字符串 | elicitationValue / finiteNumberOrText:转不出有限数就回退原文 |
| 非 form 的 elicitation 直接报错 | toInputRequested:mode≠form 或缺 requestedSchema 时 throw |
| 中断后 agent 端没收到取消 | interrupt → cancelProviderPrompt(session/cancel 通知,失败被吞) |
9心智模型补丁
读完后,关于「Eyrie 怎么接 agent」的认知需要打这几个补丁:
acp,且它接的是一类标准协议 agent,不是某个具体 agent。
agent/registry.ts 的 PROVIDER_MODULES 数组,加一个 provider 就加一行。runner.ts。
session/request_permission / elicitation/create 请求,daemon 用 pending map 把这些悬挂的 ACP 响应和 Eyrie 的用户决策桥起来。
session/prompt 是四方竞速:agent 响应 / 本地中断 / 超时 / transport 关闭,谁先到谁定结局。
mapper.toRunCompleted;失败(后三者)走 startTurn 手写的 failed 事件。initialize 握手实时拿;而 provider.toAvailability()/getCapabilities() 是从 provider row 缓存的 capabilitiesJson 静态读的。两者可能漂移。
10新词表
| 协议层 | |
|---|---|
ACP v1 | Agent Client Protocol,一套 client(这里是 Eyrie daemon)跟 coding agent 对话的标准协议,跑在 stdio 上的 JSON-RPC。本 PR 主题。 |
newline-delimited JSON-RPC | 一行一个 JSON 对象、用 \n 切帧的传输约定。底座的 acceptChunk 实现它。 |
session/update | agent 在一次问答里流式发回增量的单向通知帧(消息片段、工具、plan、usage 等)。旅程 B 的入口。 |
elicitation | ACP 里「agent 反过来向用户要结构化表单输入」的机制。Eyrie 只支持 form 模式,映射成既有的 input.requested 事件。 |
stopReason | session/prompt 响应里 agent 报的结束原因(end_turn / cancelled / refusal / max_tokens / max_turn_requests),runner 据此映射 run 状态。 |
| 实现层 | |
prompt-expansion | ACP 的 slash 命令不走独立 RPC,而是把 /name args 当成一条普通 prompt 文本发给 agent(runControl 走这条)。 |
四方竞速 | 本报告对 requestPrompt 里两层 Promise.race 的叫法:agent 响应 / 中断 / 超时 / transport 关闭。 |
AgentPersistenceHandle | Eyrie 通用的「provider 会话恢复指针」三态联合(resume / load / none)。ACP 把它映射到 session/resume vs session/load。 |
canonicalize (path) | 把路径解析成宿主机规范长名(解 symlink + Windows 8.3 短名别名),用 fs.realpath.native。跨平台顺带项。 |
11测试与风险地图
测试占比高(49%),主干行为兜底扎实,但有几处分支无测试。纯事实陈述,不评价。
- 主干问答:initialize + session/new + 多轮 prompt,连
clientCapabilities/clientInfo的 wire 形状都精确断言(acp-runner.test.ts) - 会话三路径:resume / load / resume 降级到 load 全覆盖
- 并发守卫:Running 时第二个 startTurn 抛、
session/prompt仍只发一次 - 中断 / 超时 / transport 关闭三条失败收尾
- 审批 + elicitation 往返(含 pending 取消)
- 真实 JSON-RPC 编解码:
acp-runner-integration.test.ts用 PassThrough 接真实连接跑完整 turn,断言事件序列 - 结构化 diff → unified diff 精确字符串(
acp-event-mapper.test.ts) - 底座:请求/响应匹配、通知串行序、handler 抛错连锁失败、畸形 JSON
- 🟠 整段 elicitation 双向逻辑零测试:
toInputRequested的 schema→问题、toElicitationResponse的 accept/decline/cancel、各类型强转——mapper 测试一条没覆盖 - 🟡
toRunCompleted的max_tokens/max_turn_requests两个 stopReason 分支 - 🟡
mcpTransport的「未知 string 传输」「非 string type」两条抛错分支 - 🟡
provider.ts的toAvailability在 transport≠stdio 时的available:false分支 - ⚪ 底座
spawn()真实子进程路径(env 合并、childExitreason 映射、stderr 排空) - ⚪ 分帧边界(半行跨 chunk、一 chunk 多行、空行跳过)
toInputRequested 会 throw,这条异常如何被传输层收尾(agent 的请求会不会挂住)值得对真实 agent 验一次。
12验收提示
别把下面这些当缺陷——它们是有意的预留或顺带改动:
AcpAgentRegistry(registry.ts)目前没接进 daemon 装配。全仓搜索它只被自己的 barrel(index.ts)和测试引用;daemon 真正走的是DbAgentRegistry+PROVIDER_MODULES那条路(module.ts已把 ACP 接进去)。这是一套并存的独立 registry 实现,不是死代码报错,但也不是当前生效路径——值得问作者它的去向(是早期实现遗留,还是留给某个未接入的装配点)。- 能力有两条读取路径,可能漂移。live runner 从
initialize握手实时拿;provider.toAvailability()从 row 的capabilitiesJson静态读。row 里这份 JSON 何时被写回最近一次握手结果,不在本 PR 范围——存在「静态 availability 与实际能力短暂不一致」的窗口。 - ~345 行跨平台/CI 改动与 ACP 功能无关。5 个
scripts/check-*.mjs把import.meta.url === \`file://${argv[1]}\`这个「是否入口脚本」的判定换成pathToFileURL().href(Windows 文件 URL 是file:///C:/...,老写法在 Windows 上判错);migrations.test.ts加resolveDrizzleKitBin适配 Bun 工作区 bin 布局;terminal-ws.test.ts加FakePty断言 resize;path-canonicalization.ts把realpath换成realpath.native(多解 Windows 8.3 别名)。这些是 Windows/Bun 可移植性加固,搭车进来的。 acp-real-server-smoke.test.ts默认 skip。它只在 envEYRIE_ACP_REAL_SMOKE_COMMAND存在时跑真实外部 ACP server,CI 不强制——不是测试缺失。- provider 层
getControlCommands()返回空数组是对的。ACP 的 slash 命令是会话建立后才由 agent 广播的,所以无 live runner 时(provider 层)命令为空,要等commands.updated刷新commandSnapshot。
13覆盖声明
本报告对 main...phase2-acp 全量 3940 改动行做了覆盖,无抽样:
- 主力亲自精读全文:
runner.ts(641)、event-mapper.ts(484)、json-rpc-connection.ts(304)、protocol.ts(266)、provider.ts(143)、registry.ts、module.ts,以及既有契约types.ts的事件 union——报告中每段代码均出自亲自 Read 过的文件、亲手裁剪。 - diff 全覆盖:
agent/registry.ts接线、services/*+testing/index.ts路径收敛、5 个scripts/check-*.mjs、migrations.test.ts/terminal-ws.test.ts/cli serve.test.ts等顺带测试,均逐一读过 diff 并归类为跨平台/CI 加固。 - 三个子系统先由 subagent 并行精读建图(传输层 / runner 接线 / event-mapper),其结论仅作定位地图;所有入报告的代码由主力二次精读对应文件后裁剪,未直接引用转述。
- 测试文件以行为清单方式覆盖(第 11 节),未逐行复述断言。
一处由精读纠正的关键事实:AcpEventMapper 是完全无状态的纯翻译类,聚合 / pending / 命令快照等状态全在 runner.ts——本报告以此口径为准。