docs: trim generated prose

This commit is contained in:
Tianyi Cui
2026-07-12 03:36:43 +08:00
parent 3dca90261c
commit 75838e10b5
323 changed files with 2857 additions and 11833 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ The bridge multiplexes N sessions over one connection. Live sessions are held in
## Session config options
The bridge advertises one independent `select` per composable knob in the `session/new`/`session/load` responses — `sandbox-mode` (`read-only`/`workspace-write`/`danger-full-access`, category `mode`) iff the mounted executor confines (`ctx.get('bash')?.sandboxMode` defined), `approval-policy` (`ask`/`never`) iff the approval seam is composed — with each session's `currentValue` folded from its OWN log (`effectiveSandboxMode`/`effectiveApprovalPolicy` ?? the composition default), so `session/load` reports a resumed session's overrides with no catch-up machinery. `session/set_config_option` validates the value against the same closed vocabulary, routes to the domain's write path (`setSandboxMode`/`setApprovalPolicy` — ONE log-only event on that session's log), and returns the complete refreshed state per the spec. Anchoring honors turn-enclosure: a switch while a turn is open appends immediately (openness read from the LOG — `agent.status` stays `running` between queued turns); an idle switch is held on the session record and anchored at the next turn's `agent/prompt-submit` (inside the turn, before anything assembles, last write per knob — an idle flip-flop anchors as one event), because appending from inside a `session/event` listener would reorder events for later-registered peers. Until anchored the switch lives in bridge memory only: responses overlay it truthfully, and a crash before the next turn reverts it — `session/load` then reports the fold's truth. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6.
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.
@@ -56,7 +56,7 @@ How a tool call renders in the editor is owned by the TOOL, not the bridge — t
- `{ 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 `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → typically the applied hunks with context lines computed from the before/after content, or a whole-file diff for a create; a successful mutation ALWAYS returns this so the model-facing result text can't clobber the diff — an ACP `tool_call_update.content` replaces the call's content). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; a tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind `other` — the bridge never sniffs a kind from the tool name). For example `dsh-tool-bash` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path.
`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.
+4 -34
View File
@@ -1,11 +1,5 @@
/**
* 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.
*
* Pure translation between harness vocabulary and ACP wire types.
* @module @deepseek-ai/dsh-acp/codec
*/
@@ -16,29 +10,6 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr
/**
* 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`/`rejected`).
* `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` (a step abort or a queue-aware `agent.cancel()`, 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)
* - `rejected` → `cancelled` (the prompt was blocked by an `agent/prompt-submit`
* hook before any step ran — ACP has no "rejected" reason, and a
* blocked prompt is, from the client's view, the prompt not being
* carried out; `cancelled` is the closest legal wire reason)
* @param reason - the harness turn-end reason to translate.
* @returns the legal ACP wire value per the mapping above.
*/
@@ -56,10 +27,9 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
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.
// 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.
default:
return 'end_turn'
}
+100 -407
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.
*
* 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.
* @module @deepseek-ai/dsh-acp
*/
@@ -102,11 +72,7 @@ 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.
// Persistence enables loadSession; tools own call and result rendering.
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']
/**
@@ -295,24 +261,9 @@ interface SessionRecord {
*/
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 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.
* 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}.
*/
inflight: {
resolve: (reason: StopReason) => void
@@ -321,38 +272,17 @@ interface SessionRecord {
logWatermark: number
} | 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.
* Config switches accepted while the session was IDLE, not yet anchored in its log.
*/
pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy }
}
/**
* Drive the in-flight prompt's settle from the harness event stream. The bridge
* settles off the durable log: the `turn/end` session event on the
* `session/event` feed for the prompt's own turn, with the agent
* erroring/settling to idle as a fallback (docs/defensive-patterns.md "honor
* cross-seam contracts on BOTH sides") for the case where a throwing peer `session/event` listener
* starved the bridge's listener before it saw the boundary. The first of these
* to fire settles the prompt; `settle` is then cleared so the others are no-ops
* (settle-exactly-once).
* Drive the in-flight prompt's settle from the harness event stream.
*/
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 the injected services NOW, during apply(), while we are inside this plugin's fiber
// (where `inject` grants access).
const agents = ctx.agents
const sessionPersistence = ctx.sessionPersistence
const logger = ctx.logger
@@ -362,25 +292,16 @@ export function apply(ctx: Context, config: AcpConfig): void {
// this warn sink so a throwing tool presenter is logged, not propagated.
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.
// 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).
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.
// 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).
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.
// Set once the bridge has torn down (disposal or client disconnect).
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.
// Whether the client advertised the Zed `_meta.terminal_output` capability in `initialize`.
let terminalOutputCap = false
// Assigned at the bottom, before any agent event can fire (a session only
@@ -448,10 +369,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
/** 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 docs/defensive-patterns.md "contain callback exceptions").
// sessionUpdate returns a promise; a closed connection rejects it.
/* 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 */
@@ -482,22 +400,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- 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` — the
// durable boundary events (there is no agent/* turn mirror). `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: a `session/event` is routed to its own record, so
// two sessions streaming at once never cross-settle or interleave updates.
// 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).
ctx.on('session/event', (session, event: SessionEvent) => {
const rec = sessions.get(session.header.id)
if (rec === undefined) return
@@ -508,14 +414,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
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.
// Tag the in-flight prompt with its owning turn — but only a `message`-triggered turn
// (the kind a `send()` prompt produces).
if (inflight.turn === undefined && event.data.trigger.kind === 'message') {
inflight.turn = event.data.turn
}
@@ -527,34 +427,21 @@ export function apply(ctx: Context, config: AcpConfig): void {
settleFromTurnEnd(inflight, event.data.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.
// 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.
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.
// 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.
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.
// The owning turn's end in the log.
const end = events.findLast(
(e): e is Extract<SessionEvent, { type: 'turn/end' }> =>
e.type === 'turn/end' && e.data.turn === owningTurn,
@@ -568,10 +455,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
settleFromTurnEnd(inflight, end.data.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`.
// Demux via the agent→sessionId reverse map.
// On a settle to idle/disposed, reconcile any still-pending prompt from the log (covers a
// starved `session/event` listener — see settleFromLog).
ctx.on('agent/status', (agent, status: AgentStatus) => {
const sessionId = bySession.get(agent)
if (sessionId === undefined) return
@@ -580,17 +465,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (status === 'idle' || status === 'disposed') settleFromLog(rec)
})
// --- Approval answerer -----------------------------------------------------
// The bridge is the approval channel for the agents it owns: an `ask` routed
// through `ctx.approval` (dsh-tools asks and sandbox escalation) becomes
// an editor permission prompt attached to the already-streamed tool call. The
// listener occupies the single decision slot ONLY for its own agents — a
// foreign or call-less request delegates via next() so another answerer (or
// the fail-closed `unavailable` default) takes the question. A rejected
// `requestPermission` (client gone, bridge torn down) propagates and the
// ApprovalService contains it as `unavailable`. Options are one-shot only:
// allow_always is a grant-storage design the approval RFC defers, so the
// prompt never offers a durable grant the harness could not honor.
// --- Approval answerer The bridge is the approval channel for the agents it owns: an `ask`
// routed through `ctx.approval` (dsh-tools asks and sandbox escalation) becomes an editor
// permission prompt attached to the already-streamed tool call.
ctx.on('approval/request', (req, next) => {
const sessionId = bySession.get(req.agent)
// The protocol requires `toolCall` (the prompt renders attached to it), so
@@ -614,17 +491,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- The ACP Agent method surface -----------------------------------------
/**
* The session config options this composition can honor, with current
* values folded from the AGENT'S OWN session log (`effectiveSandboxMode` /
* `effectiveApprovalPolicy` — the log is the per-session store, so a
* `session/load` reports a resumed session's overrides with no catch-up
* machinery), overlaid with the record's not-yet-anchored pending switches
* (see {@link SessionRecord.pendingSwitches}). Capability-gated like every
* advertised lever: the sandbox option exists only when the mounted
* executor confines (`ctx.get('bash')?.sandboxMode` defined), the approval
* option only when the approval seam is composed — both read
* opportunistically so this bridge keeps working in compositions without
* them.
* The session config options this composition can honor, with current values folded from the
* AGENT'S own session log (`effectiveSandboxMode` / `effectiveApprovalPolicy` — the log is
* the per-session store, so a `session/load` reports a resumed session's overrides with no
* catch-up machinery), overlaid with the record's not-yet-anchored pending switches (see
* {@link SessionRecord.pendingSwitches}).
*/
const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => {
const options: SessionConfigOption[] = []
@@ -694,15 +565,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
}
// Idle-accepted switches anchor at the next turn's prompt-submit: the turn
// is open (the seam fires inside it, per drained message — the first flush
// empties the slot, later ones no-op), the loop has not yet assembled
// anything for it, and — unlike appending from inside a `session/event`
// listener — this seam fires OUTSIDE any log emit, so peer listeners
// (the dev invariants, persistence) observe the anchored events in strict
// log order. A turn with no prompt (an idle inject's one-shot injection
// turn) leaves the switch pending — it runs no step, so nothing executes
// or assembles under a stale value.
// Anchor idle switches during prompt-submit so persistence observes ordered in-turn events.
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
const sessionId = bySession.get(agent)
const rec = sessionId === undefined ? undefined : sessions.get(sessionId)
@@ -755,10 +618,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
})
// Creation is now asynchronous because it awaits the unpublished setup
// transaction. A client disconnect can therefore close this bridge
// after the entry check but before the handle resolves; never install a
// post-close record that quiesce() could not have seen.
// Creation is now asynchronous because it awaits the unpublished setup transaction.
/* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC
immediately on close; real stdio may let the handler resume */
if (closed) {
@@ -789,25 +649,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
validateWorkspaceParams(params)
validateMcpServers(params)
// Reserve THIS id's load slot BEFORE the await. Without it, two pipelined
// loads for the same id could both pass the guard above while the first
// resume() is pending, then both install a record and leak a second
// agent. (Distinct ids load concurrently — the set is keyed by id.) The
// slot is released in `finally` so a rejected load never wedges the id.
// Reserve this id's load slot before the await.
loadingIds.add(sessionId)
try {
// Validate the PERSISTED cwd BEFORE resuming — `list()` is a
// metadata-only read (no full-log parse), so this rejects a session we
// can't honor WITHOUT ever constructing/registering an agent (a
// post-resume reject would leak the registered agent — cancel() does not
// unregister it — and wedge the id against re-load). The session's bash
// workdir is derived from its persisted `header.cwd` and the request
// `cwd` does NOT override it (resume takes no cwd), so a session with no
// absolute persisted cwd would silently run bash in the SERVER's launch
// dir, not the client's workspace. A session created by this bridge
// always has a cwd (session/new requires it); reject the rest loudly.
// (An id unknown to `list()` falls through to resume, which rejects with
// the backend's not-found error.)
// Validate the persisted cwd before resuming — `list()` is a metadata-only read (no
// full-log parse), so this rejects a session we can't honor WITHOUT ever
// constructing/registering an agent (a post-resume reject would leak the registered
// agent — cancel() does not unregister it — and wedge the id against re-load).
const meta = (await sessionPersistence.list()).find(m => m.id === sessionId)
if (meta !== undefined) {
const persistedCwd = meta.cwd
@@ -825,12 +673,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
resumeSessionId: sessionId,
agentOptions: agentOptions(config),
})
// The bridge may have torn down (disposal / client disconnect) while
// resume() was pending. Its listeners are gone, so installing a record
// now would resurrect a live agent the bridge can no longer drive. Bail —
// and tear down the just-resumed agent (unregister + stop + remove its
// session) before throwing, so it does not leak: it has no SessionRecord,
// so quiesce() would never see it.
// The bridge may have torn down (disposal / client disconnect) while resume() was
// pending.
/* v8 ignore next 4 -- 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
@@ -855,19 +699,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
pendingSwitches: {},
}
sessions.set(sessionId, record)
// 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.
//
// Replay through a THROWAWAY presenter, NOT `record.presenter`: a
// historical turn that was interrupted mid-tool (a `tool/call` with no
// matching `tool/result` in the persisted log) would otherwise leave a
// stale in-flight entry on the live presenter, which then serves all
// future live events for this session. The throwaway pairs call→result
// as the log replays in order (same as live) and is discarded after,
// so the record's presenter starts clean for the post-load live stream.
// Replay the persisted event log to the client as session/update.
const replayPresenter = makePresenter(agent)
const replayTerminal: TerminalRendering = {
enabled: terminalEnabled,
@@ -899,13 +731,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
// 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).
// 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).
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 }])
@@ -916,18 +743,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
cancel(params: CancelNotification): Promise<void> {
const rec = sessions.get(SessionId(params.sessionId))
if (rec === undefined) return Promise.resolve()
// session/cancel maps to the queue-aware agent.cancel(reason): it aborts
// a RUNNING step, clears the queued + steering FIFOs, and drops a
// turn that is about to start (the pre-step window) — so a queued-but-
// not-yet-started prompt never runs, and a prompt accepted right after
// cannot be batched into the cancelled turn. Scoped to THIS session's
// agent — a cancel in one session never touches another's stream or
// pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt
// as cancelled directly here: do NOT rely on the resulting turn/end to
// settle it, because cancel() may drop the turn before any turn/end is
// emitted, and removing this direct settle would move the RPC's
// resolution onto the settleFromLog/agent-status path, changing its
// timing.
// Queue-aware cancellation drops pending prompts as well as the active step.
rec.agent.cancel('session/cancel')
settlePrompt(rec, 'cancelled')
return Promise.resolve()
@@ -941,17 +757,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (typeof params.value !== 'string') {
throw invalidParams(`config option ${params.configId} is a select; boolean values are not accepted`)
}
// The setters append ONE log-only event on this session's own log —
// the log is the store (the sandbox RFC § Per-session mode switching): execution, the
// prompt section, and the narrator all fold it from there, and a
// resumed session reports the override back through
// configOptionsFor. A switch while a turn is OPEN anchors
// immediately (the next step sees it); an IDLE switch waits in
// pendingSwitches for the next `turn/start` (turn-enclosure: a bare
// between-turns append would be dropped as crash tail on reload).
// Values are validated against the same closed lists the options
// advertised; an id this composition never advertised (or an unknown
// one) rejects.
// The setters append one log-only event on this session's own log — the log is the
// store (the sandbox RFC § Per-session mode switching): execution, the prompt section,
// and the narrator all fold it from there, and a resumed session reports the override
// back through configOptionsFor.
switch (params.configId) {
case 'sandbox-mode': {
const defaultMode = ctx.get('bash')?.sandboxMode
@@ -993,11 +802,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- 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.
// The transport stream.
/* 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>,
@@ -1007,29 +812,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
/**
* Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach
* quiescence"): for each session settle any pending prompt `cancelled`, then
* run that session's {@link AgentHandle} `dispose()` — which stops the loop
* (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the
* final `turn/end` + `session/flush` are captured while `onAppend` is still
* attached), unregisters the agent, and removes its session from the store.
* The per-session disposes run in parallel. Idempotent — clears the `sessions`
* map first and memoizes, so a second call (close racing dispose) is a no-op.
* Shared by Cordis disposal AND client disconnect (`conn.closed`).
*
* Per-agent disposal closes the former pre-step best-effort window — but via
* the DISPOSED path, not `cancel()`: the start-disposer resolves `handle.disposed`,
* which wakes the parked loop, and `isDisposed()` breaks the loop before a
* queued-but-not-yet-running turn can start (a turn cut off mid-flight ends
* with reason `disposed`, not `aborted`). A bare client disconnect (resolves
* `conn.closed` WITHOUT disposing the fiber) thus leaves NO registered agent
* and NO session-store entry — not an idled-but-still-registered one. When the
* fiber IS disposed (whole-context or an ACP-only HMR
* `acpFiber.dispose()`), this same memoized teardown runs first; the factory's
* register+start+session effects are ALSO bound to the bridge fiber (the
* factory is reached through this bridge's traceable service proxy, so
* `AgentLoop.start`'s `this.ctx.effect(...)` binds to the CALLER context — the
* bridge fiber), so any agent this path did not reach is still reclaimed by
* fiber disposal.
* quiescence"): for each session settle any pending prompt `cancelled`, then run that
* session's {@link AgentHandle} `dispose()` — which stops the loop (sets `disposed`, aborts
* the in-flight step), AWAITS the loop's exit (the final `turn/end` + `session/flush` are
* captured while `onAppend` is still attached), unregisters the agent, and removes its
* session from the store.
*/
let quiescing: Promise<void> | undefined
const quiesce = (): Promise<void> => {
@@ -1058,13 +845,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
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.
// 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()`.
/* 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
@@ -1092,22 +875,9 @@ export function agentOptions(config: AcpConfig): { model?: string } {
}
/**
* Validate the `cwd`/`additionalDirectories` contract shared by `session/new`
* and `session/load`: `cwd` must be absolute (a relative path would be ambiguous
* as a workspace root). The persisted-cwd equality check for `session/load`
* happens after the metadata lookup; this validator only enforces request shape:
* - `session/new`: the validated `cwd` becomes the session's `SessionHeader.cwd`
* (via `agents.create({meta:{cwd}})`) and thus the default bash workdir.
* - `session/load`: the request `cwd` must be absolute AND must match the
* PERSISTED `header.cwd`, which stays authoritative for the bash workdir —
* the request cwd does not override it.
* Any absolute path is accepted (the per-session cwd flows to the bash executor
* — see `dsh-tool-bash`), so the server no longer has to launch in the
* workspace. `additionalDirectories` must still be empty: widening the
* tool/filesystem scope beyond the single cwd is a separate, unimplemented
* concern (a sandbox seam), and silently ignoring extra roots would desync the
* client's filesystem-scope UI. Both request shapes carry `cwd: string` and
* `additionalDirectories?: string[]`, so one validator covers both.
* Validate the `cwd`/`additionalDirectories` contract shared by `session/new` and
* `session/load`: `cwd` must be absolute (a relative path would be ambiguous as a workspace
* root).
*/
function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] }): void {
if (!isAbsolute(params.cwd)) {
@@ -1125,38 +895,13 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
}
/**
* 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` during load replay only — so a
* loaded transcript reconstructs the USER side of each turn without echoing
* a live `session/prompt` back to the client
* - `tool/call` → `tool_call` (pending)
* - `tool/result` → `tool_call_update` (completed/failed)
*
* Tool-call presentation (title/kind/rawInput, and the completed-state content)
* is owned by each TOOL via `presentCall`/`presentResult` — the bridge never
* special-cases tool names. `presenter` resolves those from the tool registry
* and remembers each call's `(name, args)` so the completed `tool/result` (which
* carries neither) can find its tool. A {@link nullToolPresenter} gives the
* generic fallback (title = tool name, raw args as input) when no registry is
* available (e.g. pure translator tests).
*
* Other event types (turn/step boundaries, context/message, …) produce
* no client update.
* Translate one session event into zero or more ACP updates.
* @param sessionId - the ACP session id stamped on every emitted notification.
* @param event - the harness session event to translate.
* @param notify - sink for each produced `session/update` notification; called
* zero or more times per event (best-effort UI feed, never load-bearing).
* @param presenter - resolves tool-owned render intent for tool events;
* defaults to the generic-fallback {@link nullToolPresenter}.
* @param terminal - the connection's terminal-rendering context; defaults to
* disabled (the plain-text console-block fallback).
* @param options - `includeUserMessages` (default `true`): live streaming
* passes `false` so a prompt the client just sent is not echoed back.
* @param notify - best-effort update sink.
* @param presenter - tool render resolver; defaults to generic presentation.
* @param terminal - terminal rendering context; disabled by default.
* @param options - controls replay of user messages.
*/
export function streamSessionEventUpdate(
sessionId: SessionId,
@@ -1243,26 +988,11 @@ 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.
* 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.
*/
export class ToolPresenter {
private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>()
@@ -1307,49 +1037,38 @@ export class ToolPresenter {
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
present = undefined
}
// No tool-owned presentation: fall back to the tool name as the title, the
// full parsed args as the raw input, and kind `other` (the generic card).
// The kind is never sniffed from the name — the bridge does not special-case
// tool names; a tool that wants a richer kind declares `presentCall`.
// No tool-owned presentation: fall back to the tool name as the title, the full parsed args
// as the raw input, and kind `other` (the generic card).
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args }
this.pending.set(callId, { name, args, card: view.card })
return view
}
/**
* 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.
* @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.
* Resolve completed presentation from the remembered tool call.
* @param callId - matching call id; unknown ids use raw content.
* @param content - fallback result content.
* @param isError - result error flag.
* @param meta - optional tool metadata.
* @returns tool-owned view or normalized generic fallback.
*/
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
const call = this.pending.get(callId)
this.pending.delete(callId)
// No remembered call (unknown/late callId) → nothing to present from; raw content.
if (call === undefined) return { card: 'generic', content }
let present: ToolResultView | undefined
try {
present = this.tools.get(call.name, this.agent)
?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} })
} catch (error: unknown) {
// A throwing presentResult must not break streaming/replay: log + fall back.
// Presentation failure falls back without breaking replay or streaming.
this.onError(`acp: tool "${call.name}" presentResult threw, using raw result: ${String(error)}`)
present = undefined
}
if (present === undefined) return { card: 'generic', content }
// Orphan guard: only honor a `terminal` result when the PENDING call was a
// terminal. A result-only terminal with no matching call-side terminal would
// orphan `_meta.terminal_output` to a terminal Zed never made — drop it back
// to the raw content.
// A terminal result requires a terminal call card.
if (present.card === 'terminal' && call.card !== 'terminal') return { card: 'generic', content }
// A generic result that reformats no content keeps the RAW result content
// (the tool replaced only the title); fill it so the card is never blanked.
// Preserve raw content when a generic presenter changes only metadata.
if (present.card === 'generic' && present.content === undefined) return { ...present, content }
return present
}
@@ -1397,24 +1116,14 @@ type AcpToolCallContent =
| { 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 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`.
*/
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).
// Relativize only paths contained by the workspace; keep the workspace root absolute.
if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title
return title.split(rawPath).join(rel)
}
@@ -1473,11 +1182,9 @@ function toolCallUpdate(callId: CallId, view: ToolCallView, terminal: TerminalRe
}
}
case 'terminal': {
// A terminal-rendered call gets a terminal CARD when the client supports it:
// the description renders ABOVE the card, then the terminal block, plus
// `_meta.terminal_info` (the cwd header). Without the capability it is an
// ordinary execute card whose body is the description and whose rawInput is
// the command; the output arrives as text on the result.
// A terminal-rendered call gets a terminal CARD when the client supports it: the
// description renders ABOVE the card, then the terminal block, plus `_meta.terminal_info`
// (the cwd header).
const asTerminal = terminal.enabled
const description: AcpToolCallContent[] = view.description !== undefined
? [{ type: 'content', content: { type: 'text', text: view.description } }]
@@ -1522,16 +1229,7 @@ function terminalExitMeta(callId: string, view: TerminalResultView): TerminalExi
}
/**
* Build the `tool_call_update` (completed) `session/update` from a result render
* intent. A `generic` result sends its reformatted content (or the raw result);
* a `terminal` result rides its output/exit on `_meta` when the client is capable
* (the terminal card consumes them and `content` is OMITTED — a
* `tool_call_update.content` REPLACES the call's content collection in Zed, so
* re-sending would clobber the terminal block the call installed) and otherwise
* derives the fenced ```console fallback from `output`. A `diff` result emits its
* `{ type: 'diff' }` content blocks (an applied hunk, or a whole-file diff for a
* create), which replace the diff the call installed — so the model-facing result
* text can never clobber it.
* Build the `tool_call_update` (completed) `session/update` from a result render intent.
*/
function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean, terminal: TerminalRendering): ToolCallSessionUpdate {
const status = isError ? 'failed' as const : 'completed' as const
@@ -1573,12 +1271,7 @@ function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean
...view.title !== undefined ? { title: view.title } : {},
}
case 'diff': {
// A result-time diff: emit one `{ type: 'diff' }` content block per entry
// (an applied hunk for an edit/overwrite, or a whole-file diff for a
// create), mirroring the call-side diff arm. `tool_call_update.content`
// REPLACES the call's content in an editor, so this result diff supersedes
// the diff the pending card installed (and keeps the model-facing result
// text from clobbering it).
// Result diff content replaces the pending card's call-side diff.
const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText }))
// Relativize the replacement title against the session cwd from the diff
// path, exactly as the call-side card does — `tool_call_update.title`
+23 -69
View File
@@ -36,10 +36,8 @@ describe('acp bridge — disposal & HMR safety', () => {
})
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.
// Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop stay up and the
// transport is still live.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const before = harness.ctx.agents.list().length
@@ -51,14 +49,9 @@ 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 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.
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 +63,8 @@ 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.
// 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.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const before = harness.ctx.agents.list().length
@@ -85,10 +76,7 @@ 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.
// The ACP transport closes (editor quits) while a turn runs.
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: [] })
@@ -106,14 +94,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// 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.
// Await bridge quiescence without disposing root agent and session services.
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
@@ -122,9 +103,6 @@ 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).
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: [] })
@@ -157,14 +135,10 @@ 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 `session.onAppend` → `session/event`), and only
// THEN detach onAppend + remove the session. 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.
// 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
// `session.onAppend` → `session/event`), and only THEN detach onAppend + remove the
// session.
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: [] })
@@ -186,18 +160,8 @@ 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 `onAppend` is 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.
// The teardown-order contract only earns its keep when the closing events are produced BY
// the dispose itself.
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: [] })
@@ -223,11 +187,8 @@ 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.
// The factory returns a per-agent AgentHandle whose dispose() tears down EXACTLY that agent
// + its session — RFC 011 isolation.
const harness = await makeBridgeHarness({ storageDir, script: [] })
const handleA = await harness.ctx.agents.create({
agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
@@ -251,14 +212,8 @@ 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 `onAppend` 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.
// The AgentHandle teardown folds session-detach, register, and loop-stop into one composite
// effect whose disposers run as a `.then()` chain.
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({
@@ -276,11 +231,10 @@ 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.
// 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.
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' },
+1 -3
View File
@@ -19,9 +19,7 @@ describe('acp bridge — demux & config edges', () => {
})
it('ignores events from an agent the bridge does not own (strict id demux)', async () => {
// A second agent created directly on the registry (NOT via the bridge) runs
// a turn. Its session/event + agent/status must NOT produce ACP updates and
// must not settle anything — the bridge demuxes strictly by its own id.
// A second agent created directly on the registry (not via the bridge) runs a turn.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+13 -37
View File
@@ -1,12 +1,5 @@
/**
* Shared test fixtures for the ACP bridge specs. A plain module (NOT a
* *.spec.ts) so importing it does not re-register a describe block.
*
* `makeBridgeHarness` builds a full in-memory cordis context (llm + session +
* system-prompt + tools + agents + agent-loop + persistence) with the ACP
* bridge wired to an in-memory transport, plus a `ClientSideConnection` on the
* other end — so a test drives the bridge exactly as an editor would, with no
* subprocess and no real stdio.
* Shared test fixtures for the ACP bridge specs.
*/
import { Context } from 'cordis'
@@ -218,13 +211,9 @@ export async function makeBridgeHarness(options: {
}
ctx.llm.registerAdapter(['mock'], adapter)
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the
// agent writes flow to the client's reader and vice versa. (ndJsonStream
// takes (output, input): the agent writes to a2c and reads from c2a; the
// client writes to c2a and reads from a2c.) The client→agent path (c2a) runs
// through a hand-held writer so a test can close it (`closeClientTransport`)
// to simulate the editor disconnecting — closing it EOFs the agent's reader
// and resolves the bridge's `conn.closed`.
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the agent writes flow
// to the client's reader and vice versa. (ndJsonStream takes (output, input): the agent
// writes to a2c and reads from c2a; the client writes to c2a and reads from a2c.)
const a2c = new TransformStream<Uint8Array, Uint8Array>()
const c2a = new TransformStream<Uint8Array, Uint8Array>()
const c2aWriter = c2a.writable.getWriter()
@@ -253,11 +242,9 @@ export async function makeBridgeHarness(options: {
onSessionUpdateError: undefined,
client: undefined as unknown as ClientSideConnection,
acpFiber: undefined as unknown as BridgeHarness['acpFiber'],
// Close the writable the CLIENT writes to (c2a) — its readable, which the
// agent's ndJsonStream consumes, then EOFs cleanly, so the bridge's
// `conn.closed` resolves and it sees the client disconnect. If the client
// connection holds a writer lock on it, abort the connection's signal path
// instead by closing through the underlying stream.
// Close the writable the CLIENT writes to (c2a) — its readable, which the agent's
// ndJsonStream consumes, then EOFs cleanly, so the bridge's `conn.closed` resolves and it
// sees the client disconnect.
closeClientTransport: async () => { await c2aWriter.close() },
dispose: async () => { await ctx.fiber.dispose() },
storageDir: options.storageDir,
@@ -281,27 +268,16 @@ export async function makeBridgeHarness(options: {
},
})
// Wire the bridge (agent side) and the client (test side). The test config
// can override `model` (including to undefined): default to 'mock' unless the
// caller explicitly set the key (even to undefined), so a `{ model: undefined }`
// override means "no model at all".
// Wire the bridge (agent side) and the client (test side).
const cfg: AcpConfig = { stream: agentStream, ...options.config }
if (!(options.config && 'model' in options.config)) cfg.model = 'mock'
// Mount the bridge the way production does: as a cordis PLUGIN (via
// `ctx.plugin` with the real `inject`), NOT `AcpPlugin.apply(ctx, cfg)`
// directly on the root ctx. The plugin fiber is the faithful reproduction —
// the bridge's `apply` runs inside the fiber's injection scope, and its ACP
// handlers later run from the JSON-RPC read loop OUTSIDE that scope, exactly
// as under the example's cordis.yml. (Mounting directly on root made every
// service an ungated property and hid the "cannot get property … without
// inject" failure that bit a real Zed session.) `harness.acpFiber.dispose()`
// tears down JUST the bridge (its listeners + effect) for the HMR test.
// Mount the bridge the way production does: as a cordis plugin (via `ctx.plugin` with the
// real `inject`), not `AcpPlugin.apply(ctx, cfg)` directly on the root ctx.
harness.acpFiber = await ctx.plugin({
name: 'acp-test',
// Use the bridge's REAL exported `inject` so this never drifts from the
// plugin's actual dependency list (adding a service to the bridge must not
// require editing the harness — a hardcoded list silently broke when `tools`
// was added). The bridge programs against the interface packages only.
// Use the bridge's real exported `inject` so this never drifts from the plugin's actual
// dependency list (adding a service to the bridge must not require editing the harness — a
// hardcoded list silently broke when `tools` was added).
inject: [...AcpPlugin.inject],
apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) },
})
+6 -22
View File
@@ -58,12 +58,7 @@ describe('acp bridge — session/load replay', () => {
})
it('replays a persisted tool call with the TOOL-OWNED presentation (title/rawInput/console output)', async () => {
// A turn with a REAL bash tool call is persisted, then loaded by a fresh
// bridge. The replayed tool_call/tool_call_update must carry the tool's OWN
// presentation — identical to how it streamed live — via a throwaway
// presenter that pairs call→result as the log replays in order. Uses the
// shipping tool (withBash), not a stand-in (docs/testing.md "prefer the real
// implementation over a mock in tests").
// A turn with a real bash tool call is persisted, then loaded by a fresh bridge.
live = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -95,10 +90,7 @@ describe('acp bridge — session/load replay', () => {
})
it('replays a persisted todo/write as a plan sessionUpdate on load', async () => {
// A turn whose model called todo_write persists a todo/write event. A fresh
// bridge loading the session must re-emit the ACP `plan` update from the log
// (the load replay runs every event through streamSessionEventUpdate), so an
// editor reopening the session sees the current plan.
// A turn whose model called todo_write persists a todo/write event.
live = await makeBridgeHarness({
storageDir,
withTodo: true,
@@ -169,11 +161,7 @@ describe('acp bridge — session/load replay', () => {
})
it('a load whose resume finishes after a client disconnect leaks no live session', async () => {
// A session/load is mid-resume() when the client transport closes. The load
// must NOT end up with a live registered agent for the connection that is
// already gone. (The bridge's post-await `closed` guard backs this on real
// stdio; here the SDK rejects the in-flight request on close — either way no
// agent survives.) Stall persistence so resume() is pending across the close.
// A session/load is mid-resume() when the client transport closes.
live = await makeBridgeHarness({ storageDir, script: [textResponse('x')] })
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -198,10 +186,8 @@ describe('acp bridge — session/load replay', () => {
})
it('rejects load when the requested cwd does not match the persisted session cwd', async () => {
// Seed a session on disk whose header.cwd is a DIFFERENT absolute path than
// the server's launch dir. The bridge must LOAD it (per-session cwd is
// honored — the resumed session keeps header.cwd, and bash routes there), no
// longer reject on a mismatch.
// Seed a session on disk whose header.cwd is a DIFFERENT absolute path than the server's
// launch dir.
loader = await makeBridgeHarness({ storageDir, script: [] })
const otherCwd = '/some/other/workspace'
await loader.ctx.sessionPersistence.create({
@@ -237,9 +223,7 @@ describe('acp bridge — session/load replay', () => {
})
it('rejects loading a persisted session that has NO cwd (would silently run in the launch dir)', async () => {
// A legacy / externally-created session log with no header.cwd. The bridge
// must reject the load rather than accept it and let bash silently fall back
// to the server's launch dir (the request cwd does not override the header).
// A legacy / externally-created session log with no header.cwd.
loader = await makeBridgeHarness({ storageDir, script: [] })
await loader.ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION, id: SessionId('legacy'), createdAt: 1, // no cwd
+3 -13
View File
@@ -1,17 +1,7 @@
/**
* Property-based protocol-shape tests for the ACP update stream (RFC 001 →
* ADR 0013 precedent). Fuzz arbitrary harness `SessionEvent` sequences through
* the pure `streamSessionEventUpdate` translator and assert the invariants an
* ACP client relies on:
*
* - every emitted update is a legal `SessionUpdate` variant;
* - a `tool_call_update` for a given id is never emitted before a `tool_call`
* for that id (the client must see the pending call before its completion);
* - the translator is a pure function of the event (same event → same updates),
* so live streaming and `session/load` replay produce identical streams.
*
* Pure-function fuzzing (no live loop) keeps these deterministic — a failure is
* a real finding, not timing noise.
* Property-based protocol-shape tests for the ACP update stream (RFC 001 → ADR 0013
* precedent). Fuzz arbitrary harness `SessionEvent` sequences through the pure
* `streamSessionEventUpdate` translator and assert the invariants an ACP client relies on.
*/
import { describe, expect, it } from 'vitest'
+14 -29
View File
@@ -288,10 +288,8 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
})
it('a THROWING presentCall/presentResult is contained: generic fallback + onError, never propagates', () => {
// A buggy tool whose display callbacks throw must NOT fail a live turn or a
// session/load replay (docs/defensive-patterns.md "contain callback exceptions at the
// boundary"). The presenter swallows the throw, reports via onError, and
// falls back to the generic presentation.
// A buggy tool whose display callbacks throw must not fail a live turn or a session/load
// replay (docs/defensive-patterns.md "contain callback exceptions at the boundary").
const boom: ToolDefinition = {
name: 'boom',
description: 'b',
@@ -621,12 +619,10 @@ describe('diff-card mapping', () => {
})
describe('result-time diff card (REAL fs edit tool → tool_call_update diff blocks)', () => {
// Drive the SHIPPING fs edit tool through the bridge: the pending tool/call
// installs the call-time snippet, then the tool/result carries the tool's
// computed applied-hunk `meta`, which presentResult narrows into a `diff`
// result card the bridge forwards as `{ type: 'diff' }` content blocks. Uses
// the REAL tool (not a stand-in) per the anti-mock convention, mirroring the
// call-side diff test above.
// Drive the SHIPPING fs edit tool through the bridge: the pending tool/call installs the
// call-time snippet, then the tool/result carries the tool's computed applied-hunk `meta`,
// which presentResult narrows into a `diff` result card the bridge forwards as `{ type:
// 'diff' }` content blocks.
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -678,11 +674,9 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
})
it('the completed diff TITLE relativizes against the session cwd (the result title replaces the card header)', async () => {
// A `tool_call_update.title` replaces the card header, so the result-side
// diff must relativize its title exactly as the pending card did — otherwise
// a completed absolute-path edit flips `Edit src/b.ts` back to the raw
// absolute path. The diff/location paths stay absolute (the editor opens the
// real path). Drive the REAL fs edit tool with an absolute in-workspace path.
// A `tool_call_update.title` replaces the card header, so the result-side diff must
// relativize its title exactly as the pending card did — otherwise a completed
// absolute-path edit flips `Edit src/b.ts` back to the raw absolute path.
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
const args = JSON.stringify({ file_path: '/work/proj/src/b.ts', old_string: 'OLD', new_string: 'NEW' })
@@ -704,11 +698,7 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
})
it('a diff result with an EMPTY diffs array and no title omits both keys (nothing to send)', () => {
// A synthetic tool whose presentResult yields a `diff` card with no hunks and
// no title — the shipping fs tools never emit this (edit always has a hunk;
// write always falls back to a whole-file diff), so a stand-in is the only way
// to exercise the empty-content AND absent-title branches of the result-side
// diff arm.
// A synthetic empty diff covers branches shipping filesystem tools cannot emit.
const emptyDiffTool: ToolDefinition = {
name: 'writer',
description: 'writes a file',
@@ -734,11 +724,8 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
})
describe('relative-path display titles (bridge relativizes the title against the session cwd)', () => {
// The bridge relativizes a file card's TITLE against the session workspace cwd
// (mirroring the reference adapter's toDisplayPath), while leaving locations/
// diff paths RAW. Drive it with the REAL fs tools so the title/locations come
// from the shipping presentCall, and pass an ABSOLUTE file path (which a real
// editor forwards). The presenter is pure/args-only; the cwd is known only here.
// The bridge relativizes a file card's TITLE against the session workspace cwd (mirroring the
// reference adapter's toDisplayPath), while leaving locations/ diff paths RAW.
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -789,10 +776,8 @@ describe('relative-path display titles (bridge relativizes the title against the
})
it('an in-workspace file whose relative form starts with `..` chars (a sibling name) still relativizes', async () => {
// `/work/proj/..cache/x` is INSIDE the workspace — its relative form
// `..cache/x` begins with the chars `..` but is NOT a parent segment. The
// guard tests for a `..` SEGMENT, so this relativizes (matching the reference
// adapter, which accepts any target under `cwd + sep`).
// `/work/proj/..cache/x` is inside the workspace — its relative form `..cache/x` begins
// with the chars `..` but is not a parent segment.
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/..cache/x.ts' })
expect((update as { title: string }).title).toBe('Read ..cache/x.ts')
+26 -53
View File
@@ -40,9 +40,8 @@ describe('acp bridge — turn outcomes', () => {
})
it('rejects the prompt RPC when a turn fails (no misleading end_turn)', async () => {
// ACP has no "error" stop reason; a failed turn must surface as a rejected
// session/prompt, not a normal end_turn that hides the failure from the
// client. The bridge rejects via the turn/end{error} log record.
// ACP has no "error" stop reason; a failed turn must surface as a rejected session/prompt,
// not a normal end_turn that hides the failure from the client.
harness = await makeBridgeHarness({ storageDir, script: [errorResponse('provider boom')] })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
@@ -80,12 +79,9 @@ describe('acp bridge — turn outcomes', () => {
})
it('the REAL bash tool drives the tool-call UI end-to-end: command title + description block + console output', async () => {
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline
// stand-in, so this verifies the actual presentCall/presentResult the editor
// sees (docs/testing.md "prefer the real implementation over a mock").
// The mock MODEL still scripts the tool call (no real LLM needed), but the
// tool and executor are real: a real `echo` runs and its real output flows
// back through the bridge.
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline stand-in, so this
// verifies the actual presentCall/presentResult the editor sees (docs/testing.md "prefer
// the real implementation over a mock").
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -124,11 +120,8 @@ 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).
// Drive the real bash tool, and advertise the Zed `_meta.terminal_output` capability in
// initialize.
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -164,11 +157,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.
// The session is created with the capability ON.
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -192,10 +181,9 @@ describe('acp bridge — turn outcomes', () => {
})
it('a throwing tool presenter does not break the turn: the bridge falls back generically', async () => {
// A buggy tool whose presentCall throws must not fail the live turn — the
// bridge's presenter contains the throw (logging via its onError sink) and
// falls back to the generic title=name presentation. Exercises the real
// bridge wiring of the per-session presenter's error sink.
// A buggy tool whose presentCall throws must not fail the live turn — the bridge's
// presenter contains the throw (logging via its onError sink) and falls back to the generic
// title=name presentation.
harness = await makeBridgeHarness({
storageDir,
script: [toolCallResponse('c1', 'kaboom', { x: 1 }), textResponse('done')],
@@ -236,11 +224,9 @@ describe('acp bridge — turn outcomes', () => {
})
it('settles via the log fallback when a prior session/event listener throws (starvation)', async () => {
// A peer session/event listener that runs BEFORE the bridge's listener
// throws on turn/end (prepend: true puts it first). cordis emit stops at the
// throw, so the bridge's session/event listener never sees turn/end and
// cannot settle there. The agent/status idle-fallback must reconcile the
// prompt from the log so the RPC settles instead of hanging.
// A peer session/event listener that runs before the bridge's listener throws on turn/end
// (prepend: true puts it first). cordis emit stops at the throw, so the bridge's
// session/event listener never sees turn/end and cannot settle there.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] })
harness.ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/end') throw new Error('peer listener boom')
@@ -263,13 +249,8 @@ describe('acp bridge — turn outcomes', () => {
})
it('log fallback infers the owning turn when turn/START capture is starved', async () => {
// A peer listener throws on turn/START (not turn/end): the bridge never
// captures inflight.turn via the live stream. A throwing turn/start listener
// also FAILS the turn (the throw is recorded as the turn's error). Without
// the watermark inference the fallback would resolve `cancelled` (the bug);
// with it, it infers the owning turn from the log and REJECTS from that
// turn's error turn/end. (The model's own error is never reached — the turn
// failed at start — so the rejection carries the listener's failure.)
// A peer listener throws on turn/START (not turn/end): the bridge never captures
// inflight.turn via the live stream.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('never runs')] })
harness.ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/start') throw new Error('peer listener boom on start')
@@ -280,11 +261,8 @@ describe('acp bridge — turn outcomes', () => {
})
it('a between-turn injection does not settle the prompt early (message-trigger correlation)', async () => {
// A plugin injects context (a one-shot injection-triggered turn) right after
// the prompt is queued but before the prompt's own message turn runs. The
// bridge must NOT mistake the injection turn's turn/end for the prompt's —
// it correlates only to message-triggered turns. The prompt settles on its
// OWN turn with the real model answer.
// A plugin injects context (a one-shot injection-triggered turn) right after the prompt is
// queued but before the prompt's own message turn runs.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(AgentId(sessionId))!
@@ -332,11 +310,9 @@ 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.
// 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).
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' }] })
@@ -353,10 +329,9 @@ describe('acp bridge — turn outcomes', () => {
})
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.
// 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.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
// Cancel while idle (no prompt in flight) — a no-op.
@@ -392,10 +367,8 @@ 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.
// Regression: prompt A runs; cancel settles A and frees the slot; A's aborted turn/end is
// still pending in the loop.
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B answer')] })
const sessionId = await newSession(harness)