#65 ACP v1 agent provider:给 daemon 接上第三套 agent 协议

figuretu/eyrie · main...phase2-acp · 2026-06-14 · 自包含,读完即弃

20 commits
33 文件
+3883 / −57
49% 是测试代码
作者 LettuceLeaves

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自 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/ 源码
1887 行 · 8 文件
tests/acp-*
1708 行 · 6 文件
跨平台/CI 顺带
~345 行 · 19 文件
子系统设计重心(要细读)可放心略过
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.tsacp-provider-registry.test.tsacp-real-server-smoke.test.ts(默认 skip)
跨平台/CI 顺带 5 个 scripts/check-*.mjs(Windows 入口判定)、services/path-canonicalization.tsmigrations.test.ts(Bun bin 解析)、terminal-ws.test.ts(FakePty 断言)

称重诚实结论:这几乎全是设计承载代码。新模块没有生成物、没有 lockfile、没有大段机械搬运;protocol.ts 虽是纯类型,但它承载的是 ACP 协议契约,不是无脑样板。测试占比 49%,与源码几乎 1:1。

3架构一图流

架构面变化是「多了一条并列的进程通道」。claude/codex 各有自己的握手与翻译;ACP 新增了一条走标准 JSON-RPC 的通道,但和前两者共享同一个出口(AgentEventSink)和同一套上层契约。

以前 · 两套 provider

daemon 编排
AgentRunner 契约
claude runner
CLI 子进程 + claude 控制协议
Claude CLI
daemon 编排
AgentRunner 契约
codex runner
app-server JSON-RPC
Codex

现在 · 第三套并列接入

daemon 编排
同一 AgentRunner 契约
claude / codex
daemon 编排
同一契约
AcpRunner
stdio · newline-delimited JSON-RPC
ACP agent

注意 ACP 这条通道是双向的(图中 ◀▶):不只是 daemon 调 agent,agent 也会反过来调 daemon——要工具执行许可(approval)、要用户填表单(elicitation)。claude/codex 也有审批,但 ACP 把它做成了协议级的「agent 发请求、client 回响应」往返,这是后面旅程 C 的主题。

新增一个 provider 的标准步骤(本 PR 的范本):① 在 agent/<kind>/ 下写 provider.ts(实现 AgentProvider,把一行 provider row 变成 runner)+ runner.ts(实现 AgentRunner);② 写一个 module.ts 导出 ProviderModule{ kind, factory });③ 在 agent/registry.tsPROVIDER_MODULES 数组里加上它——就这一行,daemon 的 DbAgentRegistry 会自动 detect/lookup 全部已注册 provider。

4数据与状态形状

先把后面旅程要用到的词汇预载一遍——只看形状不讲行为。三组:ACP 进来的线协议、Eyrie 出去的事件、runner 内部的状态。

① ACP 线协议:一个可判别联合 + 开放逃生口

agent 在一次问答里流式发回的所有增量,都是 session/update 通知里的一个 AcpSessionUpdate。它是按 sessionUpdate 字段判别的联合,末尾挂一支 AcpRecord(裸 record)兜底未知类型——这样 daemon 能跟比它实现得更新的 agent 通信而不崩。

agent/acp/protocol.ts真实代码(节选)
// agent 在 prompt turn 内流式发回的增量
export type AcpSessionUpdate =
  | AcpContentChunkUpdate      // 消息/思考文本片段
  | AcpToolCall                // 工具调用开始
  | AcpToolCallUpdate          // 工具调用进展(可带结构化 diff)
  | AcpPlanUpdate
  | AcpUsageUpdate
  | AcpAvailableCommandsUpdate
  | AcpRecord                  // ← 逃生口:不认识的 update 当裸对象收着,不报错

② Eyrie 事件:本 PR 一行没改的既有契约

关键事实:mapper 的所有产出(message.deltatool.completedapproval.requested……)都是 types.ts 里早就存在的事件,本 PR 没有为 ACP 发明任何新事件类型。ACP 适配的工作是「把自己的形状对齐到既有事件」,不是「让既有契约迁就自己」。

agent/types.ts (本 PR 未改动)真实代码(节选)
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、能力缓存、挂起的审批/输入、聚合中的助手消息、状态机当前态。后面会反复回看它。

agent/acp/runner.ts真实代码(节选)
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 自己挂了)。

agent/acp/runner.ts真实代码(节选)
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 也严格按到达顺序串行处理,不会交错。

agent/acp/json-rpc-connection.ts真实代码(节选)
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)。

agent/acp/json-rpc-connection.ts真实代码(节选)
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() 是其中一条:

agent/acp/json-rpc-connection.ts真实代码(节选)
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 正常 endcompleted(或本端正在关时 killed);stdout errorcrashed;JSON 解析失败 / handler 抛错 → failConnectioncrashed;真实子进程退出(spawn 路径)则以进程 exit code 为准(0=completed、非0=crashed、有 signal=killed)。

排查路标 · 底座
症状从哪下手
agent 发的 update 顺序乱了 / 交错执行json-rpc-connection.tsacceptChunklineQueue
某个请求永远不返回(卡死)pending map:响应 id 没匹配上,或连接已 failed 但没触发 rejectPending
agent 崩了但上层不知道connect()end/error 回调 + childExit 的 reason 映射
收到畸形 JSON 行acceptLineJSON.parse catch → failConnection(整条连接 crashed)

6旅程 A:一次问答(prompt turn)

这是脊梁旅程。用户发一句话,到 Eyrie 收到 run.completed,中间穿过 runner 的懒初始化、建会话、发 prompt、流式收结果。走通它,ACP provider 的主干就清楚了。

全景 · 涉及 3 个文件
入口 startTurn
runner.ts
initialize
runner.ts
session/new|resume|load
runner.ts
session/prompt
json-rpc-connection.ts
流式 update(见旅程 B) run.completed
event-mapper.ts

先看入口的整体骨架。startTurnenterPromptTurn() / finally leavePromptTurn() 把整轮夹起来(这就是并发守卫,下面 A.3 讲),中间分三步:确保会话、断言能力、发 prompt 并收尾。

agent/acp/runner.ts真实代码(节选)
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 模式的结构化输入。

agent/acp/runner.ts真实代码(节选)
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,否则直接抛。

agent/acp/runner.ts真实代码(节选)
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 关闭」和「超时」两条失败路径。

agent/acp/runner.ts真实代码(节选)
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。

谁赢了竞速
agent 正常响应
本地中断闩(interrupt 调到 cancel)
超时(默认 10 分钟)
transport 关闭(agent 崩了)
这轮的结局
run.completed 按 stopReason 映射 completed/cancelled/failed
run.completed cancelled + 另发 session/cancel 通知
best-effort session/cancel + run.completed failed
run.completed failed,state 已被推到 Closed

并发守卫(commit「add ACP runner state guard」):一个 runner 同时只允许一个问答。第二个 startTurn 进来直接抛,不排队。这就是 enterPromptTurn 干的事——Running 态再进就抛「already running」,Closing/Closed 态进则抛对应状态名。

agent/acp/runner.ts真实代码(节选)
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.tsensureInitializedinitializeinitialize 请求本身无超时兜底)
问答报「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。

agent/acp/runner.ts真实代码(节选)
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 字段分派,未知类型直接返回空数组(静默丢弃,不报错):

agent/acp/event-mapper.ts真实代码(节选)
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。

agent/acp/runner.ts真实代码(节选)
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 让下游自己合。

agent/acp/event-mapper.ts真实代码(节选)
// 一条 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:对大文件的小改动,产出的会是「整文件全删 + 整文件全加」。

agent/acp/event-mapper.ts真实代码(节选)
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.tsemitMessageCompletions(被 cancel 了?或聚合 buffer 为空)
思考内容混进了正文event-mapper.tstextChunkEvent 的 role 参数;bufferAssistantMessage 只收 assistant
diff 把整个文件标成全删全加toUnifiedDiff:本就是整块替换,不是 bug
工具完成事件丢了 / 重复toolCallUpdateEvents:只有 completed/failed 才发 tool.completed;diff 独立计数
某类 update 完全没反应toEventsdefault: return [](未知类型静默丢弃)

8旅程 C:agent 反向索取(审批 + 输入)

前两条旅程是 daemon 调 agent。这条反过来:agent 主动调 daemon——要工具执行许可,或要用户填一份表单。这是 ACP 双向性的体现,也是 pending map 的用武之地。

全景 · 涉及 2 个文件
agent 发请求
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。

agent/acp/runner.ts真实代码(节选)
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

agent/acp/event-mapper.ts真实代码(节选)
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。

agent/acp/runner.ts真实代码(节选)
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→headerdescription→prompt,array 类型置 multiple,枚举/oneOf/anyOf 的 const 变 options)。注意 toInputRequested非 form 模式或缺 schema 直接 throw(不是静默丢),所以遇到不支持的 elicitation 会硬失败。

反向把字符串答案按 property 的 type 强转回 number/integer/boolean/array——数字转不出来就回退原字符串:

agent/acp/event-mapper.ts真实代码(节选)
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 成「取消」。

agent/acp/runner.ts真实代码(节选)
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.tspendingPermissions/pendingInputRequests 里的 promise 没被 resolve
「Unknown ACP approval request」respondToApprovalapprovalId 对不上(前缀 + RPC id 文本化)
用户填的数字变成了字符串elicitationValue / finiteNumberOrText:转不出有限数就回退原文
非 form 的 elicitation 直接报错toInputRequested:mode≠form 或缺 requestedSchema 时 throw
中断后 agent 端没收到取消interruptcancelProviderPromptsession/cancel 通知,失败被吞)

9心智模型补丁

读完后,关于「Eyrie 怎么接 agent」的认知需要打这几个补丁:

daemon 只能接 claude 和 codex 两种 agent,各一套手写适配。 多了第三套 acp,且它接的是一类标准协议 agent,不是某个具体 agent。
注册入口在 agent/registry.tsPROVIDER_MODULES 数组,加一个 provider 就加一行。
「把 ACP 翻成 Eyrie 事件」的逻辑应该在那个叫 event-mapper 的东西里。 mapper 是纯无状态翻译;聚合、pending 关联、命令快照、状态机全在 runner.ts
查「为什么没收到 completed / 审批为什么卡住」要去 runner,不是 mapper。
agent 通信就是 daemon 发指令、agent 回结果的单向流。 ACP 是双向的:agent 会反过来发 session/request_permission / elicitation/create 请求,daemon 用 pending map 把这些悬挂的 ACP 响应和 Eyrie 的用户决策桥起来。
一个问答发出去就等它返回。 一次 session/prompt 是四方竞速:agent 响应 / 本地中断 / 超时 / transport 关闭,谁先到谁定结局。
成功经 mapper.toRunCompleted;失败(后三者)走 startTurn 手写的 failed 事件。
provider 的能力(capabilities)就是它握手时报的那一份。 有两条路:live runner 从 initialize 握手实时拿;而 provider.toAvailability()/getCapabilities() 是从 provider row 缓存的 capabilitiesJson 静态读的。两者可能漂移。

10新词表

协议层
ACP v1Agent Client Protocol,一套 client(这里是 Eyrie daemon)跟 coding agent 对话的标准协议,跑在 stdio 上的 JSON-RPC。本 PR 主题。
newline-delimited JSON-RPC一行一个 JSON 对象、用 \n 切帧的传输约定。底座的 acceptChunk 实现它。
session/updateagent 在一次问答里流式发回增量的单向通知帧(消息片段、工具、plan、usage 等)。旅程 B 的入口。
elicitationACP 里「agent 反过来向用户要结构化表单输入」的机制。Eyrie 只支持 form 模式,映射成既有的 input.requested 事件。
stopReasonsession/prompt 响应里 agent 报的结束原因(end_turn / cancelled / refusal / max_tokens / max_turn_requests),runner 据此映射 run 状态。
实现层
prompt-expansionACP 的 slash 命令不走独立 RPC,而是把 /name args 当成一条普通 prompt 文本发给 agent(runControl 走这条)。
四方竞速本报告对 requestPrompt 里两层 Promise.race 的叫法:agent 响应 / 中断 / 超时 / transport 关闭。
AgentPersistenceHandleEyrie 通用的「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 测试一条没覆盖
  • 🟡 toRunCompletedmax_tokens/max_turn_requests 两个 stopReason 分支
  • 🟡 mcpTransport 的「未知 string 传输」「非 string type」两条抛错分支
  • 🟡 provider.tstoAvailability 在 transport≠stdio 时的 available:false 分支
  • ⚪ 底座 spawn() 真实子进程路径(env 合并、childExit reason 映射、stderr 排空)
  • ⚪ 分帧边界(半行跨 chunk、一 chunk 多行、空行跳过)
合并前值得留意(非阻断,供 owner 判断):① elicitation 双向逻辑分支密、强转假设多(oneOf/anyOf/items.enum),却全无测试样本——真实 ACP agent 发来的 schema 是否落在这些假设内没有验证;② 若 elicitation 进来是非 form 模式,toInputRequested 会 throw,这条异常如何被传输层收尾(agent 的请求会不会挂住)值得对真实 agent 验一次。

12验收提示

别把下面这些当缺陷——它们是有意的预留或顺带改动:

13覆盖声明

本报告对 main...phase2-acp 全量 3940 改动行做了覆盖,无抽样:

一处由精读纠正的关键事实:AcpEventMapper完全无状态的纯翻译类,聚合 / pending / 命令快照等状态全在 runner.ts——本报告以此口径为准。