docs: tighten prose audit after master retarget

This commit is contained in:
Tianyi Cui
2026-07-13 16:24:32 +08:00
parent 17e04a1c70
commit c45d7927cf
192 changed files with 1047 additions and 4078 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-acp
The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target.
It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
@@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session.
The plugin injects `agents`, `sessions`, `sessionPersistence`, `tools`, and `userInteraction`, never the concrete loop. Persistence backs `session/load`; tool definitions own presentation; user interaction maps agent questions to ACP forms.
### Config
@@ -26,60 +26,47 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
|---|---|---|
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` |
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
| `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message` → `user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently). The resumed session keeps its PERSISTED header `cwd`, so its bash tools run in the original workspace; the requested `cwd` must be absolute and match the persisted `cwd`. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load |
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, and replays user, assistant, and tool events |
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) |
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, and tool render intents |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
| `session/request_permission` | `approval/request` listener | the bridge is the [`ctx.approval`](../user-approval/README.md) answerer for the agents it owns: an `ask` (a hook or `tools/pre-execute` plugin) becomes an editor prompt attached to the streamed tool call, offering one-shot `allow_once`/`reject_once` options only; a foreign or call-less request delegates down the answerer chain (fail-closed `unavailable` default). See "Permission prompts" |
| `session/request_permission` | `approval/request` listener | answers one-shot requests for bridge-owned calls and delegates others |
| `session/set_config_option` | `setSandboxMode` / `setApprovalPolicy` | per-session knob switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
## Multi-session
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so agent-scoped approval events demultiplex in O(1). Every `session/event` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there.
Forward and reverse indexes route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md).
## Session config options
The bridge advertises `sandbox-mode` and `approval-policy` only when their services are composed. Current values fold from each session's log over the composition default, so load restores overrides directly. `session/set_config_option` validates against the closed vocabulary, calls the domain writer, and returns refreshed state. Changes inside an open turn append immediately; idle changes are coalesced in memory and anchored at the next `agent/prompt-submit`, preserving turn enclosure and event order. A crash before anchoring discards the pending change, and load reports durable log truth. See the [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload.
Background bash tasks use the session id as an opaque owner token, so one session cannot inspect or stop another's task. That contract belongs to [`dsh-tool-bash`](../../bash/tool-bash/).
## Per-session cwd
Each session runs in its own workspace, recorded as the session's `SessionHeader.cwd`. On `session/new` the (absolute) request `cwd` becomes that header cwd; on `session/load` the resumed session keeps its PERSISTED header cwd and the request `cwd` must be absolute and equal to it, so the editor and bash executor agree on the workspace before an agent is constructed. A load whose persisted session has no absolute cwd is REJECTED up front via a metadata-only `list()` check, BEFORE resume constructs an agent (else bash would silently fall back to the server's launch dir, and a post-resume reject would leak the registered agent). `dsh-tool-bash` then defaults the bash workdir to the calling agent's `session.header.cwd` (an explicit model `workdir` still wins; a relative one resolves against the session cwd; with no session cwd the executor falls back to its own config / `process.cwd()`). So the server no longer has to be launched in the workspace — an editor can open any project folder, and N sessions over one connection can each target a different directory. (`additionalDirectories` is still rejected: widening the tool/filesystem scope beyond the single cwd is a separate sandbox concern.)
`session/new` records the request's absolute cwd in the session header. `session/load` requires an absolute request cwd matching persisted metadata and rejects missing or mismatched metadata before constructing an agent. Bash defaults to that workspace; an explicit relative workdir resolves against it. `additionalDirectories` remains unsupported.
## Tool-call presentation
How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state) and `presentResult(args, result)` (completed state) on its `dsh-tools` definition, each returning a **`card`-tagged render intent** — a discriminated union the bridge switches on. `presentCall` returns a `ToolCallView`, one of three cards:
- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, a `kind` for the icon, the salient `rawInput` for a detail view, optional `content` blocks shown alongside, and optional `locations` (`FileLocation[]` = `{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along).
- `{ card: 'terminal', title, description?, cwd? }` — a shell command → a terminal card (see Terminal card).
- `{ card: 'diff', title, diffs, locations? }` — a file create/modify → an inline diff card; `diffs` is `FileDiff[]` (`{ path, oldText, newText }`, `oldText: null` ⇒ new file). The bridge emits each diff as an ACP `{ type: 'diff', path, oldText, newText }` `tool_call.content` block, which Zed renders as an inline diff / new-file preview.
`presentResult` returns a generic, terminal, or diff card. The bridge switches on `view.card`; absent presentation falls back to a generic card without inspecting the tool name. Foreground bash uses terminal cards, filesystem writes and edits use diff cards, and reads use generic cards with locations. File-card titles are relativized against the session cwd, while `locations` and diff paths remain raw so clients can open the real file. Result content replaces the pending call card, so successful mutations always provide their final diff.
The `tool/result` session event does not carry the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones.
Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation).
## Terminal card (capability-gated)
A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output and an exit-status pill — rather than a plain text block. The tool asks for this with the `terminal` card variant of its render intent (`dsh-tools`: `{ card: 'terminal', title, description?, cwd? }` from `presentCall`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` from `presentResult`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`:
- `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the card's explicit absolute `cwd`, else a relative `cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). The card's `description` renders as a content block BEFORE the terminal block, so the description sits above the card.
- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the terminal card's `output`) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the card reported a structured `exitCode`/`signal`. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call.
When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries a ` ```console ` text block the bridge DERIVES by fencing the terminal result's `output` (the tool no longer double-encodes the fences) — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [the render-intent-union RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata; result text is omitted because ACP updates replace call content. Other clients receive a generic card and fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
## Settle-exactly-once
A `session/prompt` resolves or rejects exactly once from the canonical `session/event` stream. The listener captures the prompt's owning turn from `turn/start` and settles in a `finally` block when the matching `turn/end` is appended, so a presentation/streaming failure cannot strand the RPC after the durable terminal event exists. Correlation by turn id prevents a late end from a cancelled prompt from settling its successor. A turn ending in `error` rejects the RPC with an internal error carrying the failure message because ACP has no error stop reason; every other reason resolves through the codec. An empty or whitespace-only prompt is rejected before enqueue because it would start no turn and otherwise leave the RPC pending.
A prompt captures its owning turn and settles exactly once from the matching durable `turn/end`, even if presentation failed. Turn correlation excludes stale endings. Error turns reject with an ACP internal error; empty prompts reject before enqueue.
## Permission prompts
The bridge registers an `approval/request` waterfall listener — the ACP answerer of the [user-approval seam](../user-approval/README.md). When `ctx.approval` routes an `ask` for an agent the bridge owns, the listener resolves the owning session through the reverse map and issues `session/request_permission` with the request's `callId` as the `toolCall` reference (the editor attaches the prompt to the already-streamed call) and the one-shot options `allow_once`/`reject_once` (`allow_always` is deferred to the approval RFC's grant-storage question). Outcomes map `allow-once → allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled → cancelled`. A request for an agent the bridge does NOT own — or one without a `callId` to attach to — delegates via `next()` so another answerer or the seam's fail-closed `unavailable` default takes it. A rejected `requestPermission` RPC (client gone mid-prompt) propagates to the ApprovalService, which contains it as `unavailable`. Whether a call asks at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment; without such policy, tools keep the executor's full authority.
For a bridge-owned call, the [approval seam](../user-approval/README.md) maps `ask` to an editor prompt with one-shot allow/reject options. Foreign or call-less requests delegate; unknown choices never grant, cancellation stays cancellation, and transport failure becomes fail-closed unavailability. Whether a tool asks remains policy outside the bridge.
## Disposal & disconnect
Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../../core/agent/README.md) `dispose()` — which stops the loop (sets `disposed` + aborts the in-flight step), `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. A turn cut off mid-flight by teardown ends with reason `disposed` (not `aborted` — `dispose()` uses the disposed path, not `session/cancel`'s queue-aware `cancel()`). The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise).
Disposal and client disconnect share one memoized teardown. It cancels pending prompts and disposes all owned agent handles in parallel, waiting for loop exit and final flush before registry removal. Mid-turn teardown records `disposed`; `session/cancel` records `aborted`.
## Known limitations (tracked TODOs)

View File

@@ -36,13 +36,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
}
/**
* 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; `resource_link` is an
* ACP prompt-only input rendered into text by {@link acpPromptToText};
* `reasoning` is surfaced via `agent_thought_chunk`
* streaming rather than as a message block, and `tool-call`/`tool-result`
* are handled by the tool-call update path.
* Map replayable text to ACP message content. Other block kinds use their
* prompt, thought-stream, or tool-update paths.
* @param block - the harness content block to translate.
* @returns the ACP block, or `undefined` for a kind with no message-content mapping.
*/

View File

@@ -1,37 +1,7 @@
/**
* 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.cancel()` (the queue-aware cancel: aborts a
* running step, clears queued + steering work, and drops a
* turn about to start) + settle the in-flight prompt
*
* Multi-session (RFC 011): N concurrent sessions per connection, each mapped to
* its own `ReactLoopAgent`. Sessions are keyed by id in `sessions` (forward) with an
* `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every
* `session/event` and `agent/*` event is routed strictly to its owning session
* record, so two sessions streaming at once never interleave their
* `session/update` notifications. Permission prompts ride the same ownership
* map: the bridge answers `approval/request` for its own agents over
* `session/request_permission` (see the approval answerer below) — whether a
* call ASKS is policy (a hook or plugin returning `ask`), not the bridge's.
*
* 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.
*
* Multi-session ACP server bridge over JSON-RPC stdio. Creates or resumes
* agents, routes their events, settles prompts by turn, and answers approvals.
* Stdout is reserved for protocol frames.
* @module @deepseek-ai/dsh-acp
*/
@@ -102,30 +72,15 @@ import {
} 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`. `tools` lets a tool own
// how its calls render (`presentCall`/`presentResult`); the bridge looks up the
// definition by name and falls back to a generic presentation when absent.
// Interface services required by advertised ACP capabilities.
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']
/**
* 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`).
*/
/** Build an ACP invalid-params error with visible human detail. */
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.
*/
/** Build an ACP internal error with visible human detail. */
function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
}
@@ -248,13 +203,7 @@ function stringArrayContent(
export interface AcpConfig {
/** Model name for created agents (must have a registered adapter). */
model?: 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`.
*/
/** Runtime-only transport override for tests; production uses stdio. */
stream?: Stream
}
@@ -262,70 +211,23 @@ export const Config: Schema<AcpConfig> = Schema.object({
model: Schema.string(),
})
/**
* Per-session bridge state. One per live ACP session; held in the `sessions`
* map keyed by id (RFC 011 multi-session).
*/
/** Per-session bridge state keyed by ACP session id. */
interface SessionRecord {
sessionId: SessionId
agent: Agent
/**
* The owned-agent disposer (from the {@link AgentHandle} the factory returned).
* Teardown calls it to unregister this ONE agent, stop its loop, await
* quiescence, and remove its session — instead of leaving it for the bridge
* fiber to reclaim.
*/
/** Owned-agent disposer that reaches per-session quiescence. */
dispose: () => Promise<void>
/**
* Resolves tool-owned presentation for THIS session's tool calls and remembers
* each in-flight call's `(name, args)` so the matching `tool/result` can find
* its tool. Per-session so two concurrent sessions never cross their in-flight
* tool state.
*/
/** Per-session tool presenter and in-flight call correlation. */
presenter: ToolPresenter
/**
* Whether THIS session renders shell tools as terminal cards — snapshotted
* from the client's `_meta.terminal_output` capability at session creation
* (`session/new`/`session/load`), NOT re-read live. A capability snapshot per
* session means the `tool_call` (which registers the terminal) and the matching
* `tool_call_update` (which streams its output) ALWAYS agree, even if a later
* `initialize` mutates the connection-level capability between them — otherwise
* a re-`initialize` mid-call could orphan a `terminal_output` (call non-terminal,
* result terminal) or clobber the card (call terminal, result non-terminal).
*/
/** Session-creation snapshot of terminal-card support for call/result consistency. */
terminalEnabled: boolean
/**
* 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 by its matching
* `turn/end`, direct cancellation, or teardown.
*
* `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.
*
*/
/** In-flight prompt and its captured turn number for exact settlement. */
inflight: {
resolve: (reason: StopReason) => void
reject: (error: Error) => void
turn: number | undefined
} | undefined
/**
* Config switches accepted while the session was IDLE, not yet anchored in
* its log. The turn-enclosure contract makes a bare between-turns append
* invalid (the JSONL backend treats a post-`turn/end` tail as crash
* garbage, and dev invariants throw), so an idle switch waits here and is
* anchored at the next turn's prompt-submit — before anything in that
* turn assembles a prompt or runs a call, and last write
* per knob wins (an idle flip-flop anchors as one event). Until anchored,
* the switch lives only in bridge memory: the set/new/load responses
* overlay it truthfully, and a restart before the next turn reverts it —
* which `session/load` then reports honestly from the log's fold.
*/
/** Idle config changes awaiting a turn-enclosed log anchor; last write wins. */
pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy }
}
@@ -336,41 +238,23 @@ interface SessionRecord {
* correlation in a `finally` so presentation failure cannot starve settlement.
*/
export function apply(ctx: Context, config: AcpConfig): void {
// Capture the injected services NOW, during apply(), while we are inside this
// plugin's fiber (where `inject` grants access). The ACP method handlers run
// LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is
// NOT this fiber's injection scope — so reading `ctx.agents` / `ctx.logger` /
// `ctx.sessionPersistence` lazily inside a handler throws "cannot get property
// … without inject". Resolving the references here and closing over them keeps
// the handlers working regardless of which fiber later invokes them.
// Capture injected services while executing inside this plugin's fiber.
const agents = ctx.agents
const sessionPersistence = ctx.sessionPersistence
const logger = ctx.logger
const tools = ctx.tools
const userInteraction = ctx.userInteraction
// A new ToolPresenter per session (and a throwaway per load replay), each given
// this warn sink so a throwing tool presenter is logged, not propagated.
// Presenter failures are logged and contained per session or replay.
const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent)
// Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId
// reverse map so `agent/*` events (which carry only the Agent) demux in O(1).
// The two stay in lockstep: a record is added to `sessions` and the agent to
// `bySession` together, and removed together.
// Keep forward and reverse session indexes in lockstep.
const sessions = new Map<SessionId, SessionRecord>()
const bySession = new WeakMap<Agent, SessionId>()
// Session ids whose `session/load` is mid-`resume()` (the slot is reserved
// before the async resume so a pipelined load/new for the SAME id can't create
// two agents). Distinct ids load concurrently; a given id loads once at a time.
// Reserve ids across asynchronous resume; distinct ids still load concurrently.
const loadingIds = new Set<SessionId>()
// Set once the bridge has torn down (disposal or client disconnect). An async
// `session/load` 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.
// Post-await checks prevent a closing bridge from publishing resumed sessions.
let closed = false
// Whether the client advertised the Zed `_meta.terminal_output` capability in
// `initialize`. When true, a tool's terminal presentation is rendered as a
// terminal card (content + `_meta.terminal_*`); when false, the bridge uses
// the tool's text fallback. Set once in `initialize`, read on every tool event.
// Connection-level capability copied into each new session record.
let terminalOutputCap = false
// Assigned at the bottom, before any agent event can fire (a session only
@@ -1140,12 +1024,7 @@ export function streamSessionEventUpdate(
}
/**
* Map a harness todo list to an ACP `plan` body. ACP's `PlanEntry` requires
* `content` + `priority` + `status`, but a {@link TodoItem} carries no priority,
* so synthesize a constant `'medium'` on every entry; `status` maps 1:1 (the
* harness status triple IS `PlanEntryStatus`). The ACP client REPLACES its whole
* plan on each `plan` update, matching the harness's whole-list-replace
* semantics, so no per-entry diffing is needed.
* Map a whole harness todo list to an ACP plan, assigning medium priority.
* @param todos - the harness todo list (the whole list, not a diff).
* @returns the ACP plan body, one entry per todo.
*/
@@ -1153,14 +1032,7 @@ export function todosToPlan(todos: TodoItem[]): Plan {
return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) }
}
/**
* Per-connection terminal-rendering context threaded into
* {@link streamSessionEventUpdate}: whether the client advertised the
* `_meta.terminal_output` capability, and the session's workspace cwd (the
* default terminal-card header when a tool doesn't supply its own). Kept out of
* the pure translator's required params so the no-capability / no-presenter
* tests stay terse.
*/
/** Terminal-card capability and workspace context for event rendering. */
export interface TerminalRendering {
enabled: boolean
/** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */
@@ -1171,59 +1043,30 @@ export interface TerminalRendering {
const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined }
/**
* Resolves tool-owned presentation for a session's tool-call events. A tool
* declares `presentCall`/`presentResult` (see `dsh-tools`) returning a
* `card`-tagged {@link ToolCallView}/{@link ToolResultView}; this looks them up
* by name in the registry and applies a generic fallback when a tool defines
* neither. The returned view is what {@link streamSessionEventUpdate} switches on.
*
* The `tool/result` session event does NOT carry the tool name or args — so to
* call a tool's `presentResult` (which needs both), the presenter remembers each
* `tool/call`'s `{ name, args, card }` keyed by callId and looks it up on the
* matching result. The map is bridge-LOCAL (not a change to the event schema or a
* core service): one presenter per live session
* (and a throwaway per `session/load` replay), and each entry is removed when its
* result arrives. In the normal loop a `tool/call` is always followed by a
* `tool/result` (the registry turns even a thrown tool into an isError result),
* so the map holds only currently-in-flight calls. The one exception is a step
* torn down mid-tool (an abort between `tool/call` and `tool/result`), which can
* leave a single stale entry per such call; this is bounded by the session
* lifetime (the whole presenter is dropped on teardown) and never affects
* correctness — a later result for a different callId is unaffected, and the
* stale entry's only cost is one map slot until the session ends.
* Resolve tool-owned call/result views with generic fallbacks. Per-session
* call-id state supplies the tool name and arguments omitted from result events.
*/
export class ToolPresenter {
private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>()
/**
* @param tools the registry to resolve tool definitions by name.
* @param onError invoked when a tool's `presentCall`/`presentResult` THROWS;
* the presenter swallows the error and falls back to the generic
* presentation so a buggy display callback can never fail a live turn or a
* `session/load` replay (docs/defensive-patterns.md "contain callback exceptions at the
* boundary"). Defaults to a no-op for callers that don't supply a logger.
* @param onError receives contained presenter failures before generic fallback.
*/
constructor(
private readonly tools: Pick<ToolRegistry, 'get'>,
private readonly onError: (message: string) => void = () => {},
/**
* The agent whose view resolves tool presentations: a scoped/shadowed
* tool presents with ITS OWN presentCall/presentResult — the same
* definition that executed — not a same-named global's. Absent (a replay
* with no live agent) the global view presents.
*/
/** Agent scope for tool lookup; absent during replay without a live agent. */
private readonly agent?: Agent,
) {}
/**
* Pending-state render intent for a `tool/call`; remembers `(name, args, card)`
* for the matching result.
* Resolve a pending call and remember its state for the matching result.
* @param callId - the call id the matching `tool/result` will look up.
* @param name - the tool name, resolved against the registry for `presentCall`.
* @param argsJson - the raw arguments JSON from the event; parsed for the view
* (a non-JSON string is surfaced raw).
* @returns the tool-owned view, or the generic fallback (title = tool name,
* kind `other`, parsed args as raw input) when the tool defines none or threw.
* @returns the tool-owned view, or a generic parsed-input fallback.
*/
call(callId: CallId, name: string, argsJson: string): ToolCallView {
const args = parseToolArguments(argsJson)
@@ -1245,16 +1088,12 @@ export class ToolPresenter {
}
/**
* Completed-state render intent for a `tool/result`; consumes the remembered
* `(name, args, card)`.
* @param callId - the id of the matching `tool/call`; an unknown or late id
* falls back to the raw content.
* Resolve a completed result and consume its remembered call state.
* @param callId - matching call id; unknown or late ids use raw content.
* @param content - the result's content blocks (the fallback and fill-in body).
* @param isError - whether the result is an error, forwarded to `presentResult`.
* @param meta - the result's machine-readable meta, forwarded when present.
* @returns the tool-owned view — an orphaned `terminal` result (no terminal
* call side) and a content-less `generic` are normalized — or the raw-content
* generic card when the tool defines no `presentResult` or threw.
* @returns the normalized tool-owned view, or a raw-content generic fallback.
*/
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
const call = this.pending.get(callId)
@@ -1324,25 +1163,11 @@ type AcpToolCallContent =
| { type: 'diff'; path: string; oldText: string | null; newText: string }
| { type: 'terminal'; terminalId: string }
/**
* Relativize a file card's TITLE path against the session workspace cwd, so a
* card reads `Read src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the
* reference ACP adapter's `toDisplayPath`. Only the TITLE is relativized; the
* card's `locations`/`diff` paths stay RAW (the editor opens the real path). The
* pure tool presenter can't see the session cwd, so this happens here where the
* bridge knows it. The rewrite is an exact substring replace of the known raw
* path (a card carries the same path in `locations[0]`/`diffs[0]`), never a
* heuristic. A path outside the workspace, or an absent/relative session cwd, is
* left unchanged.
*/
/** Relativize an in-workspace file path in a card title; keep target paths raw. */
function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string {
if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title
const rel = relativePath(sessionCwd, rawPath)
// Only relativize a target that stays INSIDE the workspace. `relative` prefixes
// a `..` SEGMENT for a target above the cwd — test for the segment (`..` alone
// or `..<sep>…`), NOT a bare `..` char prefix, so a sibling like `..cache/x`
// (a real in-workspace name) still relativizes. Never relativize to the empty
// string (rawPath === cwd — a non-file target).
// Reject an empty relative path or a leading parent-directory segment.
if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title
return title.split(rawPath).join(rel)
}

View File

@@ -24,22 +24,16 @@ describe('acp bridge — disposal & HMR safety', () => {
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
// Dispose the whole context. The bridge's teardown must abort the agent and
// AWAIT whenIdle() — so right after dispose resolves, the agent is settled
// (not still running). Proves disposal waited, not just requested.
// A resolved teardown is the quiescence boundary.
await harness.ctx.fiber.dispose()
expect(agent.status).not.toBe('running')
// The in-flight prompt settled (cancelled) rather than hanging forever.
const res = await promptDone
expect(res.stopReason).toBe('cancelled')
})
it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => {
// Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop
// stay up and the transport is still live. A late session/new must hit the
// `closed` guard and reject — NOT create an agent the disposed bridge can no
// longer stream or settle. Verify the world: no agent appeared.
// An ACP-only unload must close creation while shared services remain live.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const before = harness.ctx.agents.list().length
@@ -51,14 +45,7 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => {
// The factory (`ctx.agents.create`) is reached through the bridge's
// traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)`
// registration binds to the CALLER context — the bridge fiber — not the
// AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload)
// must therefore reclaim the agent's registry entry, even though agents/
// agent-loop stay up. This pins the fiber-ownership the bridge's teardown
// doc comment relies on; if a refactor rebinds the registration to the
// AgentLoop fiber, the agent would survive bridge dispose and this fails.
// The caller fiber owns agents created through its traced service proxy.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -70,10 +57,7 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => {
// After teardown (here a client disconnect sets `closed`), a late
// `session/new` must NOT create an orphan agent the bridge can no longer
// drive/settle. The transport is gone so the RPC rejects; assert the world:
// no new agent appeared in the registry.
// Assert registry state because the closed transport rejects the RPC.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const before = harness.ctx.agents.list().length
@@ -85,35 +69,21 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => {
// The ACP transport closes (editor quits) while a turn runs. The bridge must
// settle the in-flight prompt cancelled and DISPOSE the agent (the session's
// per-agent AgentHandle teardown) rather than leaving an orphaned running —
// or even idled-but-still-registered — agent whose updates are swallowed.
// Disconnect must dispose, not merely idle, the owned agent.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
// Start a prompt that hangs in the model stream. The prompt RPC will never
// return (its transport is severed), so do not await it.
// The transport will close before this hanging RPC settles.
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
// Sever the transport — the bridge's conn.closed teardown runs and drives the
// agent's AgentHandle dispose to quiescence on its OWN (before any dispose()).
await harness.closeClientTransport()
await agent.whenIdle()
// The agent's loop has stopped: status `disposed`.
expect(agent.status).toBe('disposed')
// Await the bridge teardown to completion WITHOUT tearing down the root
// agents/sessions services (so we can still query them). acpFiber.dispose()
// invokes the SAME memoized quiesce() the disconnect started and awaits its
// promise — which resolves only after every rec.dispose() (loop exit +
// session removal) has finished, closing the whenIdle()/owned.dispose()
// microtask race. The AgentHandle dispose has run: the agent is unregistered
// and its session removed from the store, not merely idled (the old
// behavior). The services live on the root ctx, so they survive this.
// The shared bridge teardown also removes registry state.
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
@@ -121,10 +91,7 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => {
// conn.closed teardown and ctx.fiber.dispose() can fire near-simultaneously.
// They must share one teardown promise: dispose() must NOT return before the
// disconnect teardown's whenIdle() has settled (a `record === undefined`-only
// guard would let the second caller return early mid-teardown).
// Both teardown callers must await the same quiescence boundary.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -133,11 +100,9 @@ describe('acp bridge — disposal & HMR safety', () => {
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
// Fire both teardown paths without awaiting the first, then await both.
const close = harness.closeClientTransport()
const dispose = harness.ctx.fiber.dispose()
await Promise.all([close, dispose])
// After BOTH settle, the agent has fully drained (not still running).
expect(agent.status).not.toBe('running')
})
@@ -157,14 +122,7 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => {
// The teardown-ORDER guarantee: a per-agent dispose must stop the loop,
// AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire
// through the still-attached store observer → `session/event`), and only
// THEN remove its publication hooks and session entry. If the order were inverted
// (detach first), the closing events would never reach persistence. Drive a
// CLEAN turn to completion, dispose JUST the bridge, then re-load the
// persisted log from disk and assert the closing turn/end is on disk — the
// world, not the agent's self-report.
// Reload from storage to verify final flush precedes session detach.
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -172,12 +130,9 @@ describe('acp bridge — disposal & HMR safety', () => {
const liveEvents = harness.ctx.agents.get(AgentId(sessionId))!.session.events.length
expect(liveEvents).toBeGreaterThan(0)
// Tear down JUST the bridge (the AgentHandle dispose runs to quiescence).
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
// Re-load the session from disk: every live event (incl. the closing
// turn/end) was flushed before the session was detached.
const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId))
expect(reloaded.events.length).toBe(liveEvents)
const last = reloaded.events.at(-1)!
@@ -186,18 +141,7 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => {
// The teardown-order contract only earns its keep when the closing events are
// produced BY the dispose itself. Here the model stream HANGS, so the turn is
// still open when teardown runs: the composite agent effect stops the loop,
// the loop unwinds and appends `turn/end {disposed}` + runs its final
// `session/flush` — all while the store-owned publication hooks are still attached (the session
// detach is the LAST disposer in the same effect's LIFO chain) — and only
// THEN is the session detached. If the order were inverted (or the session
// were a racing SIBLING effect), the abort-produced `turn/end` would never
// reach disk and a re-load would instead show crash-recovery's synthetic
// `interrupted` closer. Re-load from disk and assert the REAL `disposed`
// reason landed — proving the loop's own closing event was captured, not a
// recovered substitute.
// A mid-turn dispose must flush its real closer before detaching storage.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -205,16 +149,11 @@ describe('acp bridge — disposal & HMR safety', () => {
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
// The turn is OPEN in the log (turn/start appended, no turn/end yet).
const openTurnEnds = agent.session.events.filter(e => e.type === 'turn/end').length
// Dispose JUST the bridge: a fiber unload that must STILL honor the ordered
// teardown (the composite effect runs its disposer chain as a unit).
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
// The loop's own `turn/end {disposed}` is on disk (re-load: the world, not
// self-report) — NOT a crash-recovery `interrupted` substitute.
const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId))
const persistedTurnEnds = reloaded.events.filter(e => e.type === 'turn/end')
expect(persistedTurnEnds.length).toBe(openTurnEnds + 1)
@@ -223,11 +162,7 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('per-session AgentHandle dispose leaves sibling agents untouched', async () => {
// The factory returns a per-agent AgentHandle whose dispose() tears down
// EXACTLY that agent + its session — RFC 011 isolation. Create two agents
// directly through the registry factory (the same path the ACP bridge uses),
// dispose one handle, and assert the other survives, registered and
// queryable, with its session still in the store.
// Dispose one handle and assert the sibling remains published.
const harness = await makeBridgeHarness({ storageDir, script: [] })
const handleA = await harness.ctx.agents.create({
agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
@@ -239,11 +174,9 @@ describe('acp bridge — disposal & HMR safety', () => {
expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent)
await handleA.dispose()
// A is gone — unregistered AND its session removed from the store.
expect(harness.ctx.agents.get(AgentId('sib-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined()
expect(handleA.agent.status).toBe('disposed')
// B is wholly unaffected.
expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent)
expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined()
expect(handleB.agent.status).not.toBe('disposed')
@@ -251,14 +184,7 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => {
// The AgentHandle teardown folds session-detach, register, and loop-stop
// into ONE composite effect whose disposers run as a `.then()` chain. The
// register disposer emits `agent/disposed`; if a listener throws and the
// emit is UNCONTAINED, the rejected chain skips the LATER session-detach
// disposer — stranding the session in the store with its publication hooks attached (a
// leak AND a durability hole, since the new design relies on detach
// running). The emit must be contained. Register a throwing listener, drive
// a clean turn, dispose, and assert the session was STILL removed.
// Listener failure cannot skip the later session-detach disposer.
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
const handle = await harness.ctx.agents.create({
@@ -268,7 +194,6 @@ describe('acp bridge — disposal & HMR safety', () => {
await handle.agent.whenIdle()
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined()
// Dispose: the throwing listener must NOT break the chain before detach.
await handle.dispose()
expect(harness.ctx.agents.get(AgentId('guard-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran
@@ -276,18 +201,12 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => {
// The handle's dispose() must memoize: the underlying cordis effect disposer
// is single-shot, so a second dispose() while the first is mid-teardown would
// otherwise resolve IMMEDIATELY (effect epoch already cleared) — before the
// first call's await agent.done + final flush finished. Every caller must
// observe the same quiescence boundary.
// Concurrent callers must share the in-flight teardown promise.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
const handle = await harness.ctx.agents.create({
agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },
})
// Drive a turn that hangs in the model stream, so the loop is mid-turn when
// disposed — its exit runs a final session/flush we can gate to hold the
// teardown observably in-flight.
// Gate the final flush to keep teardown observably in flight.
handle.agent.send([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30))
expect(handle.agent.status).toBe('running')
@@ -295,22 +214,18 @@ describe('acp bridge — disposal & HMR safety', () => {
const flushGate = new Promise<void>((resolve) => { releaseFlush = resolve })
harness.ctx.on('session/flush', () => flushGate)
// First dispose enters teardown (aborts the hanging step) and blocks in the
// gated final flush.
const first = handle.dispose()
let firstSettled = false
void first.then(() => { firstSettled = true })
await new Promise(r => setTimeout(r, 20))
expect(firstSettled).toBe(false)
// Second dispose MUST await the same in-flight teardown, not resolve early.
const second = handle.dispose()
let secondSettled = false
void second.then(() => { secondSettled = true })
await new Promise(r => setTimeout(r, 20))
expect(secondSettled).toBe(false) // memoized: still pending with the first
// Release the flush; both resolve together and the session is gone.
releaseFlush()
await Promise.all([first, second])
expect(harness.ctx.agents.get(AgentId('conc-a'))).toBeUndefined()

View File

@@ -124,11 +124,7 @@ describe('acp bridge — turn outcomes', () => {
})
it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta + exit)', async () => {
// Drive the REAL bash tool, and advertise the Zed `_meta.terminal_output`
// capability in initialize. The bridge must then emit the terminal CARD: the
// description content block THEN a terminal content block + `_meta.terminal_info`
// (cwd header) on the call, and `_meta.terminal_output`/`terminal_exit` on the
// result — and OMIT the update's text content (it would clobber the card).
// Terminal capability moves output to card metadata.
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -164,11 +160,7 @@ describe('acp bridge — turn outcomes', () => {
})
it('the terminal capability is snapshotted per-session: a later initialize cannot desync a call/result', async () => {
// The session is created with the capability ON. A SECOND initialize then
// turns it OFF at the connection level — but this session keeps its snapshot,
// so its bash call STILL renders as a terminal card (call + result agree).
// Without the snapshot, the result path would re-read the now-OFF capability
// and either clobber the card (content sent) or be inconsistent with the call.
// Session creation snapshots the capability for both call and result.
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -324,11 +316,7 @@ describe('acp bridge — turn outcomes', () => {
})
it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => {
// Over the async JSON-RPC transport the loop usually wakes before cancel
// arrives, so this is a running/mid-step cancel (the synchronous pre-step
// DROP is unit-tested in agent-loop/cancel.spec.ts). The ACP-level guarantee:
// the prompt settles cancelled, the agent reaches idle, and no second/leaked
// turn runs afterward.
// The cancelled prompt must not leave queued work for another turn.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer'), textResponse('leaked')] })
const sessionId = await newSession(harness)
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
@@ -337,18 +325,12 @@ describe('acp bridge — turn outcomes', () => {
expect(res.stopReason).toBe('cancelled')
const agent = harness.ctx.agents.get(AgentId(sessionId))!
await agent.whenIdle()
// At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so
// no second turn was batched or leaked. (A best-effort abort that left queued
// work could have started a second turn.)
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start').length
expect(turnStarts).toBeLessThanOrEqual(1)
})
it('idle session/cancel then session/prompt runs the prompt (no intervening whenIdle)', async () => {
// The ACP bridge settles the cancel RPC synchronously and accepts the next
// prompt WITHOUT awaiting quiescence — so this drives cancel→prompt with NO
// whenIdle() between, the production race. An idle cancel must be a no-op that
// does NOT drop the following prompt.
// Exercise cancel→prompt without an intervening quiescence wait.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
// Cancel while idle (no prompt in flight) — a no-op.
@@ -364,9 +346,7 @@ describe('acp bridge — turn outcomes', () => {
})
it('mid-stream cancel then an IMMEDIATE next prompt runs (no intervening whenIdle)', async () => {
// Cancel a running turn, then send the next prompt WITHOUT awaiting quiescence
// (the synchronous-settle path). The new prompt must run — the cancel marker
// must not leak onto it.
// A cancel marker must not leak onto an immediate next prompt.
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('next answer')] })
const sessionId = await newSession(harness)
const a = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'A' }] })
@@ -384,10 +364,7 @@ describe('acp bridge — turn outcomes', () => {
})
it('a cancelled turn\'s late turn/end does not settle the NEXT prompt', async () => {
// Regression: prompt A runs; cancel settles A and frees the slot; A's
// aborted turn/end is still pending in the loop. Prompt B is sent before
// A's turn/end arrives. A's late turn/end (an EARLIER turn number) must NOT
// settle B — B owns a later turn. B then completes on its OWN turn/end.
// Correlation must keep A's late turn/end from settling B.
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B answer')] })
const sessionId = await newSession(harness)
@@ -396,8 +373,7 @@ describe('acp bridge — turn outcomes', () => {
await harness.client.cancel({ sessionId })
expect((await a).stopReason).toBe('cancelled')
// Immediately send B; its turn (2) is distinct from A's (1). If A's late
// turn/end leaked onto B, B would settle 'cancelled' instead of 'end_turn'.
// B owns a later turn number than A.
const b = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'B' }] })
expect(b.stopReason).toBe('end_turn')
const text = harness.updates

View File

@@ -30,12 +30,8 @@ export function resolveConfigPath(
}
/**
* Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in
* `dir` (Node native `process.loadEnvFile`). An absent file is fine — the
* environment may already carry the variables; the leaf `cordis.yml` reads
* them via the `!!js` tag. A present-but-unreadable `.env` is a real
* misconfiguration: surface it via `warn` (one line, default stderr) rather
* than silently running with the wrong environment.
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
* ambient environment; other read failures are reported through `warn`.
* @param binName - the diagnostic prefix on the warn line.
* @param dir - the directory whose `.env` to load.
* @param warn - sink for the one-line misconfiguration diagnostic.

View File

@@ -32,14 +32,8 @@ async function pkgName(absDir: string): Promise<string> {
}
/**
* Build a temp consumer dir: `node_modules` with the workspace + vendor packages
* symlinked in, a `src/` carrying the example mock backend, and a `cordis.yml`
* that wires them onto the stdio app. Returns the dir (caller removes it).
*
* `disabledBrokenEntry` appends an entry that points at a non-existent plugin but
* is marked `disabled: true`. The Loader leaves a disabled entry fiber-less by
* design, so it exercises that the fail-loud entry-load guard does NOT mistake a
* valid disabled entry for a failed import.
* Build a temporary symlinked consumer for the stdio app. The optional disabled
* broken entry verifies that load guards accept intentionally fiber-less entries.
*/
async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-'))

View File

@@ -1,13 +1,11 @@
# @deepseek-ai/dsh-user-approval
User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI.
Channel-neutral one-shot approval seam. `ctx.approval.request(req)` returns `allowed-once`, `rejected`, `cancelled`, or `unavailable`; missing or failing answerers fail closed, and a grant applies only to the requested action. Exact event signatures live in the generated [Cordis catalog](../../../docs/cordis-catalog/events.md).
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its answerer phase always produces an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. `ApprovalRequest` is a readonly same-process contract: the service borrows the exact request, agent, session, and abort signal rather than cloning or freezing them. The request requires an open turn because the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask rejects before appending. Either audit append may reject before commit because returning an unlogged decision would violate the pair. Session contains post-commit observer failures, so an authoritative audit append cannot reject the request or suppress its matching event.
Each request must belong to an open agent turn. The service appends a paired `approval/asked` and `approval/decided` audit record, while the model sees only the resulting logged tool outcome. An aborted request resolves `cancelled`; an audit append that fails before commit rejects rather than returning an unlogged decision.
The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates.
Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP bridge is the shipped human answerer.
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`, which rejects any value outside that closed vocabulary before appending. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`).
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is exposed to the model through the prompt and a coalesced switch notice.
One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md).
Answerers today: the ACP bridge ([`@deepseek-ai/dsh-acp`](../../ui/acp/)) forwards to the editor's `session/request_permission` prompt for agents it owns. The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome.
The tools pipeline consumes this seam for `ask` decisions and the sandboxed bash tool uses it for escalated retries. See the [approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md) and [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).

View File

@@ -1,35 +1,6 @@
/**
* Approval seam: `ctx.approval` answers exactly one question — "may this
* specific action proceed?" — by dispatching the `approval/request` waterfall
* to whatever answerers the deployment composed (an ACP editor prompt, an
* auto-decide policy, a scripted test listener) and returning a closed
* {@link ApprovalOutcome}. With no answerer the waterfall falls through to the
* built-in default `'unavailable'`: absence of a UI can never grant anything.
*
* The service is the MECHANISM (dispatch, cancellation, audit); answerers are
* the POLICY. It serves both ask paths the sandbox RFC names — the
* `tools/pre-execute` `ask` decision and the sandbox post-denial escalation —
* so every asker shares one outcome
* vocabulary and one audit trail. Grants are one-shot by design: an
* `'allowed-once'` outcome authorizes the single action it was asked about,
* never a class of future actions.
*
* Every request lands two log-only session events on the requesting agent's
* log (`approval/asked` / `approval/decided`, paired by
* {@link ApprovalRequestId}) — an audit trail, deliberately NOT part of the
* model-visible transcript: the model only ever sees the tool result the
* caller derives from the outcome.
*
* The seam also owns the per-session POLICY tier (the sandbox RFC § Per-session mode switching):
* `effective = fold(the session's 'approval/policy' events, last one wins)
* ?? config.policy` — the session log is the store, so an override survives
* restart by replay. The service resolves `'never'` sessions to
* `'rejected'` inside `request()` before dispatching any answerer (no
* registration order, including a later `prepend`, can precede it); a prompt section states `'never'`
* (and only `'never'` — an availability promise is unknowable without
* asking); an `agent/pre-step` narrator explains a switch to the model in at
* most one coalesced notice per step.
*
* Approval request, cancellation, audit, and per-session policy seam. Missing
* answerers fail closed; grants apply only to the requested action.
* @module @deepseek-ai/dsh-user-approval
*/
@@ -51,19 +22,9 @@ declare module 'cordis' {
interface Events {
/**
* Waterfall asking the composed answerers to decide one approval request.
* Dispatched only from {@link ApprovalService.request} — callers go through
* the service (which owns cancellation and the audit events), never through
* `ctx.waterfall` directly. A listener that can answer for this request's
* agent returns an outcome WITHOUT calling `next()` (the decision slot is
* single-occupancy, first listener to answer wins); a listener that does
* not recognize the agent MUST call `next()` so another answerer — or the
* fail-closed default `'unavailable'` — gets the question. Throwing is
* contained by the service and yields `'unavailable'`.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a
* listener registered through `agent.ctx` receives only that agent's
* questions, while a plain-context listener receives every agent's.
* `req` is a readonly same-process value borrowed from the caller.
* Ask composed answerers for one decision. Return an outcome to claim the
* request or call `next()`; failure yields the fail-closed default.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param req - the pending decision (agent, tool identity, reason, signal).
* @mode waterfall
*/
@@ -124,16 +85,8 @@ export function ApprovalRequestId(id: string): ApprovalRequestId {
}
/**
* The closed outcome vocabulary of one approval request.
*
* - `'allowed-once'` — a one-shot grant for exactly the asked-about action;
* consumed by proceeding, never a durable authorization.
* - `'rejected'` — an answerer (human or policy) said no.
* - `'cancelled'` — the question was withdrawn: the prompt was dismissed, or
* the requesting execution aborted while the question was pending.
* - `'unavailable'` — nobody composed could answer (no listener, none that
* recognizes the agent, or an answerer failed). Callers MUST fail closed on
* it, exactly like `'rejected'` — the two differ only for audit and wording.
* Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn
* request, or unavailable answerer. Callers fail closed on `unavailable`.
*/
export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
@@ -218,14 +171,9 @@ function hasOpenTurn(events: readonly SessionEvent[]): boolean {
}
/**
* THE write path for a session's approval-policy override: appends exactly
* one `approval/policy` event — the switch IS its event; nothing mutates
* policy state out of band. Takes effect on the session's next ask and next
* prompt assembly (the consumers fold on every read). Rejects a value outside
* {@link APPROVAL_POLICIES} before appending anything.
* Append the sole durable representation of a session policy override.
* @param session - the session the override belongs to.
* @param policy - the policy every subsequent ask for this session resolves
* under (until the next switch).
* @param policy - the policy in effect until the next switch.
*/
export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void {
if (!APPROVAL_POLICIES.includes(policy)) {
@@ -235,13 +183,8 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi
}
/**
* One concrete permission question. Identifies the action precisely enough
* for an answerer to present it and for the audit events to reconstruct what
* was asked — it deliberately does NOT carry tool arguments: a UI answerer
* attaches the prompt to the already-streamed tool call via `callId` instead
* of re-rendering the call. This is a readonly same-process contract:
* `request()` borrows the request and its `agent` and `signal` capabilities
* directly rather than treating them as serialized input.
* Readonly same-process permission question. `callId` links to an already
* presented tool call, so arguments are not duplicated here.
*/
export interface ApprovalRequest {
/**
@@ -278,18 +221,9 @@ export interface Config {
}
/**
* The `ctx.approval` service: dispatches {@link ApprovalRequest}s to the
* `approval/request` waterfall and audits every ask/outcome pair to the
* requesting agent's session log. Stateless between requests — grants are
* returned to the caller, never stored here.
*
* Owns the policy tier too (`effective = fold(the session's 'approval/policy'
* events) ?? config.policy`): `request()` resolves `'never'` to `'rejected'`
* before dispatching any interactive answerer, a per-agent prompt section
* states a `'never'` policy (and only that one in prose — an `'ask'` promise
* could overclaim an answerer that headless compositions do not have), and an
* `agent/pre-step` narrator injects at most one coalesced notice when a
* session's effective policy moved past what the model was last told.
* Approval request and policy service. It logs each ask/outcome pair, applies
* session policy before answerers, and exposes deterministic policy changes to
* the model through prompt and pre-step notices.
*/
export class ApprovalService extends Service {
static Config: z<Config> = z.object({
@@ -301,12 +235,7 @@ export class ApprovalService extends Service {
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
// Visibility layer 1, scoped on the prompt registry so headless
// compositions mount the seam without it: state the one deterministic
// policy per session. 'ask' renders only a source-owned state marker —
// stating "you will be asked" would overclaim in a composition with no
// answerer. The marker, not deployment-controlled prose, is what the
// restart narrator reads back from the logged request header.
// State only deterministic policy; a marker records the otherwise silent state.
ctx.inject(['systemPrompt'], (scope: Context) => {
scope.systemPrompt.section({
name: 'approval:policy',