index.ts 60 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197
  1. /**
  2. * The Agent Client Protocol (ACP) bridge: a client-driver / UI plugin that
  3. * exposes the harness agent as an ACP server over JSON-RPC stdio, so editors
  4. * (Zed and other ACP clients) can drive it. The structured analogue of the
  5. * readline `stdio-chat` plugin.
  6. *
  7. * This is NOT a loop change and NOT an ADR-0009 capability seam: it consumes
  8. * the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory,
  9. * and `dsh-session-persistence` (for `session/load`). It maps:
  10. *
  11. * - `initialize` → protocol-version negotiation, text-only capabilities
  12. * - `session/new` → `ctx.agents.create({ sessionId, meta:{cwd} })`
  13. * - `session/load` → `ctx.agents.resume(...)` then replay the event log
  14. * - `session/prompt` → `agent.send()`, settle on the owning turn's end (a turn
  15. * that ends in `error` rejects the RPC)
  16. * - `session/cancel` → `agent.cancel()` (the queue-aware cancel: aborts a
  17. * running step, clears queued + steering work, and drops a
  18. * turn about to start) + settle the in-flight prompt
  19. *
  20. * Multi-session (RFC 011): N concurrent sessions per connection, each mapped to
  21. * its own `ReactLoopAgent`. Sessions are keyed by id in `sessions` (forward) with an
  22. * `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every
  23. * `session/event` and `agent/*` event is routed strictly to its owning session
  24. * record, so two sessions streaming at once never interleave their
  25. * `session/update` notifications. The `tools/pre-execute` permission gate is
  26. * deferred — see the TODO(rfc010-permission-gate) note below.
  27. *
  28. * stdout is the protocol: this plugin must run in an example that loads NO
  29. * stdout logger (the console logger writes to stdout and would corrupt the
  30. * JSON-RPC frames). The guarantee is config-only — see the package README and
  31. * RFC 010 § Risks.
  32. *
  33. * @module @deepseek-ai/dsh-acp
  34. */
  35. import type { Context } from 'cordis'
  36. import { Readable, Writable } from 'node:stream'
  37. import { randomUUID } from 'node:crypto'
  38. import { isAbsolute, relative as relativePath, resolve as resolvePath, sep as pathSep } from 'node:path'
  39. import Schema from 'schemastery'
  40. import {
  41. AgentSideConnection,
  42. ndJsonStream,
  43. PROTOCOL_VERSION,
  44. RequestError,
  45. type Agent as AcpAgent,
  46. type AuthenticateRequest,
  47. type CancelNotification,
  48. type ContentBlock as AcpContentBlock,
  49. type InitializeRequest,
  50. type InitializeResponse,
  51. type LoadSessionRequest,
  52. type LoadSessionResponse,
  53. type NewSessionRequest,
  54. type NewSessionResponse,
  55. type Plan,
  56. type PlanEntry,
  57. type PromptRequest,
  58. type PromptResponse,
  59. type SessionNotification,
  60. type Stream,
  61. type StopReason,
  62. } from '@agentclientprotocol/sdk'
  63. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  64. import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
  65. import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
  66. import { AgentId } from '@deepseek-ai/dsh-agent'
  67. import { SessionId } from '@deepseek-ai/dsh-session'
  68. import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
  69. import type { ToolCallKind, ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
  70. // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
  71. // Context (the bridge injects it and reads `list()` for load cwd validation).
  72. import type {} from '@deepseek-ai/dsh-session-persistence'
  73. import {
  74. acpPromptToText,
  75. harnessBlockToAcpContent,
  76. promptHasUnsupportedContent,
  77. turnEndToStopReason,
  78. } from './codec.ts'
  79. export const name = 'acp'
  80. // The bridge programs against the interface packages only (architecture rule:
  81. // plugins never depend on dsh-agent-loop). `sessionPersistence` is required
  82. // because `initialize` advertises `loadSession: true`. `tools` lets a tool own
  83. // how its calls render (`presentCall`/`presentResult`); the bridge looks up the
  84. // definition by name and falls back to a generic presentation when absent.
  85. export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools']
  86. /**
  87. * Build an ACP "invalid params" error whose human detail rides in the message.
  88. * `RequestError.invalidParams(data, additionalMessage)` keeps the standard
  89. * "Invalid params" message and appends `additionalMessage`, so we pass the
  90. * detail as `additionalMessage` (and no structured `data`).
  91. */
  92. function invalidParams(detail: string): RequestError {
  93. return RequestError.invalidParams(undefined, detail)
  94. }
  95. /**
  96. * Build an ACP "internal error" whose human detail rides in the message. Used
  97. * to reject a `session/prompt` whose turn ended in failure: a plain `Error`
  98. * thrown from a method handler is flattened to a generic "Internal error" on
  99. * the wire, so we wrap the detail in the SDK's `RequestError.internalError`
  100. * (which appends `additionalMessage`) to surface *why* the turn failed.
  101. */
  102. function internalError(detail: string): RequestError {
  103. return RequestError.internalError(undefined, detail)
  104. }
  105. function sameWorkspaceCwd(left: string, right: string): boolean {
  106. return resolvePath(left) === resolvePath(right)
  107. }
  108. /** Plugin config: the agent template ACP sessions are created from. */
  109. export interface AcpConfig {
  110. /** Model name for created agents (must have a registered adapter). */
  111. model?: string
  112. /** Per-agent system prompt. */
  113. systemPrompt?: string
  114. /** Agent/server name reported to the client in `initialize`. */
  115. agentName?: string
  116. /** Agent/server version reported to the client in `initialize`. */
  117. agentVersion?: string
  118. /**
  119. * Transport stream override. Production omits this (the plugin wires
  120. * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an
  121. * in-memory `Stream` (e.g. an `ndJsonStream` over a `Duplex` pair) to drive
  122. * the bridge without a subprocess. Not part of the schemastery `Config` —
  123. * it is a runtime-only seam, never set from a `cordis.yml`.
  124. */
  125. stream?: Stream
  126. }
  127. export const Config: Schema<AcpConfig> = Schema.object({
  128. model: Schema.string(),
  129. systemPrompt: Schema.string(),
  130. agentName: Schema.string().default('deepseek-harness-acp'),
  131. agentVersion: Schema.string().default('0.0.1'),
  132. })
  133. /**
  134. * Per-session bridge state. One per live ACP session; held in the `sessions`
  135. * map keyed by id (RFC 011 multi-session).
  136. */
  137. interface SessionRecord {
  138. sessionId: SessionId
  139. agent: Agent
  140. /**
  141. * The owned-agent disposer (from the {@link AgentHandle} the factory returned).
  142. * Teardown calls it to unregister this ONE agent, stop its loop, await
  143. * quiescence, and remove its session — instead of leaving it for the bridge
  144. * fiber to reclaim.
  145. */
  146. dispose: () => Promise<void>
  147. /**
  148. * Resolves tool-owned presentation for THIS session's tool calls and remembers
  149. * each in-flight call's `(name, args)` so the matching `tool/result` can find
  150. * its tool. Per-session so two concurrent sessions never cross their in-flight
  151. * tool state.
  152. */
  153. presenter: ToolPresenter
  154. /**
  155. * Whether THIS session renders shell tools as terminal cards — snapshotted
  156. * from the client's `_meta.terminal_output` capability at session creation
  157. * (`session/new`/`session/load`), NOT re-read live. A capability snapshot per
  158. * session means the `tool_call` (which registers the terminal) and the matching
  159. * `tool_call_update` (which streams its output) ALWAYS agree, even if a later
  160. * `initialize` mutates the connection-level capability between them — otherwise
  161. * a re-`initialize` mid-call could orphan a `terminal_output` (call non-terminal,
  162. * result terminal) or clobber the card (call terminal, result non-terminal).
  163. */
  164. terminalEnabled: boolean
  165. /**
  166. * The in-flight `session/prompt`, or `undefined` when none is pending. A
  167. * prompt resolves with a {@link StopReason} or rejects with an Error (a
  168. * turn that ended in failure). Settled exactly once via {@link settlePrompt}.
  169. *
  170. * `turn` is the loop turn number this prompt owns, captured from the log's
  171. * `turn/start` after `send()`. Until then it is `undefined` (the turn has not
  172. * begun). Only a `turn/end` whose turn number equals `turn` settles the prompt
  173. * — so a *previous* prompt's late `turn/end` (e.g. an aborted turn whose end
  174. * arrives after the next prompt is already installed) can never settle the
  175. * wrong prompt. A direct cancel/dispose settle clears the whole in-flight slot,
  176. * so a later stale `turn/end` finds no pending prompt.
  177. *
  178. * `logWatermark` is the session log length at the moment the prompt was
  179. * installed (before `send()`). The settle-from-log fallback uses it to infer
  180. * the owning `turn/start` from the canonical log even when the live
  181. * `session/event` capture was starved (a peer listener that throws on
  182. * `turn/start` — see `settleFromLog`): the prompt owns the FIRST `turn/start`
  183. * appended at or after this watermark.
  184. */
  185. inflight: {
  186. resolve: (reason: StopReason) => void
  187. reject: (error: Error) => void
  188. turn: number | undefined
  189. logWatermark: number
  190. } | undefined
  191. }
  192. /**
  193. * Drive the in-flight prompt's settle from the harness event stream. The bridge
  194. * settles off the durable log: the `turn/end` session event on the
  195. * `session/event` feed for the prompt's own turn, with the agent
  196. * erroring/settling to idle as a fallback (AGENTS.md "honor cross-seam contracts
  197. * on BOTH sides") for the case where a throwing peer `session/event` listener
  198. * starved the bridge's listener before it saw the boundary. The first of these
  199. * to fire settles the prompt; `settle` is then cleared so the others are no-ops
  200. * (settle-exactly-once).
  201. */
  202. export function apply(ctx: Context, config: AcpConfig): void {
  203. // TODO(double-default): these literals duplicate the Config schema defaults
  204. // (`agentName`/`agentVersion` `.default(...)` above). The Loader applies the
  205. // schema before apply() runs, so the `??` only fires for direct-apply unit
  206. // tests. Pick one home for the default to avoid drift.
  207. const agentName = config.agentName ?? 'deepseek-harness-acp'
  208. const agentVersion = config.agentVersion ?? '0.0.1'
  209. // Capture the injected services NOW, during apply(), while we are inside this
  210. // plugin's fiber (where `inject` grants access). The ACP method handlers run
  211. // LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is
  212. // NOT this fiber's injection scope — so reading `ctx.agents` / `ctx.logger` /
  213. // `ctx.sessionPersistence` lazily inside a handler throws "cannot get property
  214. // … without inject". Resolving the references here and closing over them keeps
  215. // the handlers working regardless of which fiber later invokes them.
  216. const agents = ctx.agents
  217. const sessionPersistence = ctx.sessionPersistence
  218. const logger = ctx.logger
  219. const tools = ctx.tools
  220. // A new ToolPresenter per session (and a throwaway per load replay), each given
  221. // this warn sink so a throwing tool presenter is logged, not propagated.
  222. const makePresenter = (): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) })
  223. // Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId
  224. // reverse map so `agent/*` events (which carry only the Agent) demux in O(1).
  225. // The two stay in lockstep: a record is added to `sessions` and the agent to
  226. // `bySession` together, and removed together.
  227. const sessions = new Map<SessionId, SessionRecord>()
  228. const bySession = new WeakMap<Agent, SessionId>()
  229. // Session ids whose `session/load` is mid-`resume()` (the slot is reserved
  230. // before the async resume so a pipelined load/new for the SAME id can't create
  231. // two agents). Distinct ids load concurrently; a given id loads once at a time.
  232. const loadingIds = new Set<SessionId>()
  233. // Set once the bridge has torn down (disposal or client disconnect). An async
  234. // `session/load` mid-`resume()` when teardown ran must observe this after its
  235. // await and NOT install a record (which would resurrect a live agent/listeners
  236. // after the bridge closed). Checked after every load await.
  237. let closed = false
  238. // Whether the client advertised the Zed `_meta.terminal_output` capability in
  239. // `initialize`. When true, a tool's terminal presentation is rendered as a
  240. // terminal card (content + `_meta.terminal_*`); when false, the bridge uses
  241. // the tool's text fallback. Set once in `initialize`, read on every tool event.
  242. let terminalOutputCap = false
  243. // Assigned at the bottom, before any agent event can fire (a session only
  244. // exists after `newSession`, which the client calls after construction), so
  245. // `notify` never observes it unset — no undefined guard needed.
  246. let conn: AgentSideConnection
  247. /**
  248. * Reject any RPC after the bridge has torn down. The `AgentSideConnection`
  249. * receive loop can outlive the plugin fiber — under an ACP-only HMR reload the
  250. * `agents`/`agent-loop` services stay up while the bridge's `ctx.on` listeners
  251. * and disposer are gone — so a late `session/new`/`load`/`prompt` could create
  252. * or drive an agent the bridge can no longer stream or settle. Every
  253. * state-affecting handler calls this first. (`initialize`/`authenticate` are
  254. * pure/stateless and may answer harmlessly.)
  255. */
  256. const assertOpen = (): void => {
  257. if (closed) throw internalError('the ACP bridge has been disposed')
  258. }
  259. /** Resolve the live record for a sessionId, or throw an ACP error. */
  260. const requireSession = (sessionId: SessionId): SessionRecord => {
  261. const rec = sessions.get(sessionId)
  262. if (rec === undefined) {
  263. throw invalidParams(`unknown session: ${sessionId}`)
  264. }
  265. return rec
  266. }
  267. /** Push a `session/update` notification, swallowing post-close rejections. */
  268. const notify = (notification: SessionNotification): void => {
  269. // sessionUpdate returns a promise; a closed connection rejects it. The
  270. // update is best-effort UI feed, never load-bearing for correctness, so a
  271. // throwing/rejecting send must not break the turn (the chunk is emitted
  272. // inside the model step — see AGENTS.md "contain callback exceptions").
  273. /* v8 ignore next 3 -- the rejection only fires on a stdout/connection write
  274. failure (closed pipe), which the in-memory test transport never induces;
  275. the swallow is a defensive best-effort guard like the loop's emit traps */
  276. void Promise.resolve(conn.sessionUpdate(notification)).catch((error: unknown) => {
  277. logger.warn(`acp: session/update failed: ${String(error)}`)
  278. })
  279. }
  280. /** Settle the in-flight prompt with a stop reason, exactly once (no-op if none pending). */
  281. const settlePrompt = (rec: SessionRecord, reason: StopReason): void => {
  282. const inflight = rec.inflight
  283. if (inflight === undefined) return
  284. rec.inflight = undefined
  285. inflight.resolve(reason)
  286. }
  287. /** Apply the single ACP prompt-settlement mapping for a completed turn. */
  288. const settleFromTurnEnd = (
  289. inflight: NonNullable<SessionRecord['inflight']>,
  290. reason: TurnEndReason,
  291. ): void => {
  292. if (reason.kind === 'error') {
  293. inflight.reject(internalError(`turn failed: ${reason.message}`))
  294. } else {
  295. inflight.resolve(turnEndToStopReason(reason))
  296. }
  297. }
  298. // --- Stream the harness event taxonomy to ACP session/update --------------
  299. // All content streaming AND the prompt settle flow through `session/event`,
  300. // the canonical log: every assistant/chunk and tool/call/result is logged, so
  301. // translating from the log makes live streaming and `session/load` replay
  302. // share the identical path (streamSessionEventUpdate). Both the owning-turn
  303. // capture and the settle key off the log's own `turn/start`/`turn/end` — the
  304. // durable boundary events (there is no agent/* turn mirror). `closeTurn`
  305. // appends `turn/end` to the log unconditionally, and `turn/start` is appended
  306. // before any step runs, so within this one listener we always see the
  307. // prompt's turn-start (tag `inflight.turn`) then its turn-end (settle). A
  308. // `turn/end` settles the prompt ONLY when it is the prompt's OWN turn
  309. // (`inflight.turn === event.data.turn`) — a previous, already-cancelled turn
  310. // whose end arrives late is ignored (see
  311. // SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP
  312. // has no error stop reason); other reasons resolve via the codec. Demux
  313. // strictly by session id: a `session/event` is routed to its own record, so
  314. // two sessions streaming at once never cross-settle or interleave updates.
  315. ctx.on('session/event', (session, event: SessionEvent) => {
  316. const rec = sessions.get(session.header.id)
  317. if (rec === undefined) return
  318. streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, {
  319. enabled: rec.terminalEnabled,
  320. cwd: session.header.cwd,
  321. }, { includeUserMessages: false })
  322. const inflight = rec.inflight
  323. if (inflight === undefined) return
  324. if (event.type === 'turn/start') {
  325. // Tag the in-flight prompt with its owning turn — but ONLY a
  326. // `message`-triggered turn (the kind a `send()` prompt produces). A turn
  327. // a plugin opens between prompt-install and the prompt's own turn (an idle
  328. // `agent.inject()` writes a one-shot `injection`-triggered turn) must NOT
  329. // be mistaken for the prompt's turn, or its turn/end would settle the RPC
  330. // early. The first message turn at/after install owns the prompt
  331. // (`turn === undefined` guard); the loop batches queued messages into one
  332. // turn, so there is exactly one.
  333. if (inflight.turn === undefined && event.data.trigger.kind === 'message') {
  334. inflight.turn = event.data.turn
  335. }
  336. return
  337. }
  338. // Settle only on the OWNING turn's end.
  339. if (event.type !== 'turn/end' || inflight.turn !== event.data.turn) return
  340. rec.inflight = undefined
  341. settleFromTurnEnd(inflight, event.data.reason)
  342. })
  343. // Settle fallback: a `session/event` listener registered BEFORE ACP that
  344. // throws (on `turn/start` OR `turn/end`) would, via cordis `emit`'s
  345. // stop-on-throw, starve ACP's listener above — the prompt would hang or, if
  346. // only the turn number was missed, settle as the wrong outcome. So when the
  347. // agent settles to `idle` (or is disposed), reconcile against the canonical
  348. // log: determine the prompt's owning turn (the captured `turn`, or — if the
  349. // live capture was starved — the FIRST `turn/start` appended at/after the
  350. // install-time `logWatermark`), then settle from that turn's `turn/end`
  351. // (reject on error, resolve via codec), or `cancelled` if no owning turn ever
  352. // started. Never double-settles — clears `inflight` first.
  353. const settleFromLog = (rec: SessionRecord): void => {
  354. const inflight = rec.inflight
  355. if (inflight === undefined) return
  356. const events = rec.agent.session.events
  357. // The owning turn number: the captured one, or — if the live capture was
  358. // starved — inferred from the log as the first MESSAGE-triggered turn opened
  359. // at/after the watermark. The message-trigger filter matches the live
  360. // capture: a one-shot `injection` turn a plugin may open between
  361. // prompt-install and the prompt's turn is NOT the prompt's turn. Undefined
  362. // only if no message turn ever started for this prompt.
  363. const owningTurn = inflight.turn ?? events.slice(inflight.logWatermark).find(
  364. (e): e is Extract<SessionEvent, { type: 'turn/start' }> =>
  365. e.type === 'turn/start' && e.data.trigger.kind === 'message',
  366. )?.data.turn
  367. // The owning turn's end in the log. If `owningTurn` is undefined (no turn
  368. // ever started for this prompt — a torn-down-before-turn case that quiesce's
  369. // direct settle normally pre-empts), no `turn/end` matches (turn numbers are
  370. // >= 1) and `findLast` returns undefined, falling through to cancelled.
  371. const end = events.findLast(
  372. (e): e is Extract<SessionEvent, { type: 'turn/end' }> =>
  373. e.type === 'turn/end' && e.data.turn === owningTurn,
  374. )
  375. rec.inflight = undefined
  376. if (end === undefined) {
  377. // No owning turn / no clean turn/end (torn down mid-turn) → cancelled.
  378. inflight.resolve('cancelled')
  379. return
  380. }
  381. settleFromTurnEnd(inflight, end.data.reason)
  382. }
  383. // On a settle to idle/disposed, reconcile any still-pending prompt from the
  384. // log (covers a starved `session/event` listener — see settleFromLog). A mid-
  385. // step disposal that never appended a clean turn/end resolves `cancelled`.
  386. // Demux via the agent→sessionId reverse map.
  387. ctx.on('agent/status', (agent, status: AgentStatus) => {
  388. const sessionId = bySession.get(agent)
  389. if (sessionId === undefined) return
  390. const rec = sessions.get(sessionId)
  391. if (rec === undefined) return
  392. if (status === 'idle' || status === 'disposed') settleFromLog(rec)
  393. })
  394. // --- The ACP Agent method surface -----------------------------------------
  395. const makeAgent = (connection: AgentSideConnection): AcpAgent => {
  396. conn = connection
  397. return {
  398. initialize(params: InitializeRequest): Promise<InitializeResponse> {
  399. // Echo the client's version if we support it, else our own. We support
  400. // exactly PROTOCOL_VERSION; any other requested version negotiates
  401. // down to ours (the client disconnects if it can't speak it).
  402. const protocolVersion = params.protocolVersion === PROTOCOL_VERSION ? params.protocolVersion : PROTOCOL_VERSION
  403. // Remember the Zed terminal-output `_meta` capability: when set, bash and
  404. // other shell tools render as a terminal card (see streamSessionEventUpdate
  405. // + the terminal-rendering RFC). `_meta` is `{[k]: unknown} | null`, so
  406. // narrow defensively to a strict boolean true.
  407. terminalOutputCap = params.clientCapabilities?._meta?.['terminal_output'] === true
  408. return Promise.resolve({
  409. protocolVersion,
  410. agentInfo: { name: agentName, version: agentVersion },
  411. agentCapabilities: {
  412. loadSession: true,
  413. // Baseline prompt blocks only: text plus resource_link rendered as
  414. // text. No image/audio/embeddedContext, no mcpCapabilities.
  415. promptCapabilities: { image: false, audio: false, embeddedContext: false },
  416. },
  417. authMethods: [],
  418. })
  419. },
  420. authenticate(_params: AuthenticateRequest): Promise<void> {
  421. // No auth methods advertised; nothing to do. Present because the SDK
  422. // Agent interface requires it.
  423. return Promise.resolve()
  424. },
  425. newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
  426. assertOpen()
  427. validateWorkspaceParams(params)
  428. validateMcpServers(params)
  429. const sessionId = SessionId(randomUUID())
  430. const handle = agents.create({
  431. agentId: AgentId(sessionId),
  432. sessionId,
  433. meta: { cwd: params.cwd },
  434. agentOptions: agentOptions(config),
  435. })
  436. bySession.set(handle.agent, sessionId)
  437. sessions.set(sessionId, {
  438. sessionId,
  439. agent: handle.agent,
  440. dispose: () => handle.dispose(),
  441. presenter: makePresenter(),
  442. terminalEnabled: terminalOutputCap,
  443. inflight: undefined,
  444. })
  445. return Promise.resolve({ sessionId })
  446. },
  447. async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
  448. assertOpen()
  449. // The wire `params.sessionId` is a raw protocol string; brand it once at
  450. // this entry so the session collections and the resume factory see a SessionId.
  451. const sessionId = SessionId(params.sessionId)
  452. if (sessions.has(sessionId) || loadingIds.has(sessionId)) {
  453. throw invalidParams(`session ${sessionId} is already loaded`)
  454. }
  455. validateWorkspaceParams(params)
  456. validateMcpServers(params)
  457. // Reserve THIS id's load slot BEFORE the await. Without it, two pipelined
  458. // loads for the same id could both pass the guard above while the first
  459. // resume() is pending, then both install a record and leak a second
  460. // agent. (Distinct ids load concurrently — the set is keyed by id.) The
  461. // slot is released in `finally` so a rejected load never wedges the id.
  462. loadingIds.add(sessionId)
  463. try {
  464. // Validate the PERSISTED cwd BEFORE resuming — `list()` is a
  465. // metadata-only read (no full-log parse), so this rejects a session we
  466. // can't honor WITHOUT ever constructing/registering an agent (a
  467. // post-resume reject would leak the registered agent — cancel() does not
  468. // unregister it — and wedge the id against re-load). The session's bash
  469. // workdir is derived from its persisted `header.cwd` and the request
  470. // `cwd` does NOT override it (resume takes no cwd), so a session with no
  471. // absolute persisted cwd would silently run bash in the SERVER's launch
  472. // dir, not the client's workspace. A session created by this bridge
  473. // always has a cwd (session/new requires it); reject the rest loudly.
  474. // (An id unknown to `list()` falls through to resume, which rejects with
  475. // the backend's not-found error.)
  476. const meta = (await sessionPersistence.list()).find(m => m.id === sessionId)
  477. if (meta !== undefined) {
  478. const persistedCwd = meta.cwd
  479. if (persistedCwd === undefined || !isAbsolute(persistedCwd)) {
  480. throw invalidParams(
  481. `session ${sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`,
  482. )
  483. }
  484. if (!sameWorkspaceCwd(persistedCwd, params.cwd)) {
  485. throw invalidParams(`session ${sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`)
  486. }
  487. }
  488. const handle = await agents.resume({
  489. agentId: AgentId(sessionId),
  490. resumeSessionId: sessionId,
  491. agentOptions: agentOptions(config),
  492. })
  493. // The bridge may have torn down (disposal / client disconnect) while
  494. // resume() was pending. Its listeners are gone, so installing a record
  495. // now would resurrect a live agent the bridge can no longer drive. Bail —
  496. // and tear down the just-resumed agent (unregister + stop + remove its
  497. // session) before throwing, so it does not leak: it has no SessionRecord,
  498. // so quiesce() would never see it.
  499. /* v8 ignore next 4 -- the in-memory test transport rejects the in-flight
  500. session/load request the instant it closes (before this post-await
  501. code runs), so the guard can't be hit in tests; it protects the real
  502. stdio path, where a closed pipe need not reject a mid-flight handler. */
  503. if (closed) {
  504. await handle.dispose()
  505. throw invalidParams('connection closed during session/load')
  506. }
  507. const agent = handle.agent
  508. bySession.set(agent, sessionId)
  509. // Snapshot the terminal capability ONCE for this session (used by both
  510. // the replay below and the post-load live stream) so a later
  511. // `initialize` can't desync the call/result of a tool card.
  512. const terminalEnabled = terminalOutputCap
  513. const record: SessionRecord = {
  514. sessionId,
  515. agent,
  516. dispose: () => handle.dispose(),
  517. presenter: makePresenter(),
  518. terminalEnabled,
  519. inflight: undefined,
  520. }
  521. sessions.set(sessionId, record)
  522. // Replay the persisted event log to the client as session/update. Use
  523. // the raw event log (NOT deriveMessages, which drops assistant/chunk
  524. // and trace events): RFC 010's load contract reconstructs the streamed
  525. // turns — user prompts (user/message → user_message_chunk), assistant
  526. // text and reasoning (assistant/chunk), and tool calls/results.
  527. //
  528. // Replay through a THROWAWAY presenter, NOT `record.presenter`: a
  529. // historical turn that was interrupted mid-tool (a `tool/call` with no
  530. // matching `tool/result` in the persisted log) would otherwise leave a
  531. // stale in-flight entry on the live presenter, which then serves all
  532. // future live events for this session. The throwaway pairs call→result
  533. // as the log replays in order (same as live) and is discarded after,
  534. // so the record's presenter starts clean for the post-load live stream.
  535. const replayPresenter = makePresenter()
  536. const replayTerminal: TerminalRendering = {
  537. enabled: terminalEnabled,
  538. cwd: agent.session.header.cwd,
  539. }
  540. for (const event of agent.session.events) {
  541. streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
  542. }
  543. return {}
  544. } finally {
  545. loadingIds.delete(sessionId)
  546. }
  547. },
  548. async prompt(params: PromptRequest): Promise<PromptResponse> {
  549. assertOpen()
  550. const rec = requireSession(SessionId(params.sessionId))
  551. if (rec.inflight !== undefined) {
  552. throw invalidParams('a prompt is already in flight for this session')
  553. }
  554. if (promptHasUnsupportedContent(params.prompt)) {
  555. throw invalidParams('only text and resource_link prompt content is supported; image/audio/embedded resource blocks are rejected rather than silently dropped')
  556. }
  557. const text = acpPromptToText(params.prompt)
  558. if (text.trim().length === 0) {
  559. // Reject up front rather than calling send(): an empty prompt would
  560. // queue no work, no turn would start, and the RPC would hang forever
  561. // waiting for a settle that never comes.
  562. throw invalidParams('empty prompt')
  563. }
  564. // Install the in-flight slot BEFORE send() (send does not synchronously
  565. // flip status to running; the session/event listener records the turn
  566. // number and settle/rejects it). Capture the log length now as the
  567. // watermark: the settle-from-log fallback infers the owning turn/start
  568. // as the first one appended at/after it, surviving a starved live
  569. // capture. A turn that ends in error rejects this promise (the codec
  570. // never produces an error stop reason).
  571. const stopReason = await new Promise<StopReason>((resolve, reject) => {
  572. rec.inflight = { resolve, reject, turn: undefined, logWatermark: rec.agent.session.events.length }
  573. rec.agent.send([{ type: 'text', text }])
  574. })
  575. return { stopReason }
  576. },
  577. cancel(params: CancelNotification): Promise<void> {
  578. const rec = sessions.get(SessionId(params.sessionId))
  579. if (rec === undefined) return Promise.resolve()
  580. // session/cancel maps to the queue-aware agent.cancel(reason): it aborts
  581. // a RUNNING step, clears the queued + steering FIFOs, and drops a
  582. // turn that is about to start (the pre-step window) — so a queued-but-
  583. // not-yet-started prompt never runs, and a prompt accepted right after
  584. // cannot be batched into the cancelled turn. Scoped to THIS session's
  585. // agent — a cancel in one session never touches another's stream or
  586. // pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt
  587. // as cancelled directly here: do NOT rely on the resulting turn/end to
  588. // settle it, because cancel() may drop the turn before any turn/end is
  589. // emitted, and removing this direct settle would move the RPC's
  590. // resolution onto the settleFromLog/agent-status path, changing its
  591. // timing.
  592. rec.agent.cancel('session/cancel')
  593. settlePrompt(rec, 'cancelled')
  594. return Promise.resolve()
  595. },
  596. }
  597. }
  598. // --- Connection lifecycle --------------------------------------------------
  599. // The transport stream. Production wires stdio (stdout carries the protocol);
  600. // tests inject an in-memory pipe pair via config.stream to drive the bridge
  601. // without a subprocess. ndJsonStream is the SDK's stdio framing helper. The
  602. // AgentSideConnection constructor synchronously invokes makeAgent (assigning
  603. // the outer `conn`), so `conn` is set before any agent method runs.
  604. /* v8 ignore next 4 -- production stdio wiring; tests always inject config.stream */
  605. const stream: Stream = config.stream ?? ndJsonStream(
  606. Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
  607. Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
  608. )
  609. conn = new AgentSideConnection(makeAgent, stream)
  610. /**
  611. * Tear ALL live sessions down to quiescence (AGENTS.md "dispose must reach
  612. * quiescence"): for each session settle any pending prompt `cancelled`, then
  613. * run that session's {@link AgentHandle} `dispose()` — which stops the loop
  614. * (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the
  615. * final `turn/end` + `session/flush` are captured while `onAppend` is still
  616. * attached), unregisters the agent, and removes its session from the store.
  617. * The per-session disposes run in parallel. Idempotent — clears the `sessions`
  618. * map first and memoizes, so a second call (close racing dispose) is a no-op.
  619. * Shared by Cordis disposal AND client disconnect (`conn.closed`).
  620. *
  621. * Per-agent disposal closes the former pre-step best-effort window — but via
  622. * the DISPOSED path, not `cancel()`: the start-disposer resolves `handle.disposed`,
  623. * which wakes the parked loop, and `isDisposed()` breaks the loop before a
  624. * queued-but-not-yet-running turn can start (a turn cut off mid-flight ends
  625. * with reason `disposed`, not `aborted`). A bare client disconnect (resolves
  626. * `conn.closed` WITHOUT disposing the fiber) thus leaves NO registered agent
  627. * and NO session-store entry — not an idled-but-still-registered one. When the
  628. * fiber IS disposed (whole-context or an ACP-only HMR
  629. * `acpFiber.dispose()`), this same memoized teardown runs first; the factory's
  630. * register+start+session effects are ALSO bound to the bridge fiber (the
  631. * factory is reached through this bridge's traceable service proxy, so
  632. * `AgentLoop.start`'s `this.ctx.effect(...)` binds to the CALLER context — the
  633. * bridge fiber), so any agent this path did not reach is still reclaimed by
  634. * fiber disposal.
  635. */
  636. let quiescing: Promise<void> | undefined
  637. const quiesce = (): Promise<void> => {
  638. // Memoize: disposal and client-disconnect can both fire. The first call owns
  639. // the teardown; later callers await the SAME promise so `fiber.dispose()`
  640. // never returns before an in-flight close teardown has finished.
  641. if (quiescing !== undefined) return quiescing
  642. // Mark closed BEFORE draining: a `session/load` mid-`resume()` (no record
  643. // installed yet) must observe this after its await and refuse to install a
  644. // post-teardown record. Set even when there are no live sessions.
  645. closed = true
  646. const recs = [...sessions.values()]
  647. sessions.clear()
  648. if (recs.length === 0) return Promise.resolve()
  649. quiescing = (async () => {
  650. await Promise.all(recs.map(async (rec) => {
  651. settlePrompt(rec, 'cancelled')
  652. // Per-agent dispose (the AgentHandle disposer): unregister this agent,
  653. // stop its loop (sets disposed + aborts the in-flight step), await
  654. // quiescence (the loop exit + final flush), and remove its session — so
  655. // a bare client disconnect leaves NO registered agent and NO
  656. // session-store entry, not just an idled-but-still-registered one.
  657. await rec.dispose()
  658. }))
  659. })()
  660. return quiescing
  661. }
  662. // Client disconnect: when the ACP transport closes (editor quits, pipe EOF),
  663. // the in-flight turn would otherwise keep running and its `session/update`
  664. // writes would be silently swallowed by `notify()`. Tear the session down so
  665. // a vanished client does not leave an orphaned running agent. `conn.closed`
  666. // rejects/resolves once; contain any teardown throw (nothing else can act on
  667. // it — the connection is already gone). The Cordis disposer below still runs
  668. // on normal shutdown and is idempotent with this.
  669. /* v8 ignore start -- the .catch arrow is a defensive guard: conn.closed
  670. settling rejected or quiesce() throwing on an already-closed connection is
  671. not reproducible through the in-memory test transport (it never severs
  672. mid-run), and there is nothing else to act on once the connection is gone —
  673. the swallow mirrors notify(). */
  674. void conn.closed.then(quiesce).catch((error: unknown) => {
  675. logger.warn(`acp: connection-close teardown failed: ${String(error)}`)
  676. })
  677. /* v8 ignore stop */
  678. ctx.effect(() => quiesce, 'acp.connection')
  679. }
  680. /**
  681. * Build per-agent options from the plugin config, omitting absent fields
  682. * (exactOptionalPropertyTypes: never assign `undefined` to an optional key).
  683. * Exported for unit coverage of both the present and absent branches.
  684. */
  685. export function agentOptions(config: AcpConfig): { model?: string; systemPrompt?: string } {
  686. return {
  687. ...config.model !== undefined ? { model: config.model } : {},
  688. ...config.systemPrompt !== undefined ? { systemPrompt: config.systemPrompt } : {},
  689. }
  690. }
  691. /**
  692. * Validate the `cwd`/`additionalDirectories` contract shared by `session/new`
  693. * and `session/load`: `cwd` must be absolute (a relative path would be ambiguous
  694. * as a workspace root). The persisted-cwd equality check for `session/load`
  695. * happens after the metadata lookup; this validator only enforces request shape:
  696. * - `session/new`: the validated `cwd` becomes the session's `SessionHeader.cwd`
  697. * (via `agents.create({meta:{cwd}})`) and thus the default bash workdir.
  698. * - `session/load`: the request `cwd` must be absolute AND must match the
  699. * PERSISTED `header.cwd`, which stays authoritative for the bash workdir —
  700. * the request cwd does not override it.
  701. * Any absolute path is accepted (the per-session cwd flows to the bash executor
  702. * — see `dsh-tool-bash`), so the server no longer has to launch in the
  703. * workspace. `additionalDirectories` must still be empty: widening the
  704. * tool/filesystem scope beyond the single cwd is a separate, unimplemented
  705. * concern (a sandbox seam), and silently ignoring extra roots would desync the
  706. * client's filesystem-scope UI. Both request shapes carry `cwd: string` and
  707. * `additionalDirectories?: string[]`, so one validator covers both.
  708. */
  709. function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] }): void {
  710. if (!isAbsolute(params.cwd)) {
  711. throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
  712. }
  713. if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
  714. throw invalidParams('additionalDirectories is not supported in this MVP')
  715. }
  716. }
  717. function validateMcpServers(params: { mcpServers?: unknown[] }): void {
  718. if (params.mcpServers !== undefined && params.mcpServers.length > 0) {
  719. throw invalidParams('mcpServers is not supported in this MVP')
  720. }
  721. }
  722. /**
  723. * Translate a single harness {@link SessionEvent} into the `session/update`
  724. * notification(s) it produces, pushing each via `notify`. Shared by live
  725. * streaming (`session/event`) and `session/load` replay so both paths emit an
  726. * identical update stream from the same event log.
  727. *
  728. * - `assistant/chunk` text-delta/reasoning-delta → message/thought chunks
  729. * - `user/message` → `user_message_chunk` during load replay only — so a
  730. * loaded transcript reconstructs the USER side of each turn without echoing
  731. * a live `session/prompt` back to the client
  732. * - `tool/call` → `tool_call` (pending)
  733. * - `tool/result` → `tool_call_update` (completed/failed)
  734. *
  735. * Tool-call presentation (title/kind/rawInput, and the completed-state content)
  736. * is owned by each TOOL via `presentCall`/`presentResult` — the bridge never
  737. * special-cases tool names. `presenter` resolves those from the tool registry
  738. * and remembers each call's `(name, args)` so the completed `tool/result` (which
  739. * carries neither) can find its tool. A {@link nullToolPresenter} gives the
  740. * generic fallback (title = tool name, raw args as input) when no registry is
  741. * available (e.g. pure translator tests).
  742. *
  743. * Other event types (turn/step boundaries, context/message, …) produce
  744. * no client update.
  745. */
  746. export function streamSessionEventUpdate(
  747. sessionId: SessionId,
  748. event: SessionEvent,
  749. notify: (notification: SessionNotification) => void,
  750. presenter: Pick<ToolPresenter, 'call' | 'result'> = nullToolPresenter,
  751. terminal: TerminalRendering = noTerminalRendering,
  752. options: { includeUserMessages?: boolean } = {},
  753. ): void {
  754. const includeUserMessages = options.includeUserMessages ?? true
  755. switch (event.type) {
  756. case 'assistant/chunk': {
  757. const chunk = event.data.chunk
  758. if (chunk.type === 'text-delta') {
  759. notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: chunk.text } } })
  760. } else if (chunk.type === 'reasoning-delta') {
  761. notify({ sessionId, update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: chunk.text } } })
  762. }
  763. return
  764. }
  765. case 'user/message': {
  766. if (!includeUserMessages) return
  767. // Replay the user's prompt so a loaded session shows both sides of each
  768. // turn. Live prompt turns suppress this path to avoid duplicating what
  769. // the client just sent.
  770. for (const block of event.data.content) {
  771. const content = harnessBlockToAcpContent(block)
  772. if (content !== undefined) {
  773. notify({ sessionId, update: { sessionUpdate: 'user_message_chunk', content } })
  774. }
  775. }
  776. return
  777. }
  778. case 'tool/call': {
  779. const view = presenter.call(event.data.callId, event.data.name, event.data.arguments)
  780. notify({ sessionId, update: toolCallUpdate(event.data.callId, view, terminal) })
  781. return
  782. }
  783. case 'tool/result': {
  784. const view = presenter.result(event.data.callId, event.data.content, event.data.isError, event.data.meta)
  785. notify({ sessionId, update: toolResultUpdate(event.data.callId, view, event.data.isError, terminal) })
  786. return
  787. }
  788. case 'todo/write': {
  789. notify({ sessionId, update: { sessionUpdate: 'plan', ...todosToPlan(event.data.todos) } })
  790. return
  791. }
  792. // turn/step boundaries, context/message, steering,
  793. // assistant/message — no direct ACP client update.
  794. default:
  795. return
  796. }
  797. }
  798. /**
  799. * Map a harness todo list to an ACP `plan` body. ACP's `PlanEntry` requires
  800. * `content` + `priority` + `status`, but a {@link TodoItem} carries no priority,
  801. * so synthesize a constant `'medium'` on every entry; `status` maps 1:1 (the
  802. * harness status triple IS `PlanEntryStatus`). The ACP client REPLACES its whole
  803. * plan on each `plan` update, matching the harness's whole-list-replace
  804. * semantics, so no per-entry diffing is needed.
  805. */
  806. export function todosToPlan(todos: TodoItem[]): Plan {
  807. return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) }
  808. }
  809. /**
  810. * Per-connection terminal-rendering context threaded into
  811. * {@link streamSessionEventUpdate}: whether the client advertised the
  812. * `_meta.terminal_output` capability, and the session's workspace cwd (the
  813. * default terminal-card header when a tool doesn't supply its own). Kept out of
  814. * the pure translator's required params so the no-capability / no-presenter
  815. * tests stay terse.
  816. */
  817. export interface TerminalRendering {
  818. enabled: boolean
  819. /** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */
  820. cwd: string | undefined
  821. }
  822. /** Default: terminal rendering off (the ` ```console ` text fallback path). */
  823. const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined }
  824. /**
  825. * Resolves tool-owned presentation for a session's tool-call events. A tool
  826. * declares `presentCall`/`presentResult` (see `dsh-tools`) returning a
  827. * `card`-tagged {@link ToolCallView}/{@link ToolResultView}; this looks them up
  828. * by name in the registry and applies a generic fallback when a tool defines
  829. * neither. The returned view is what {@link streamSessionEventUpdate} switches on.
  830. *
  831. * The `tool/result` session event does NOT carry the tool name or args — so to
  832. * call a tool's `presentResult` (which needs both), the presenter remembers each
  833. * `tool/call`'s `{ name, args, card }` keyed by callId and looks it up on the
  834. * matching result. The map is bridge-LOCAL (not a change to the event schema or a
  835. * core service): one presenter per live session
  836. * (and a throwaway per `session/load` replay), and each entry is removed when its
  837. * result arrives. In the normal loop a `tool/call` is always followed by a
  838. * `tool/result` (the registry turns even a thrown tool into an isError result),
  839. * so the map holds only currently-in-flight calls. The one exception is a step
  840. * torn down mid-tool (an abort between `tool/call` and `tool/result`), which can
  841. * leave a single stale entry per such call; this is bounded by the session
  842. * lifetime (the whole presenter is dropped on teardown) and never affects
  843. * correctness — a later result for a different callId is unaffected, and the
  844. * stale entry's only cost is one map slot until the session ends.
  845. */
  846. export class ToolPresenter {
  847. private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>()
  848. /**
  849. * @param tools the registry to resolve tool definitions by name.
  850. * @param onError invoked when a tool's `presentCall`/`presentResult` THROWS;
  851. * the presenter swallows the error and falls back to the generic
  852. * presentation so a buggy display callback can never fail a live turn or a
  853. * `session/load` replay (AGENTS.md "contain callback exceptions at the
  854. * boundary"). Defaults to a no-op for callers that don't supply a logger.
  855. */
  856. constructor(
  857. private readonly tools: Pick<ToolRegistry, 'get'>,
  858. private readonly onError: (message: string) => void = () => {},
  859. ) {}
  860. /** Pending-state render intent for a `tool/call`; remembers `(name, args, card)` for the matching result. */
  861. call(callId: CallId, name: string, argsJson: string): ToolCallView {
  862. const args = parseToolArguments(argsJson)
  863. let present: ToolCallView | undefined
  864. try {
  865. present = this.tools.get(name)?.presentCall?.(args)
  866. } catch (error: unknown) {
  867. // A throwing presentCall must not break streaming: log and fall back.
  868. this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
  869. present = undefined
  870. }
  871. // No tool-owned presentation: fall back to the tool name as the title and the
  872. // full parsed args as the raw input (the generic card).
  873. const view: ToolCallView = present ?? { card: 'generic', title: name, kind: toolKindFor(name), rawInput: args }
  874. this.pending.set(callId, { name, args, card: view.card })
  875. return view
  876. }
  877. /** Completed-state render intent for a `tool/result`; consumes the remembered `(name, args, card)`. */
  878. result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
  879. const call = this.pending.get(callId)
  880. this.pending.delete(callId)
  881. // No remembered call (unknown/late callId) → nothing to present from; raw content.
  882. if (call === undefined) return { card: 'generic', content }
  883. let present: ToolResultView | undefined
  884. try {
  885. present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} })
  886. } catch (error: unknown) {
  887. // A throwing presentResult must not break streaming/replay: log + fall back.
  888. this.onError(`acp: tool "${call.name}" presentResult threw, using raw result: ${String(error)}`)
  889. present = undefined
  890. }
  891. if (present === undefined) return { card: 'generic', content }
  892. // Orphan guard: only honor a `terminal` result when the PENDING call was a
  893. // terminal. A result-only terminal with no matching call-side terminal would
  894. // orphan `_meta.terminal_output` to a terminal Zed never made — drop it back
  895. // to the raw content.
  896. if (present.card === 'terminal' && call.card !== 'terminal') return { card: 'generic', content }
  897. // A generic result that reformats no content keeps the RAW result content
  898. // (the tool replaced only the title); fill it so the card is never blanked.
  899. if (present.card === 'generic' && present.content === undefined) return { ...present, content }
  900. return present
  901. }
  902. }
  903. /**
  904. * The no-op presenter used when no tool registry is available (e.g. the pure
  905. * translator tests): every tool gets the generic fallback presentation, and
  906. * results pass their raw content through unchanged.
  907. */
  908. export const nullToolPresenter: Pick<ToolPresenter, 'call' | 'result'> = {
  909. call: (_callId, name, argsJson) => ({ card: 'generic', title: name, kind: toolKindFor(name), rawInput: parseToolArguments(argsJson) }),
  910. result: (_callId, content) => ({ card: 'generic', content }),
  911. }
  912. /** Map a harness tool name to an ACP ToolKind (best-effort; default `other`). */
  913. function toolKindFor(name: string): ToolCallKind {
  914. if (name === 'bash' || name === 'bash_output' || name === 'bash_kill') return 'execute'
  915. if (name === 'read' || name.startsWith('read')) return 'read'
  916. if (name === 'write' || name === 'edit' || name.startsWith('edit')) return 'edit'
  917. return 'other'
  918. }
  919. /** Parse a tool-call arguments JSON string for `rawInput`; raw string on failure. */
  920. function parseToolArguments(args: string): unknown {
  921. try {
  922. return args ? JSON.parse(args) : {}
  923. } catch {
  924. // The model produced non-JSON arguments; surface the raw string rather
  925. // than dropping it. (The harness tool layer handles validation; here we
  926. // only feed the client's tool-call UI.)
  927. return args
  928. }
  929. }
  930. /** Map harness tool-result content blocks to ACP tool-call content (text only). */
  931. function toolResultContent(blocks: ContentBlock[]): { type: 'content'; content: AcpContentBlock }[] {
  932. const out: { type: 'content'; content: AcpContentBlock }[] = []
  933. for (const block of blocks) {
  934. const content = harnessBlockToAcpContent(block)
  935. if (content !== undefined) out.push({ type: 'content', content })
  936. }
  937. return out
  938. }
  939. /** The `session/update` payload for a `tool_call` / `tool_call_update`. */
  940. type ToolCallSessionUpdate = SessionNotification['update']
  941. /** An ACP tool-call content block (a text/image `content`, a `diff`, or a `terminal`). */
  942. type AcpToolCallContent =
  943. | { type: 'content'; content: AcpContentBlock }
  944. | { type: 'diff'; path: string; oldText: string | null; newText: string }
  945. | { type: 'terminal'; terminalId: string }
  946. /**
  947. * Relativize a file card's TITLE path against the session workspace cwd, so a
  948. * card reads `Read src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the
  949. * reference ACP adapter's `toDisplayPath`. Only the TITLE is relativized; the
  950. * card's `locations`/`diff` paths stay RAW (the editor opens the real path). The
  951. * pure tool presenter can't see the session cwd, so this happens here where the
  952. * bridge knows it. The rewrite is an exact substring replace of the known raw
  953. * path (a card carries the same path in `locations[0]`/`diffs[0]`), never a
  954. * heuristic. A path outside the workspace, or an absent/relative session cwd, is
  955. * left unchanged.
  956. */
  957. function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string {
  958. if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title
  959. const rel = relativePath(sessionCwd, rawPath)
  960. // Only relativize a target that stays INSIDE the workspace. `relative` prefixes
  961. // a `..` SEGMENT for a target above the cwd — test for the segment (`..` alone
  962. // or `..<sep>…`), NOT a bare `..` char prefix, so a sibling like `..cache/x`
  963. // (a real in-workspace name) still relativizes. Never relativize to the empty
  964. // string (rawPath === cwd — a non-file target).
  965. if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title
  966. return title.split(rawPath).join(rel)
  967. }
  968. /**
  969. * Resolve the terminal card's header cwd. A `TerminalCallView.cwd` (a model
  970. * `workdir`) wins when ABSOLUTE; a RELATIVE one resolves against the session cwd
  971. * (matching how `dsh-tool-bash` resolves a relative workdir for execution, so the
  972. * header matches where the command actually ran); when the view gives no cwd, the
  973. * session workspace cwd is the default. Returns `undefined` only when neither the
  974. * view nor the session supplies one (Zed then shows "current directory").
  975. */
  976. function terminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
  977. if (viewCwd === undefined) return sessionCwd
  978. if (isAbsolute(viewCwd)) return viewCwd
  979. return sessionCwd !== undefined ? resolvePath(sessionCwd, viewCwd) : viewCwd
  980. }
  981. /**
  982. * Build the `tool_call` (pending) `session/update` from a tool's render intent.
  983. * Switches on `view.card`: a `generic` card maps title/kind/rawInput/content/
  984. * locations; a `diff` card emits `{ type: 'diff' }` content blocks (the editor's
  985. * inline diff) plus follow-along locations; a `terminal` card renders as a
  986. * terminal when the client is capable (a `terminal` content block + the
  987. * `_meta.terminal_info` cwd header) and otherwise falls back to a generic execute
  988. * card whose body is the description. File-card titles are relativized against the
  989. * session cwd (see {@link displayTitle}).
  990. */
  991. function toolCallUpdate(callId: CallId, view: ToolCallView, terminal: TerminalRendering): ToolCallSessionUpdate {
  992. switch (view.card) {
  993. case 'generic':
  994. return {
  995. sessionUpdate: 'tool_call',
  996. toolCallId: callId,
  997. // Relativize the title against the session cwd when the card carries a
  998. // file location (a read/file card); a location-less card (bash, todo)
  999. // has no path to relativize, so the title is used as-is.
  1000. title: displayTitle(view.title, view.locations?.[0]?.path, terminal.cwd),
  1001. kind: view.kind ?? 'other',
  1002. status: 'in_progress',
  1003. ...view.rawInput !== undefined ? { rawInput: view.rawInput } : {},
  1004. ...view.locations !== undefined ? { locations: view.locations } : {},
  1005. ...view.content !== undefined && view.content.length > 0 ? { content: toolResultContent(view.content) } : {},
  1006. }
  1007. case 'diff': {
  1008. const rawPath = view.locations?.[0]?.path ?? view.diffs[0]?.path
  1009. const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText }))
  1010. return {
  1011. sessionUpdate: 'tool_call',
  1012. toolCallId: callId,
  1013. title: displayTitle(view.title, rawPath, terminal.cwd),
  1014. kind: 'edit',
  1015. status: 'in_progress',
  1016. ...view.locations !== undefined ? { locations: view.locations } : {},
  1017. ...content.length > 0 ? { content } : {},
  1018. }
  1019. }
  1020. case 'terminal': {
  1021. // A terminal-rendered call gets a terminal CARD when the client supports it:
  1022. // the description renders ABOVE the card, then the terminal block, plus
  1023. // `_meta.terminal_info` (the cwd header). Without the capability it is an
  1024. // ordinary execute card whose body is the description and whose rawInput is
  1025. // the command; the output arrives as text on the result.
  1026. const asTerminal = terminal.enabled
  1027. const description: AcpToolCallContent[] = view.description !== undefined
  1028. ? [{ type: 'content', content: { type: 'text', text: view.description } }]
  1029. : []
  1030. const content: AcpToolCallContent[] = [
  1031. ...description,
  1032. ...asTerminal ? [{ type: 'terminal' as const, terminalId: callId }] : [],
  1033. ]
  1034. return {
  1035. sessionUpdate: 'tool_call',
  1036. toolCallId: callId,
  1037. title: view.title,
  1038. kind: 'execute',
  1039. status: 'in_progress',
  1040. rawInput: view.title,
  1041. ...content.length > 0 ? { content } : {},
  1042. ...asTerminal
  1043. ? { _meta: { terminal_info: { terminal_id: callId, cwd: terminalCwd(view.cwd, terminal.cwd) } } }
  1044. : {},
  1045. }
  1046. }
  1047. default:
  1048. return assertNever(view, 'ToolCallView.card')
  1049. }
  1050. }
  1051. /** The `terminal_exit` `_meta` entry for a completed terminal call. */
  1052. interface TerminalExitMeta {
  1053. terminal_exit?: { terminal_id: string; exit_code?: number; signal?: string }
  1054. }
  1055. /**
  1056. * Build the optional `terminal_exit` portion of a `tool_call_update`'s `_meta`
  1057. * from a terminal result: a `signal` death yields `{signal}`, an `exitCode`
  1058. * yields `{exit_code}`, and neither yields nothing (the card simply shows no exit
  1059. * pill). Spread into the `_meta` object alongside `terminal_output`.
  1060. */
  1061. function terminalExitMeta(callId: string, view: TerminalResultView): TerminalExitMeta {
  1062. if (view.signal !== undefined) return { terminal_exit: { terminal_id: callId, signal: view.signal } }
  1063. if (view.exitCode !== undefined) return { terminal_exit: { terminal_id: callId, exit_code: view.exitCode } }
  1064. return {}
  1065. }
  1066. /**
  1067. * Build the `tool_call_update` (completed) `session/update` from a result render
  1068. * intent. A `generic` result sends its reformatted content (or the raw result);
  1069. * a `terminal` result rides its output/exit on `_meta` when the client is capable
  1070. * (the terminal card consumes them and `content` is OMITTED — a
  1071. * `tool_call_update.content` REPLACES the call's content collection in Zed, so
  1072. * re-sending would clobber the terminal block the call installed) and otherwise
  1073. * derives the fenced ```console fallback from `output`. A `diff` result emits its
  1074. * `{ type: 'diff' }` content blocks (an applied hunk, or a whole-file diff for a
  1075. * create), which replace the diff the call installed — so the model-facing result
  1076. * text can never clobber it.
  1077. */
  1078. function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean, terminal: TerminalRendering): ToolCallSessionUpdate {
  1079. const status = isError ? 'failed' as const : 'completed' as const
  1080. switch (view.card) {
  1081. case 'terminal': {
  1082. const output = view.output ?? ''
  1083. if (terminal.enabled) {
  1084. return {
  1085. sessionUpdate: 'tool_call_update',
  1086. toolCallId: callId,
  1087. status,
  1088. ...view.title !== undefined ? { title: view.title } : {},
  1089. _meta: {
  1090. terminal_output: { terminal_id: callId, data: output },
  1091. ...terminalExitMeta(callId, view),
  1092. },
  1093. }
  1094. }
  1095. // No terminal capability: the bridge derives the fenced ```console fallback.
  1096. const fenced = `\`\`\`console\n${output.replace(/\n+$/, '')}\n\`\`\``
  1097. return {
  1098. sessionUpdate: 'tool_call_update',
  1099. toolCallId: callId,
  1100. status,
  1101. content: [{ type: 'content', content: { type: 'text', text: fenced } }],
  1102. ...view.title !== undefined ? { title: view.title } : {},
  1103. }
  1104. }
  1105. case 'generic':
  1106. return {
  1107. sessionUpdate: 'tool_call_update',
  1108. toolCallId: callId,
  1109. status,
  1110. // The presenter fills a generic result's content from the raw result, so
  1111. // `content` is always defined here; the guard keeps this total for a
  1112. // directly-constructed view.
  1113. /* v8 ignore next -- content always defined via the presenter (see above) */
  1114. ...view.content !== undefined ? { content: toolResultContent(view.content) } : {},
  1115. ...view.title !== undefined ? { title: view.title } : {},
  1116. }
  1117. case 'diff': {
  1118. // A result-time diff: emit one `{ type: 'diff' }` content block per entry
  1119. // (an applied hunk for an edit/overwrite, or a whole-file diff for a
  1120. // create), mirroring the call-side diff arm. `tool_call_update.content`
  1121. // REPLACES the call's content in an editor, so this result diff supersedes
  1122. // the diff the pending card installed (and keeps the model-facing result
  1123. // text from clobbering it).
  1124. const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText }))
  1125. // Relativize the replacement title against the session cwd from the diff
  1126. // path, exactly as the call-side card does — `tool_call_update.title`
  1127. // replaces the card header, so a raw absolute path here would undo the
  1128. // pending card's relativized title.
  1129. const title = view.title !== undefined ? displayTitle(view.title, view.diffs[0]?.path, terminal.cwd) : undefined
  1130. return {
  1131. sessionUpdate: 'tool_call_update',
  1132. toolCallId: callId,
  1133. status,
  1134. ...content.length > 0 ? { content } : {},
  1135. ...title !== undefined ? { title } : {},
  1136. }
  1137. }
  1138. default:
  1139. return assertNever(view, 'ToolResultView.card')
  1140. }
  1141. }