feat(acp): ACP bridge — drive the coding agent from an editor over JSON-RPC stdio

Implements the RFC 010 MVP: a new `@deepseek-ai/dsh-acp` package bridges the
harness agent to the Agent Client Protocol (JSON-RPC 2.0 over newline-delimited
stdio), so Zed and other ACP editors can drive the coding agent — streaming
render, tool-call display, and resumable sessions via `session/load`.

- packages/acp: AgentSideConnection wiring; initialize/newSession/loadSession/
  prompt/cancel; a total TurnEndReason→StopReason codec; settle-once with a
  fallback chain (agent/turn-end → logged turn/end → idle); single-session
  guard; cwd-must-equal-launch-dir validation; load replays from the persisted
  event log (assistant/chunk→agent_message_chunk, tool/call/result→tool_call*).
- agent: add Agent.whenIdle() quiescence signal to the interface; LoopAgent
  implements it (resolves on the first running→idle/disposed transition). The
  bridge awaits it on disposal so teardown reaches quiescence, not just abort.
- examples: extract the shared provider/tool core into examples/base.yml;
  coding-agent nest-includes it; new examples/acp-agent serves the agent over
  ACP with JSONL persistence and no stdout logger (stdout is the protocol).
- Permission gate deferred (TODO(rfc010-permission-gate)): tools run with the
  executor's full authority; only the Agent→sessionId ownership seam is laid
  down. Cancel is best-effort for a not-yet-started queued turn
  (TODO(rfc010-cancel-prestep)). RFC 010 stays `proposed`.
- Docs: package README + Zed snippet; client-driver cookbook section; root and
  packages layout/commands; RFC 010 implementation-status note.

48 bridge tests + whenIdle coverage; 100% per-file coverage; e2e boots the
example as a subprocess and verifies a written file on disk (key-gated, with a
no-key stdout-purity check).
This commit is contained in:
Tianyi Cui
2026-06-16 11:10:25 +08:00
parent add59a3336
commit fb9636db44
39 changed files with 2819 additions and 51 deletions

103
packages/acp/src/codec.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Pure translation between harness vocabulary and ACP wire types. No I/O, no
* Cordis context — every function here is total and unit-testable in isolation.
* Keeping the mapping pure is deliberate: the SDK rejects an unknown
* `stopReason`, so the {@link turnEndToStopReason} total function (with its
* exhaustive test over every `TurnEndReason` kind) is the guard that a turn
* always settles to a legal wire value.
*
* @module @deepseek-ai/dsh-acp/codec
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
/**
* Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum.
*
* The mapping is total over the kinds the loop actually produces today
* (`completed`/`aborted`/`error`/`disposed`/`max-tokens`). `TurnEndReason` is
* merge-extensible, so an unknown future kind falls through to `end_turn` —
* the safest default (the turn DID end; we just lack a more specific wire
* reason) — rather than throwing into the SDK, which would reject an unknown
* `stopReason` and break the prompt RPC. When a new kind gains a dedicated ACP
* reason (e.g. a future `refusal` → `refusal`), add an explicit case here.
*
* - `completed` → `end_turn` (the model chose to stop)
* - `max-tokens` → `max_tokens` (cut off at the output-token ceiling)
* - `aborted` → `cancelled` (an `agent.abort()`, e.g. from `session/cancel`)
* - `error` → `end_turn` (defensive fallback only: the bridge REJECTS the
* `session/prompt` RPC on an error turn BEFORE calling this, so
* a client sees a JSON-RPC error, not a stop reason — see
* `rejectPrompt` in index.ts. This case keeps the function total
* for any non-bridge caller / property test.)
* - `disposed` → `cancelled` (the agent was torn down mid-turn — closest to a
* cancellation from the client's perspective)
*/
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
switch (reason.kind) {
case 'completed':
return 'end_turn'
case 'max-tokens':
return 'max_tokens'
case 'aborted':
return 'cancelled'
case 'disposed':
return 'cancelled'
case 'error':
return 'end_turn'
// Merge-extensible: an unknown future TurnEndReason kind still has to
// produce a legal wire value (the SDK rejects unknown stopReason), so
// default to end_turn rather than assertNever. Add an explicit case when a
// new kind gains a dedicated ACP reason.
default:
return 'end_turn'
}
}
/**
* Translate a harness {@link ContentBlock} from a prompt into ACP content for
* replay, or `undefined` for block kinds the bridge does not surface to the
* client as message content. Today only `text` maps (text-only
* `promptCapabilities`); `reasoning` is surfaced via `agent_thought_chunk`
* streaming rather than as a message block, and `tool-call`/`tool-result`/
* `image` are handled by the tool-call update path or not advertised.
*/
export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined {
switch (block.type) {
case 'text':
return { type: 'text', text: block.text }
// reasoning → streamed as agent_thought_chunk, not a message block
// tool-call / tool-result → the tool_call / tool_call_update path
// image → not advertised (text-only promptCapabilities)
default:
return undefined
}
}
/**
* Extract plain text from an ACP prompt's content blocks, concatenating every
* `text` block. Non-text blocks are ignored here; the caller rejects a prompt
* carrying image/audio per the advertised text-only capabilities BEFORE
* calling this, so dropping them here only affects `resource`/`resource_link`
* (which carry no inline text to forward in the MVP).
*/
export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
return prompt
.filter((block): block is AcpContentBlock & { type: 'text'; text: string } => block.type === 'text')
.map(block => block.text)
.join('')
}
/**
* Whether an ACP prompt contains any content the text-only bridge cannot
* accept — i.e. ANY non-`text` block (image, audio, `resource`, `resource_link`,
* …). The caller rejects such a prompt up front rather than silently dropping
* the unsupported parts: a prompt like `[text, resource_link]` carries context
* the model would otherwise never see, so running it text-only would be silent
* data loss. When richer block kinds are supported, narrow this.
*/
export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean {
return prompt.some(block => block.type !== 'text')
}

737
packages/acp/src/index.ts Normal file
View File

@@ -0,0 +1,737 @@
/**
* The Agent Client Protocol (ACP) bridge: a client-driver / UI plugin that
* exposes the harness agent as an ACP server over JSON-RPC stdio, so editors
* (Zed and other ACP clients) can drive it. The structured analogue of the
* readline `stdio-chat` plugin.
*
* This is NOT a loop change and NOT an ADR-0009 capability seam: it consumes
* the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory,
* and `dsh-session-persistence` (for `session/load`). It maps:
*
* - `initialize` → protocol-version negotiation, text-only capabilities
* - `session/new` → `ctx.agents.create({ sessionId, meta:{cwd} })`
* - `session/load` → `ctx.agents.resume(...)` then replay the event log
* - `session/prompt` → `agent.send()`, settle on the owning turn's end (a turn
* that ends in `error` rejects the RPC)
* - `session/cancel` → `agent.abort()` + settle the in-flight prompt
*
* Single-session for the MVP (a 2nd `session/new` is rejected); RFC 011 lifts
* that. The `tools/execute` permission gate is deferred — see the
* TODO(rfc010-permission-gate) note below.
*
* stdout is the protocol: this plugin must run in an example that loads NO
* stdout logger (the console logger writes to stdout and would corrupt the
* JSON-RPC frames). The guarantee is config-only — see the package README and
* RFC 010 § Risks.
*
* @module @deepseek-ai/dsh-acp
*/
import type { Context } from 'cordis'
import { Readable, Writable } from 'node:stream'
import { randomUUID } from 'node:crypto'
import { isAbsolute } from 'node:path'
import Schema from 'schemastery'
import {
AgentSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
RequestError,
type Agent as AcpAgent,
type AuthenticateRequest,
type CancelNotification,
type ContentBlock as AcpContentBlock,
type InitializeRequest,
type InitializeResponse,
type LoadSessionRequest,
type LoadSessionResponse,
type NewSessionRequest,
type NewSessionResponse,
type PromptRequest,
type PromptResponse,
type SessionNotification,
type Stream,
type StopReason,
} from '@agentclientprotocol/sdk'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence'
import {
acpPromptToText,
harnessBlockToAcpContent,
promptHasUnsupportedContent,
turnEndToStopReason,
} from './codec.ts'
export const name = 'acp'
// The bridge programs against the interface packages only (architecture rule:
// plugins never depend on dsh-agent-loop). `sessionPersistence` is required
// because `initialize` advertises `loadSession: true`.
export const inject = ['agents', 'sessions', 'sessionPersistence']
/**
* Build an ACP "invalid params" error whose human detail rides in the message.
* `RequestError.invalidParams(data, additionalMessage)` keeps the standard
* "Invalid params" message and appends `additionalMessage`, so we pass the
* detail as `additionalMessage` (and no structured `data`).
*/
function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
}
/**
* Build an ACP "internal error" whose human detail rides in the message. Used
* to reject a `session/prompt` whose turn ended in failure: a plain `Error`
* thrown from a method handler is flattened to a generic "Internal error" on
* the wire, so we wrap the detail in the SDK's `RequestError.internalError`
* (which appends `additionalMessage`) to surface *why* the turn failed.
*/
function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
}
/** Plugin config: the agent template ACP sessions are created from. */
export interface AcpConfig {
/** Model name for created agents (must have a registered adapter). */
model?: string
/** Per-agent system prompt. */
systemPrompt?: string
/** Agent/server name reported to the client in `initialize`. */
agentName?: string
/** Agent/server version reported to the client in `initialize`. */
agentVersion?: string
/**
* Transport stream override. Production omits this (the plugin wires
* `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an
* in-memory `Stream` (e.g. an `ndJsonStream` over a `Duplex` pair) to drive
* the bridge without a subprocess. Not part of the schemastery `Config` —
* it is a runtime-only seam, never set from a `cordis.yml`.
*/
stream?: Stream
}
export const Config: Schema<AcpConfig> = Schema.object({
model: Schema.string(),
systemPrompt: Schema.string(),
agentName: Schema.string().default('deepseek-harness-acp'),
agentVersion: Schema.string().default('0.0.1'),
})
/**
* Per-session bridge state. Single-entry in this MVP (RFC 011 makes the maps
* multi-entry); kept as a record from the start so RFC 011 generalizes the
* container, not the shape.
*/
interface SessionRecord {
sessionId: string
agent: Agent
/**
* The in-flight `session/prompt`, or `undefined` when none is pending. A
* prompt resolves with a {@link StopReason} or rejects with an Error (a
* turn that ended in failure). Settled exactly once via {@link settlePrompt}.
*
* `turn` is the loop turn number this prompt owns, captured from the log's
* `turn/start` after `send()`. Until then it is `undefined` (the turn has not
* begun). Only a `turn/end` whose turn number equals `turn` settles the prompt
* — so a *previous* prompt's late `turn/end` (e.g. an aborted turn whose end
* arrives after the next prompt is already installed) can never settle the
* wrong prompt. A direct cancel/dispose settle clears the whole in-flight slot,
* so a later stale `turn/end` finds no pending prompt.
*
* `logWatermark` is the session log length at the moment the prompt was
* installed (before `send()`). The settle-from-log fallback uses it to infer
* the owning `turn/start` from the canonical log even when the live
* `session/event` capture was starved (a peer listener that throws on
* `turn/start` — see `settleFromLog`): the prompt owns the FIRST `turn/start`
* appended at or after this watermark.
*/
inflight: {
resolve: (reason: StopReason) => void
reject: (error: Error) => void
turn: number | undefined
logWatermark: number
} | undefined
}
/**
* Drive the in-flight prompt's settle from the harness event stream. A turn
* can end three ways the bridge must all handle (AGENTS.md "honor cross-seam
* contracts on BOTH sides"): the normal `agent/turn-end` event; a `turn/end`
* session event WITHOUT the agent event (a boundary emit threw inside the loop,
* which still appends `turn/end`); or the agent erroring/settling to idle. The
* first of these to fire settles the prompt; `settle` is then cleared so the
* others are no-ops (settle-exactly-once).
*/
export function apply(ctx: Context, config: AcpConfig): void {
const agentName = config.agentName ?? 'deepseek-harness-acp'
const agentVersion = config.agentVersion ?? '0.0.1'
// Single live session for the MVP. RFC 011 turns this into maps keyed by
// sessionId plus an agent→sessionId reverse map for the permission gate.
let record: SessionRecord | undefined
// True while a `session/load` is between reserving the single-session slot and
// installing its `record` (resume() is async). The session guards check BOTH
// `record` and `loading` so a pipelined load/new cannot slip past while the
// first load's resume() is pending and leak a second live agent.
let loading = false
// Set once the bridge has torn down (disposal or client disconnect). An async
// `session/load` that was mid-`resume()` when teardown ran must observe this
// after its await and NOT install a `record` (which would resurrect a live
// agent/listeners after the bridge closed). Checked after every load await.
let closed = false
// Ownership marker: agents this bridge created. The deferred permission gate
// (TODO(rfc010-permission-gate)) and RFC 011 build on this; laid down now so
// the seam exists. A WeakMap so a disposed agent's entry is collectable.
const owned = new WeakMap<Agent, string>()
// Assigned at the bottom, before any agent event can fire (a session only
// exists after `newSession`, which the client calls after construction), so
// `notify` never observes it unset — no undefined guard needed.
let conn: AgentSideConnection
/**
* Reject any RPC after the bridge has torn down. The `AgentSideConnection`
* receive loop can outlive the plugin fiber — under an ACP-only HMR reload the
* `agents`/`agent-loop` services stay up while the bridge's `ctx.on` listeners
* and disposer are gone — so a late `session/new`/`load`/`prompt` could create
* or drive an agent the bridge can no longer stream or settle. Every
* state-affecting handler calls this first. (`initialize`/`authenticate` are
* pure/stateless and may answer harmlessly.)
*/
const assertOpen = (): void => {
if (closed) throw internalError('the ACP bridge has been disposed')
}
/** Resolve the live record for a sessionId, or throw an ACP error. */
const requireSession = (sessionId: string): SessionRecord => {
if (record === undefined || record.sessionId !== sessionId) {
throw invalidParams(`unknown session: ${sessionId}`)
}
return record
}
/** Push a `session/update` notification, swallowing post-close rejections. */
const notify = (notification: SessionNotification): void => {
// sessionUpdate returns a promise; a closed connection rejects it. The
// update is best-effort UI feed, never load-bearing for correctness, so a
// throwing/rejecting send must not break the turn (the chunk is emitted
// inside the model step — see AGENTS.md "contain callback exceptions").
/* v8 ignore next 3 -- the rejection only fires on a stdout/connection write
failure (closed pipe), which the in-memory test transport never induces;
the swallow is a defensive best-effort guard like the loop's emit traps */
void Promise.resolve(conn.sessionUpdate(notification)).catch((error: unknown) => {
ctx.logger.warn(`acp: session/update failed: ${String(error)}`)
})
}
/** Settle the in-flight prompt with a stop reason, exactly once (no-op if none pending). */
const settlePrompt = (rec: SessionRecord, reason: StopReason): void => {
const inflight = rec.inflight
if (inflight === undefined) return
rec.inflight = undefined
inflight.resolve(reason)
}
// --- Stream the harness event taxonomy to ACP session/update --------------
// All content streaming AND the prompt settle flow through `session/event`,
// the canonical log: every assistant/chunk and tool/call/result is logged, so
// translating from the log makes live streaming and `session/load` replay
// share the identical path (streamSessionEventUpdate). Both the owning-turn
// capture and the settle key off the log's own `turn/start`/`turn/end` — NOT
// the `agent/turn-start`/`agent/turn-end` EVENTS, which a throwing PEER
// listener (cordis `emit` stops at the first throw) or a boundary-emit failure
// can skip. `closeTurn` appends `turn/end` to the log unconditionally, and
// `turn/start` is appended before any step runs, so within this one listener
// we always see the prompt's turn-start (tag `inflight.turn`) then its
// turn-end (settle). A `turn/end` settles the prompt ONLY when it is the
// prompt's OWN turn (`inflight.turn === event.data.turn`) — a previous,
// already-cancelled turn whose end arrives late is ignored (see
// SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP
// has no error stop reason); other reasons resolve via the codec. Demux
// strictly by session id.
ctx.on('session/event', (session, event: SessionEvent) => {
const rec = record
if (rec === undefined || session.header.id !== rec.sessionId) return
streamSessionEventUpdate(rec.sessionId, event, notify)
const inflight = rec.inflight
if (inflight === undefined) return
if (event.type === 'turn/start') {
// Tag the in-flight prompt with its owning turn — but ONLY a
// `message`-triggered turn (the kind a `send()` prompt produces). A turn
// a plugin opens between prompt-install and the prompt's own turn (an idle
// `agent.inject()` writes a one-shot `injection`-triggered turn) must NOT
// be mistaken for the prompt's turn, or its turn/end would settle the RPC
// early. The first message turn at/after install owns the prompt
// (`turn === undefined` guard); the loop batches queued messages into one
// turn, so there is exactly one.
if (inflight.turn === undefined && event.data.trigger.kind === 'message') {
inflight.turn = event.data.turn
}
return
}
// Settle only on the OWNING turn's end.
if (event.type !== 'turn/end' || inflight.turn !== event.data.turn) return
rec.inflight = undefined
const reason = event.data.reason
if (reason.kind === 'error') {
inflight.reject(internalError(`turn failed: ${reason.message}`))
} else {
inflight.resolve(turnEndToStopReason(reason))
}
})
// Settle fallback: a `session/event` listener registered BEFORE ACP that
// throws (on `turn/start` OR `turn/end`) would, via cordis `emit`'s
// stop-on-throw, starve ACP's listener above — the prompt would hang or, if
// only the turn number was missed, settle as the wrong outcome. So when the
// agent settles to `idle` (or is disposed), reconcile against the canonical
// log: determine the prompt's owning turn (the captured `turn`, or — if the
// live capture was starved — the FIRST `turn/start` appended at/after the
// install-time `logWatermark`), then settle from that turn's `turn/end`
// (reject on error, resolve via codec), or `cancelled` if no owning turn ever
// started. Never double-settles — clears `inflight` first.
const settleFromLog = (rec: SessionRecord): void => {
const inflight = rec.inflight
if (inflight === undefined) return
const events = rec.agent.session.events
// The owning turn number: the captured one, or — if the live capture was
// starved — inferred from the log as the first MESSAGE-triggered turn opened
// at/after the watermark. The message-trigger filter matches the live
// capture: a one-shot `injection` turn a plugin may open between
// prompt-install and the prompt's turn is NOT the prompt's turn. Undefined
// only if no message turn ever started for this prompt.
const owningTurn = inflight.turn ?? events.slice(inflight.logWatermark).find(
(e): e is Extract<SessionEvent, { type: 'turn/start' }> =>
e.type === 'turn/start' && e.data.trigger.kind === 'message',
)?.data.turn
// The owning turn's end in the log. If `owningTurn` is undefined (no turn
// ever started for this prompt — a torn-down-before-turn case that quiesce's
// direct settle normally pre-empts), no `turn/end` matches (turn numbers are
// >= 1) and `findLast` returns undefined, falling through to cancelled.
const end = events.findLast(
(e): e is Extract<SessionEvent, { type: 'turn/end' }> =>
e.type === 'turn/end' && e.data.turn === owningTurn,
)
rec.inflight = undefined
if (end === undefined) {
// No owning turn / no clean turn/end (torn down mid-turn) → cancelled.
inflight.resolve('cancelled')
return
}
const reason = end.data.reason
if (reason.kind === 'error') {
inflight.reject(internalError(`turn failed: ${reason.message}`))
} else {
inflight.resolve(turnEndToStopReason(reason))
}
}
// On a settle to idle/disposed, reconcile any still-pending prompt from the
// log (covers a starved `session/event` listener — see settleFromLog). A mid-
// step disposal that never appended a clean turn/end resolves `cancelled`.
ctx.on('agent/status', (agent, status: AgentStatus) => {
const rec = record
if (rec === undefined || owned.get(agent) !== rec.sessionId) return
if (status === 'idle' || status === 'disposed') settleFromLog(rec)
})
// --- The ACP Agent method surface -----------------------------------------
const makeAgent = (connection: AgentSideConnection): AcpAgent => {
conn = connection
return {
initialize(params: InitializeRequest): Promise<InitializeResponse> {
// Echo the client's version if we support it, else our own. We support
// exactly PROTOCOL_VERSION; any other requested version negotiates
// down to ours (the client disconnects if it can't speak it).
const protocolVersion = params.protocolVersion === PROTOCOL_VERSION ? params.protocolVersion : PROTOCOL_VERSION
return Promise.resolve({
protocolVersion,
agentInfo: { name: agentName, version: agentVersion },
agentCapabilities: {
loadSession: true,
// text-only: no image/audio/embeddedContext, no mcpCapabilities
promptCapabilities: { image: false, audio: false, embeddedContext: false },
},
authMethods: [],
})
},
authenticate(_params: AuthenticateRequest): Promise<void> {
// No auth methods advertised; nothing to do. Present because the SDK
// Agent interface requires it.
return Promise.resolve()
},
newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
assertOpen()
if (record !== undefined || loading) {
throw invalidParams('this agent supports a single session; a session already exists (RFC 011 will lift this)')
}
validateWorkspaceParams(params)
const sessionId = randomUUID()
const agent = ctx.agents.create({
agentId: sessionId,
sessionId,
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
})
owned.set(agent, sessionId)
record = { sessionId, agent, inflight: undefined }
return Promise.resolve({ sessionId })
},
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
assertOpen()
if (record !== undefined || loading) {
throw invalidParams('this agent supports a single session; a session already exists (RFC 011 will lift this)')
}
validateWorkspaceParams(params)
// Reserve the single-session slot BEFORE the await. Without this, two
// pipelined load/new requests could both pass the guard above while the
// first load's resume() is pending, then both install a record and leak
// a second live agent. `loading` claims the slot; it is cleared in
// `finally` so a rejected load (bad id, cwd mismatch) never wedges all
// future sessions on this connection.
loading = true
try {
// Validate the PERSISTED cwd BEFORE resuming — `list()` is a
// metadata-only read (no full-log parse) — so a mismatch rejects
// without ever constructing/registering a live agent (which would
// then leak in `ctx.agents`/`ctx.sessions` with no disposer here).
// A session persisted in workspace A must not be loaded by a server
// launched in workspace B: it would replay A's history while tools run
// in B. (If the id is unknown to `list()`, fall through to resume,
// which rejects with the backend's not-found error.)
const meta = (await ctx.sessionPersistence.list()).find(m => m.id === params.sessionId)
if (meta?.cwd !== undefined && meta.cwd !== process.cwd()) {
throw invalidParams(
`session was created in ${meta.cwd}, but the server's launch directory is ${process.cwd()}; honoring a different cwd is not yet supported — launch the server in the session's workspace`,
)
}
const agent = await ctx.agents.resume({
agentId: params.sessionId,
resumeSessionId: params.sessionId,
agentOptions: agentOptions(config),
})
// The bridge may have torn down (disposal / client disconnect) while
// resume() was pending. Its listeners are gone, so installing `record`
// now would resurrect a live agent the bridge can no longer drive or
// tear down. Bail: the just-resumed agent is reclaimed with the host
// context (no per-agent disposer — TODO(rfc010-agent-disposal)).
/* v8 ignore next 3 -- the in-memory test transport rejects the in-flight
session/load request the instant it closes (before this post-await
code runs), so the guard can't be hit in tests; it protects the real
stdio path, where a closed pipe need not reject a mid-flight handler. */
if (closed) {
throw invalidParams('connection closed during session/load')
}
owned.set(agent, params.sessionId)
record = { sessionId: params.sessionId, agent, inflight: undefined }
// Replay the persisted event log to the client as session/update. Use
// the raw event log (NOT deriveMessages, which drops assistant/chunk
// and trace events): RFC 010's load contract reconstructs the streamed
// turns — user prompts (user/message → user_message_chunk), assistant
// text and reasoning (assistant/chunk), and tool calls/results.
for (const event of agent.session.events) {
streamSessionEventUpdate(params.sessionId, event, notify)
}
return {}
} finally {
loading = false
}
},
async prompt(params: PromptRequest): Promise<PromptResponse> {
assertOpen()
const rec = requireSession(params.sessionId)
if (rec.inflight !== undefined) {
throw invalidParams('a prompt is already in flight for this session')
}
if (promptHasUnsupportedContent(params.prompt)) {
throw invalidParams('only text prompt content is supported (text-only promptCapabilities); image/audio/resource blocks are rejected rather than silently dropped')
}
const text = acpPromptToText(params.prompt)
if (text.trim().length === 0) {
// Reject up front rather than calling send(): an empty prompt would
// queue no work, no turn would start, and the RPC would hang forever
// waiting for a settle that never comes.
throw invalidParams('empty prompt')
}
// Install the in-flight slot BEFORE send() (send does not synchronously
// flip status to running; the session/event listener records the turn
// number and settle/rejects it). Capture the log length now as the
// watermark: the settle-from-log fallback infers the owning turn/start
// as the first one appended at/after it, surviving a starved live
// capture. A turn that ends in error rejects this promise (the codec
// never produces an error stop reason).
const stopReason = await new Promise<StopReason>((resolve, reject) => {
rec.inflight = { resolve, reject, turn: undefined, logWatermark: rec.agent.session.events.length }
rec.agent.send([{ type: 'text', text }])
})
return { stopReason }
},
cancel(params: CancelNotification): Promise<void> {
const rec = record
if (rec === undefined || rec.sessionId !== params.sessionId) return Promise.resolve()
// RFC 010: session/cancel maps to agent.abort(reason). This aborts a
// RUNNING step (the turn ends 'aborted' → 'cancelled' via turn-end).
// It also settles the in-flight prompt as cancelled directly, in case
// the abort lands in the pre-step window (queued-but-not-started) where
// abort() has no AbortController to signal — see the README
// TODO(rfc010-cancel-prestep): a not-yet-started queued turn may still
// run to completion until a loop-level cancel lands. Best-effort abort
// plus honest RPC/UI cancellation. A secondary consequence of that same
// gap: because the loop batches all queued messages into one turn, a
// prompt accepted right after a pre-step cancel can be merged into the
// same turn as the cancelled one — that turn then carries both prompts'
// text and the new prompt settles for it. Both are closed by the same
// queue-aware loop cancel; the single-in-flight rule bounds the blast
// radius to one extra prompt.
rec.agent.abort('session/cancel')
settlePrompt(rec, 'cancelled')
return Promise.resolve()
},
}
}
// --- Connection lifecycle --------------------------------------------------
// The transport stream. Production wires stdio (stdout carries the protocol);
// tests inject an in-memory pipe pair via config.stream to drive the bridge
// without a subprocess. ndJsonStream is the SDK's stdio framing helper. The
// AgentSideConnection constructor synchronously invokes makeAgent (assigning
// the outer `conn`), so `conn` is set before any agent method runs.
/* v8 ignore next 4 -- production stdio wiring; tests always inject config.stream */
const stream: Stream = config.stream ?? ndJsonStream(
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
)
conn = new AgentSideConnection(makeAgent, stream)
/**
* Tear the live session down to quiescence (AGENTS.md "dispose must reach
* quiescence"): settle any pending prompt `cancelled`, abort the agent, and
* AWAIT it draining via the interface-level `whenIdle()` signal (NOT
* `agent/status('disposed')`, which fires before the driver exits). Idempotent
* — clears `record` first, so a second call (close racing dispose) is a no-op.
* Shared by Cordis disposal AND client disconnect (`conn.closed`).
*
* Caveat (same window as TODO(rfc010-cancel-prestep)): if teardown lands in
* the pre-step window — `agent.send()` queued a turn but the loop has not yet
* flipped to `running` — `abort()` has no live `AbortController` to signal and
* `whenIdle()` returns immediately (status is still `idle`), so that queued
* turn may still start and run after teardown returns. Reaching true
* quiescence in that window needs a queue-aware loop cancel primitive (a
* loop-level change, out of the RFC 010 MVP scope); for `newSession` agents
* the worst case is one short queued turn, since the bridge enforces a single
* in-flight prompt.
*
* The agent itself is NOT individually disposed/unregistered here — the
* factory (`ctx.agents.create`/`resume`) registers it on the AgentLoop fiber
* and returns no per-agent disposer, so the registry entry is reclaimed when
* the host context disposes. On a bare client disconnect (without a host
* dispose) the idled agent therefore lingers in `ctx.agents` until shutdown;
* since the MVP is single-session-per-connection and a reconnect spins up a
* fresh context, this does not strand work. A per-agent disposal seam is
* RFC 011 follow-up (TODO(rfc010-agent-disposal)).
*/
let quiescing: Promise<void> | undefined
const quiesce = (): Promise<void> => {
// Memoize: disposal and client-disconnect can both fire. The first call owns
// the teardown; later callers await the SAME promise so `fiber.dispose()`
// never returns before an in-flight close teardown has finished (using
// `record === undefined` as the only guard would let the second caller
// return early while the first is still awaiting whenIdle()).
if (quiescing !== undefined) return quiescing
// Mark closed BEFORE the record check: a `session/load` mid-`resume()` (no
// record installed yet) must observe this after its await and refuse to
// install a post-teardown record. Set even when there is nothing else to do.
closed = true
const rec = record
record = undefined
if (rec === undefined) return Promise.resolve()
quiescing = (async () => {
settlePrompt(rec, 'cancelled')
rec.agent.abort('disposed')
await rec.agent.whenIdle()
})()
return quiescing
}
// Client disconnect: when the ACP transport closes (editor quits, pipe EOF),
// the in-flight turn would otherwise keep running and its `session/update`
// writes would be silently swallowed by `notify()`. Tear the session down so
// a vanished client does not leave an orphaned running agent. `conn.closed`
// rejects/resolves once; contain any teardown throw (nothing else can act on
// it — the connection is already gone). The Cordis disposer below still runs
// on normal shutdown and is idempotent with this.
/* v8 ignore start -- the .catch arrow is a defensive guard: conn.closed
settling rejected or quiesce() throwing on an already-closed connection is
not reproducible through the in-memory test transport (it never severs
mid-run), and there is nothing else to act on once the connection is gone —
the swallow mirrors notify(). */
void conn.closed.then(quiesce).catch((error: unknown) => {
ctx.logger.warn(`acp: connection-close teardown failed: ${String(error)}`)
})
/* v8 ignore stop */
ctx.effect(() => quiesce, 'acp.connection')
}
/**
* Build per-agent options from the plugin config, omitting absent fields
* (exactOptionalPropertyTypes: never assign `undefined` to an optional key).
* Exported for unit coverage of both the present and absent branches.
*/
export function agentOptions(config: AcpConfig): { model?: string; systemPrompt?: string } {
return {
...config.model !== undefined ? { model: config.model } : {},
...config.systemPrompt !== undefined ? { systemPrompt: config.systemPrompt } : {},
}
}
/**
* Validate `session/new` params per the MVP contract: `cwd` absolute AND equal
* to the server's launch directory (there is no path from session cwd to the
* bash workdir yet — RFC 010 § Deferred — so the server must be launched in the
* workspace root, and we error loudly rather than silently run tools in the
* wrong directory); `additionalDirectories` empty (we cannot widen filesystem
* scope yet, and silently ignoring them would desync the client's scope UI).
*/
/**
* Validate the MVP `cwd`/`additionalDirectories` contract shared by
* `session/new` and `session/load`: `cwd` must be absolute AND equal the
* server's launch directory (there is no path from session cwd to the bash
* workdir yet — RFC 010 § Deferred — so the server must be launched in the
* workspace root, and we error loudly rather than silently run tools in the
* wrong directory); `additionalDirectories` must be empty (we cannot widen
* filesystem scope yet, and silently ignoring it would desync the client's
* scope UI). Both request shapes carry `cwd: string` and
* `additionalDirectories?: string[]`, so one validator covers both.
*/
function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] }): void {
if (!isAbsolute(params.cwd)) {
throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
}
if (params.cwd !== process.cwd()) {
throw invalidParams(
`cwd must equal the server's launch directory (${process.cwd()}); honoring an arbitrary cwd is not yet supported — launch the server in the workspace root`,
)
}
if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
throw invalidParams('additionalDirectories is not supported in this MVP')
}
}
/**
* Translate a single harness {@link SessionEvent} into the `session/update`
* notification(s) it produces, pushing each via `notify`. Shared by live
* streaming (`session/event`) and `session/load` replay so both paths emit an
* identical update stream from the same event log.
*
* - `assistant/chunk` text-delta/reasoning-delta → message/thought chunks
* - `user/message` → `user_message_chunk` (text blocks) — so a `session/load`
* replay reconstructs the USER side of each turn, not just the agent's
* - `tool/call` → `tool_call` (pending)
* - `tool/result` → `tool_call_update` (completed/failed)
*
* Other event types (turn/step boundaries, context/message, usage, …) produce
* no client update.
*/
export function streamSessionEventUpdate(
sessionId: string,
event: SessionEvent,
notify: (notification: SessionNotification) => void,
): void {
switch (event.type) {
case 'assistant/chunk': {
const chunk = event.data.chunk
if (chunk.type === 'text-delta') {
notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: chunk.text } } })
} else if (chunk.type === 'reasoning-delta') {
notify({ sessionId, update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: chunk.text } } })
}
return
}
case 'user/message': {
// Replay the user's prompt so a loaded session shows both sides of each
// turn. Only text blocks carry inline content the bridge surfaces (the
// prompt path is text-only); other block kinds produce no chunk.
for (const block of event.data.content) {
const content = harnessBlockToAcpContent(block)
if (content !== undefined) {
notify({ sessionId, update: { sessionUpdate: 'user_message_chunk', content } })
}
}
return
}
case 'tool/call': {
notify({
sessionId,
update: {
sessionUpdate: 'tool_call',
toolCallId: event.data.callId,
title: event.data.name,
kind: toolKindFor(event.data.name),
status: 'in_progress',
rawInput: parseToolArguments(event.data.arguments),
},
})
return
}
case 'tool/result': {
notify({
sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: event.data.callId,
status: event.data.isError ? 'failed' : 'completed',
content: toolResultContent(event.data.content),
},
})
return
}
// turn/step boundaries, context/message, steering, usage, error,
// assistant/message — no direct ACP client update.
default:
return
}
}
/** Map a harness tool name to an ACP ToolKind (best-effort; default `other`). */
function toolKindFor(name: string): 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other' {
if (name === 'bash' || name === 'bash_output' || name === 'bash_kill') return 'execute'
if (name === 'read' || name.startsWith('read')) return 'read'
if (name === 'write' || name === 'edit' || name.startsWith('edit')) return 'edit'
return 'other'
}
/** Parse a tool-call arguments JSON string for `rawInput`; raw string on failure. */
function parseToolArguments(args: string): unknown {
try {
return args ? JSON.parse(args) : {}
} catch {
// The model produced non-JSON arguments; surface the raw string rather
// than dropping it. (The harness tool layer handles validation; here we
// only feed the client's tool-call UI.)
return args
}
}
/** Map harness tool-result content blocks to ACP tool-call content (text only). */
function toolResultContent(blocks: ContentBlock[]): { type: 'content'; content: AcpContentBlock }[] {
const out: { type: 'content'; content: AcpContentBlock }[] = []
for (const block of blocks) {
const content = harnessBlockToAcpContent(block)
if (content !== undefined) out.push({ type: 'content', content })
}
return out
}
export default apply