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

View File

@@ -1,29 +1,8 @@
#!/usr/bin/env node
/**
* The `dsh-acp-agent` bin: boot the ACP server from a leaf `cordis.yml` that
* loads the {@link @deepseek-ai/dsh-acp-agent} app plugin (plus an LLM adapter
* and a bash executor), speaking ACP JSON-RPC on stdio. The shared boot glue —
* `.env` loading, the fail-loud Loader guards, snapshot-aware config
* resolution, the settle-the-tree boot sequence — lives in
* {@link @deepseek-ai/dsh-app-boot}; this bin owns only the ACP-specific
* lifecycle:
*
* - `.env` loading is SKIPPED in snapshot REPLAY so a stray key can never
* trigger a live model call.
* - `DSH_SNAPSHOT=replay` swaps the given `cordis.yml` for its sibling
* `cordis.snapshot.yml` (the keyless replay tree: `llm-replay` in place of
* `llm-deepseek`).
* - In a snapshot run the harness closes stdin when done, so dispose the
* context (flushing persistence) and exit cleanly. In a normal editor
* session stdin stays open for the connection's lifetime (the editor kills
* the process), so the EOF handler never fires.
*
* IMPORTANT: stdout is the ACP JSON-RPC channel. This bin writes diagnostics to
* STDERR only (the app plugin loads no stdout logger, and the shared guards
* write to stderr); a stray stdout write corrupts the protocol frames.
*
* Usage: `dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`).
*
* The `dsh-acp-agent` bin: boot the ACP server from a leaf `cordis.yml` that loads the {@link
* @deepseek-ai/dsh-acp-agent} app plugin (plus an LLM adapter and a bash executor), speaking
* ACP JSON-RPC on stdio.
* @module @deepseek-ai/dsh-acp-agent/bin
*/

View File

@@ -1,32 +1,7 @@
/**
* The ACP server app: the default agent spine ({@link
* @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster an ACP
* server needs — JSONL session persistence and the {@link @deepseek-ai/dsh-acp}
* bridge, and DELIBERATELY NOTHING that writes to stdout.
*
* The cluster is the OPPOSITE of {@link @deepseek-ai/dsh-stdio-agent}'s, and
* baking it in is the whole point: an ACP server speaks JSON-RPC on stdout, so
* a stray console logger would corrupt the protocol frames (the [stdout-purity
* footgun]). This package contains NO console-logger entry, NO `hmr` (the editor
* owns the subprocess), and pre-creates NO agents (ACP `session/new` creates
* them on demand) — so the default front door has no logger entry to get wrong.
* (A leaf `cordis.yml` could still add a sibling `@cordisjs/plugin-logger-console`,
* which this app does not prevent — so the rule "never add a stdout logger to an
* ACP leaf" still stands; the app just gives the leaf nothing to misconfigure.)
*
* The leaf supplies the swappable backends: the LLM adapter (`llm-deepseek` for
* the real model, `llm-replay` for keyless snapshot replay), the bash executor
* (`bash-local`), and any optional product tools it wants to expose. This app's
* {@link Config} (model, system prompt, persistence root) routes each value to
* where it is wired — model/prompt onto the bridge's per-session agent
* template, the root onto the JSONL backend.
*
* Plugin export shape: named `name`/`Config`/`apply`, NO default export — the
* cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray
* default would collapse the module to the bare `apply` and drop the `Config`
* namespace (see docs/postmortem/0001 — the exact bug that shipped here once).
* The keyless ACP snapshot/Loader-path tests guard this end-to-end.
*
* The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}) plus the
* coupled front-door cluster an ACP server needs — JSONL session persistence and the {@link
* @deepseek-ai/dsh-acp} bridge, and deliberately NOTHING that writes to stdout.
* @module @deepseek-ai/dsh-acp-agent
*/

View File

@@ -139,14 +139,7 @@ describe('dsh-acp-agent composition', () => {
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
// Postmortem 0001 guard: a stray `export default apply` makes the Loader's
// `unwrapExports` (`exports.default ?? exports`) collapse the module to the
// bare `apply` function, DROPPING the named `name`/`Config`. This package has
// no `inject` export, so that collapse would NOT crash at load (the keyless
// bin smoke would still answer `initialize`) — it would silently lose its
// config schema. So guard the shape directly here: assert no `default`
// export, and that the real `unwrapExports` leaves `name`/`Config`/`apply`
// intact. Adding `export default` to src/index.ts fails this test.
// Loader must retain the namespace so name, Config, and apply survive unwrapping.
expect('default' in acpAgent).toBe(false)
expect(typeof acpAgent.apply).toBe('function')

View File

@@ -18,20 +18,9 @@ import { Readable, Writable } from 'node:stream'
import { afterEach, describe, expect, it } from 'vitest'
/**
* BUILT-ARTIFACT smoke for the published `dsh-acp-agent` bin. `load-path.e2e.ts`
* boots `src/bin.ts` under tsx — but the package's `bin` field points at
* `lib/bin.js`, run under plain `node` by a real consumer. This runs the REAL
* `lib/bin.js` under `node` (NOT tsx) and asserts it answers an `initialize`
* JSON-RPC frame, so a regression in the published entry (a settle race that
* exits before the bridge attaches, a stdout logger leaking onto the protocol)
* fails here.
*
* It build-gates: SKIPS if `lib/bin.js` is absent (suite run without
* `pnpm run build`); CI runs it after the build step. Setup mirrors a real
* install (a temp dir whose `node_modules` symlinks the built packages) and runs
* `node --expose-internals` (the cordis Loader resolves bare plugin specifiers
* via its internal module loader, active only under that flag). KEYLESS:
* `initialize` never reaches the model; a dummy key lets `llm-deepseek` boot.
* Built-ARTIFACT smoke for the published `dsh-acp-agent` bin. `load-path.e2e.ts` boots
* `src/bin.ts` under tsx — but the package's `bin` field points at `lib/bin.js`, run under
* plain `node` by a real consumer.
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
@@ -48,12 +37,7 @@ const vendorPackages = [
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
'schemastery', 'cosmokit',
]
// Third-party deps the ACP bridge needs at runtime. They are declared by
// `dsh-acp` (NOT by `acp-agent`), so they live under `packages/ui/acp/node_modules`
// and are NOT necessarily hoisted where THIS test file can resolve them — pnpm's
// strict layout only exposes a package's deps under that package. Resolve each
// from the `ui/acp` package directory (the one that declares it) so the lookup
// works regardless of hoisting, then symlink it into the consumer for plain node.
// Third-party deps the ACP bridge needs at runtime.
const npmDeps = ['@agentclientprotocol/sdk', 'zod']
const acpPkgDir = join(repoRoot, 'packages/ui/acp')
@@ -161,18 +145,14 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js,
}, 30_000)
it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
// A typo'd config path must fail clearly, not exit 0. The include plugin
// itself cannot be imported from a non-existent dir; the Loader logs that and
// leaves the entry with no fiber, which boot()'s entry-load check throws on.
// A typo'd config path must fail clearly, not exit 0.
const { code, stderr } = await runBinExpectingExit('/nonexistent/dir/cordis.yml')
expect(code).not.toBe(0)
expect(stderr).toContain('failed to load')
}, 30_000)
it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
// The directory exists (the include imports), but the file does not — the
// include's init throws "config file not found", which surfaces as an
// unhandled rejection the fail-loud guard turns into a non-zero exit.
// Existing directory plus missing config exercises the include plugin's fail-loud path.
consumer = await makeConsumer()
const { code, stderr } = await runBinExpectingExit('./does-not-exist.yml', consumer)
expect(code).not.toBe(0)

View File

@@ -17,22 +17,9 @@ import {
} from '@agentclientprotocol/sdk'
/**
* REAL-load-path smoke for @deepseek-ai/dsh-acp-agent: boot the app through its
* own `bin` (the demo:acp entry) as a subprocess, driving the cordis Loader and
* `unwrapExports` over a minimal `cordis.yml` that loads THIS package. This is
* the guard a hand-built `ctx.plugin({...})` mount structurally cannot be — that
* bypasses `unwrapExports`, the exact path that once dropped the bridge's
* `inject` and shipped (docs/postmortem/0001). It exercises the headline ACP
* operations end-to-end: `initialize` → `session/new` → `session/load`.
*
* KEYLESS: `session/new` and `session/load` reach the agent FACTORY but never
* the model (no prompt is sent), so no DEEPSEEK_API_KEY is needed. A dummy key
* lets `llm-deepseek`'s `apply()` (key-PRESENT check only) boot the tree.
*
* The config is written into a temp dir whose cwd IS the session workspace, so
* the bash workdir validation passes. We point tsx at the repo-root tsconfig
* (TSX_TSCONFIG_PATH) because the child's cwd is outside the repo and the
* unbuilt `paths` map is found by searching UP from cwd.
* real-load-path smoke for @deepseek-ai/dsh-acp-agent: boot the app through its own `bin` (the
* demo:acp entry) as a subprocess, driving the cordis Loader and `unwrapExports` over a
* minimal `cordis.yml` that loads THIS package.
*/
const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
@@ -131,13 +118,8 @@ describe('dsh-acp-agent real-load-path smoke (bin + Loader, keyless)', () => {
const { sessionId } = await client.newSession({ cwd, mcpServers: [] })
expect(sessionId).toBeTruthy()
// session/load reaches the resume FACTORY + persistence without the model:
// load an UNKNOWN id (loading the live `sessionId` would correctly reject as
// "already loaded"). The bridge consults `sessionPersistence.list()` then
// `agents.resume()`, both of which run from the JSON-RPC read loop OUTSIDE
// the bridge's inject scope — the exact path postmortem 0001 crashed. A
// healthy tree rejects with a not-found error; a broken export shape would
// instead throw "cannot get property … without inject" before reaching it.
// session/load reaches the resume FACTORY + persistence without the model: load an UNKNOWN
// id (loading the live `sessionId` would correctly reject as "already loaded").
const unknownId = '00000000-0000-4000-8000-000000000000'
await client.loadSession({ sessionId: unknownId, cwd, mcpServers: [] }).then(
() => { throw new Error('expected session/load of an unknown id to reject') },

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.

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'
}

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`

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' },

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: [] })

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) },
})

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

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'

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')

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)

View File

@@ -1,27 +1,7 @@
/**
* Shared boot glue for the app bins (`dsh-stdio-agent`, `dsh-acp-agent`): load
* the gitignored `.env`, install the fail-loud Loader guards, resolve the
* config path (snapshot-aware), and drive the cordis Loader against a leaf
* `cordis.yml` until the whole tree has settled. Each bin stays a thin
* self-executing composition over these helpers, parameterized by its
* diagnostic prefix; the loader-failure lore lives here, once, under the
* per-file coverage gate.
*
* Two failure classes the guards handle:
*
* - `loader.await()` does NOT rethrow a load error (`EntryTree.await()` uses
* `Promise.allSettled`, which swallows rejections). A plugin whose
* `[Service.init]` throws surfaces as an unhandled rejection AFTER `boot()`
* resolves — Node's default handler already exits non-zero, and
* {@link installFailLoud} replaces the noisy dump with one labelled stderr
* line and a guaranteed `exit(1)`.
* - A plugin module that fails to IMPORT is caught and only LOGGED by the
* cordis Loader (`entry._init`), leaving the entry with no `fiber` and
* producing no rejection — the process would otherwise exit 0 with a usable
* config typo reported only as a log line; {@link assertEntriesLoaded} makes
* `boot()` reject on any such entry instead of returning a half-empty
* context.
*
* Shared boot glue for the app bins (`dsh-stdio-agent`, `dsh-acp-agent`): load the gitignored
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), and
* drive the cordis Loader against a leaf `cordis.yml` until the whole tree has settled.
* @module @deepseek-ai/dsh-app-boot
*/
@@ -31,13 +11,11 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
/**
* Resolve the config to boot, honoring snapshot REPLAY. Given the requested
* path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in
* the SAME directory (the keyless replay tree). Other modes — including no
* snapshot mode at all — use the path as-is. Returns an absolute path resolved
* from `cwd`.
* Resolve the config to boot, honoring snapshot replay.
*
* @param configPath - the requested config path (absolute, or relative to `cwd`).
* @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the basename.
* @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the
* basename.
* @param cwd - the base a relative `configPath` resolves against.
* @returns the absolute path of the config to boot.
*/
@@ -88,15 +66,8 @@ export interface FailLoudProcess {
}
/**
* Make a load failure fail loud with a clear message on stderr. Covers the
* failure path {@link assertEntriesLoaded} cannot: an include whose
* `[Service.init]` throws (e.g. a config FILE that does not exist in a real
* directory) surfaces as an unhandled promise rejection AFTER `boot()`
* resolves. Node's default handler already exits non-zero on an unhandled
* rejection; this replaces the noisy stack dump with a single labelled line on
* STDERR (never stdout — for the ACP bin that channel carries JSON-RPC) and
* guarantees `exit(1)`. Install before `boot()`. Returns the uninstaller
* (tests use it; the bins run until exit and never do).
* Make a load failure fail loud with a clear message on stderr.
*
* @param binName - the diagnostic prefix on the fatal-failure line.
* @param proc - the process slice to register on; tests inject a fake.
* @returns the uninstaller that removes the rejection handler.
@@ -111,13 +82,8 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process
}
/**
* After the tree settles, assert every loader entry actually started. A
* started entry has a `fiber`; an entry with `fiber === undefined` after the
* tree settled never loaded (its module failed to import), so throw and let
* `boot()` reject instead of returning a half-empty context. A `disabled`
* entry is the one legitimate fiber-less state: `Entry.refresh()` deliberately
* skips `init()` for it — a valid "plugin turned off" config, not a failed
* import — so it is excluded.
* After the tree settles, assert every loader entry actually started.
*
* @param ctx - the settled context whose loader entries to audit.
* @param binName - the diagnostic prefix on the thrown error.
*/
@@ -130,27 +96,9 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
}
/**
* Boot the Loader against `absoluteConfigPath` and return the root context
* once the whole tree has settled. The include is handed the config's ABSOLUTE
* `file://` URL as its `path`, so resolution never depends on `ctx.baseUrl`
* (an absolute URL ignores the base) and can never fall back to the cwd;
* `baseUrl` is still pinned to the config's directory so the config's OWN
* relative plugin/include paths resolve against it.
* Boot the Loader against `absoluteConfigPath` and return the root context once the whole tree
* has settled.
*
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns
* once the include ENTRY is registered, but the include then loads its child
* plugins asynchronously — without awaiting the tree, `boot()` would resolve
* while the app's plugins are still mounting, and a CLI process with no
* attached handles yet exits 0 silently. Failures surface two ways: an entry
* whose module failed to import is caught here by {@link assertEntriesLoaded}
* (this `boot()` rejects); an init that THROWS surfaces as an unhandled
* rejection caught by {@link installFailLoud} (installed by the bin first).
*
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages)
* are resolved by the cordis Loader's internal module loader, which is only
* active under `node --expose-internals`; a consumer running a built bin must
* pass that flag (or install the plugins where node hoists them). Relative
* specifiers resolve against the config directory with no flag.
* @param binName - the diagnostic prefix for load-failure errors.
* @param absoluteConfigPath - the config to include; must already be absolute
* (see {@link resolveConfigPath}).

View File

@@ -1,14 +1,7 @@
#!/usr/bin/env node
/**
* The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that
* loads the {@link @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM
* adapter and a bash executor). The boot glue — `.env` loading, the fail-loud
* Loader guards, the settle-the-tree boot sequence — lives in
* {@link @deepseek-ai/dsh-app-boot}, shared with the ACP bin.
*
* Usage: `dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`). The
* `demo:echo` / `demo:repl` scripts invoke it with the example's config.
*
* The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that loads the {@link
* @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM adapter and a bash executor).
* @module @deepseek-ai/dsh-stdio-agent/bin
*/

View File

@@ -1,40 +1,8 @@
/**
* The stdio chat app: the default agent spine ({@link
* @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal
* chat needs — a console logger, the readline UI (the in-package `stdio-chat`
* module), JSONL session
* persistence, and a pre-created `main` agent the UI drives.
*
* The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the
* console (stdout is just the terminal) and always pre-creates the `main` agent
* the readline UI sends to. The leaf supplies the swappable backends (the LLM
* adapter, the bash executor), optional product tools, the optional `hmr`
* dev-reload plugin, and this app's {@link Config} (model, prompt, persistence
* root, welcome banner).
*
* `hmr` is deliberately a LEAF entry, not baked in here: it is a Loader-only,
* subprocess-only dev plugin (its constructor throws without `--expose-internals`
* + a live `loader`, and the in-process test tier cannot even import it), so a
* package whose `apply` statically pulled it in could never be unit-tested or
* carry the per-file coverage gate. Unlike the console logger, a stray `hmr` is
* not a stdout-purity footgun — so leaving it at the leaf costs no safety, while
* baking the LOGGER in (the real coupling) keeps stdout-vs-no-stdout a property
* of the artifact.
*
* Counterpart to {@link @deepseek-ai/dsh-acp-agent}, which bakes in the OPPOSITE
* cluster (no stdout logger, no pre-created agents — the ACP bridge reserves
* stdout for JSON-RPC and creates agents on demand). Splitting the two front
* doors into two packages makes each cluster a property of the artifact: there
* is no logger entry in the ACP leaf to get wrong.
*
* Plugin export shape: named `name`/`Config`/`apply`, NO default export — the
* cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray
* default would collapse the module to the bare `apply` and drop the `Config`
* namespace (see docs/postmortem/0001). This app carries no `inject`, so a
* collapsed shape would BOOT rather than crash a smoke — the shape is pinned by
* the explicit `unwrapExports` assertion in this package's unit suite, and the
* keyless echo smoke proves the composed tree runs through the real Loader.
*
* The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}) plus the
* coupled front-door cluster a terminal chat needs — a console logger, the readline UI (the
* in-package `stdio-chat` module), JSONL session persistence, and a pre-created `main` agent
* the UI drives.
* @module @deepseek-ai/dsh-stdio-agent
*/

View File

@@ -1,17 +1,6 @@
/**
* The stdio app's readline UI: reads lines from stdin → `agent.send()`/
* `steer()`, and renders the durable transcript to stdout. A UI is "just a
* plugin" — it consumes the `session/event` feed (the assistant token stream,
* turn/step boundaries, tool activity, todos) plus a few `agent/*` control
* events (`agent/status`, `agent/created`/`agent/disposed`) and the `agents`
* service. Dimmed chain-of-thought rendering plus robust piped-stdin EOF→idle
* exit handling, configured via {@link Config}.
*
* An internal module of the stdio app, not a package of its own: the app's
* front-door cluster always includes this UI, and nothing else composes it.
* The export shape stays named `name`/`inject`/`Config`/`apply` — the plugin
* contract the app's `ctx.plugin(uiStdio, …)` mount consumes.
*
* The stdio app's readline UI: reads lines from stdin → `agent.send()`/ `steer()`, and renders
* the durable transcript to stdout.
* @module @deepseek-ai/dsh-stdio-agent/stdio-chat
*/
@@ -80,15 +69,10 @@ type OptionSelection =
| { kind: 'invalid' }
/**
* The plugin body, parameterized over its I/O runtime. `apply` is the thin
* production wrapper that binds the real `process` streams; tests call this
* directly with fakes. Returns nothing — all registration is via `ctx.on`/
* `ctx.effect`, so fiber disposal tears every listener and the readline
* interface down.
* @param ctx - the context supplying the `agents` service and the event feeds.
* @param config - the plugin config; defaults are re-applied here for direct
* callers that bypass Loader validation.
* @param runtime - the process-I/O seam (line source, render sink, exit hook).
* Register stdio chat against an injectable I/O runtime.
* @param ctx - agent and event context.
* @param config - plugin config, defaulted for direct callers.
* @param runtime - line source, render sink, and exit hook.
*/
export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void {
// Default here too (not just via schemastery's `.default()`): this helper is
@@ -99,26 +83,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
const agentId = AgentId(config.agent ?? 'main')
const { input, output, exit } = runtime
// Render label lookup: the `turn/start` session event carries only the turn
// number, so to print the short agent id (`[main turn 1]`) we map the
// session's id to its agent's id. The session id is not reliably the agent id
// (a session can be created with an explicit/client-supplied id), so build the
// map from `agent/created` rather than parsing the id string. Seed from the
// registry's current agents first: an agent registered before this plugin
// installed (e.g. the pre-created `main` agent, or any agent surviving an HMR
// reload of just this fiber) already fired its `agent/created`, so the live
// listener alone would miss it and its turns would fall back to the raw
// session id.
// Render label lookup: the `turn/start` session event carries only the turn number, so to
// print the short agent id (`[main turn 1]`) we map the session's id to its agent's id.
const labelBySession = new Map<string, string>()
for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id)
ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) })
ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) })
// Transcript rendering off the durable `session/event` feed — the assistant
// token stream, turn/step boundaries, tool activity, and todos all come from
// the one canonical stream (no agent/* mirrors). A single listener over the
// append order keeps `inReasoning` transitions deterministic across chunk and
// boundary events.
// Transcript rendering off the durable `session/event` feed — the assistant token stream,
// turn/step boundaries, tool activity, and todos all come from the one canonical stream (no
// agent/* mirrors).
let inReasoning = false
ctx.on('session/event', (session, event) => {
if (event.type === 'assistant/chunk') {
@@ -161,16 +135,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
ctx.effect(() => {
const reader = createInterface({ input, output, terminal: isTTYPair(input, output) })
// Piped-input exit, once stdin reaches EOF:
// - If no line ever submitted work (empty stdin, blank-only lines), exit
// immediately — no turn will ever start, so there is nothing to wait
// for. (Gating on an observed 'running' here would hang forever.)
// - If work WAS submitted, exit the next time the agent settles to idle
// AFTER having run. Two subtleties this handles: the loop batches
// several queued messages into ONE turn (one idle), so we don't count
// sends; and agent.send() does NOT synchronously flip status to
// 'running', so requiring an observed 'running' first (`sawRunning`)
// avoids exiting in the gap before the turn starts and dropping work.
// Piped-input exit, once stdin reaches EOF: - If no line ever submitted work (empty stdin,
// blank-only lines), exit immediately — no turn will ever start, so there is nothing to
// wait for.
let stdinClosed = false
let disposed = false
let submittedWork = false
@@ -188,10 +155,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
const agent = ctx.agents.get(agentId)
if (agent && agent.status !== 'idle') return // a turn is still running
}
// Let any final output flush, then exit. The handle is tracked so the
// disposer can cancel it — a dispose within the flush window must not let
// the process exit out from under HMR. Re-entrant `maybeExit` calls (e.g.
// repeated idle signals) coalesce onto the one pending timer.
// Let any final output flush, then exit.
if (exitTimer !== undefined) {
return // exit already scheduled — coalesce re-entrant calls
}

View File

@@ -7,31 +7,13 @@ import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
/**
* BUILT-ARTIFACT smoke for the published `dsh-stdio-agent` bin. The other smokes
* boot `src/bin.ts` under tsx — but the package's `bin` field points at
* `lib/bin.js`, run under plain `node` by a real consumer. tsx masks two failure
* modes the built bin had: (1) `boot()` returned before the loader tree settled,
* so the process exited 0 with no output and load errors surfaced as unhandled
* rejections AFTER boot; (2) config-path resolution could fall back to the cwd.
* This test runs the REAL `lib/bin.js` under `node` (NOT tsx) and asserts the
* banner + echo round-trip, so a regression in the published entry fails here.
*
* It build-gates: if `lib/bin.js` is absent (suite run without `pnpm run build`)
* the test SKIPS with a note. CI runs it after the build step. Setup mirrors a
* real install: a temp dir whose `node_modules/@deepseek-ai/*` (and the vendored
* `cordis`/`@cordisjs/*`) are symlinked to the built packages, a `cordis.yml`
* that loads the app + the example's mock backend, and `node --expose-internals`
* (the cordis Loader resolves bare plugin specifiers via its internal module
* loader, active only under that flag — the same flag `demo:echo` passes).
* Built-ARTIFACT smoke for the published `dsh-stdio-agent` bin.
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js')
// Workspace packages the stdio app's tree needs, by repo-relative path. Each is
// symlinked into the temp consumer's node_modules under its package name, so
// plain `node` resolves the bare `@deepseek-ai/dsh-*` specifiers in cordis.yml
// to the built `lib/` (package.json `main`), exactly as an installed dep would.
// Workspace packages the stdio app's tree needs, by repo-relative path.
const dshPackages = [
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
@@ -153,9 +135,8 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin.
}, 30_000)
it('boots cleanly when the config disables an (otherwise unresolvable) entry', async () => {
// A `disabled: true` entry settles without a fiber by design; the fail-loud
// entry-load guard must NOT mistake it for a failed import. Even though its
// plugin path does not exist, the app boots and the round-trip works.
// A `disabled: true` entry settles without a fiber by design; the fail-loud entry-load
// guard must not mistake it for a failed import.
consumer = await makeConsumer('DISABLED-OK ready.', true)
const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi')
expect(stderr).not.toContain('failed to load')
@@ -165,10 +146,7 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin.
}, 30_000)
it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
// A consumer who typos the config path must get a clear failure, not silent
// success. This dir does not exist, so the include PLUGIN itself fails to
// import; the cordis Loader logs that and leaves the entry with no fiber (no
// rejection), which `boot()`'s entry-load check turns into a thrown error.
// A consumer who typos the config path must get a clear failure, not silent success.
consumer = await makeConsumer('unused')
const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '')
expect(code).not.toBe(0)
@@ -176,9 +154,7 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin.
}, 30_000)
it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
// The config DIRECTORY exists (the include plugin imports), but the file does
// not — the include's init throws "config file not found", which surfaces as
// an unhandled rejection the fail-loud guard turns into a non-zero exit.
// Existing directory plus missing config exercises the include plugin's fail-loud path.
consumer = await makeConsumer('unused')
const { code, stderr } = await runBuiltBin(consumer, './does-not-exist.yml', '')
expect(code).not.toBe(0)

View File

@@ -10,20 +10,9 @@ import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as stdioAgent from '../src/index.ts'
/**
* Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it
* composes the console logger, the agent-core spine (pre-creating the `main`
* agent from the app config), the JSONL backend, and the readline UI in one
* `ctx.plugin`. The forwarded `model` reaches the pre-created agent and
* `persona` the system-prompt plugin; `persistenceRoot`/`welcome`/
* `resumeSessionId` route to their backends.
*
* `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev
* plugin the in-process tier cannot import); the keyless echo smoke in
* `examples/echo-agent` proves the whole subprocess tree (incl. `hmr`) boots
* through the real Loader, while the export SHAPE is pinned by this suite's
* explicit `unwrapExports` assertion (an inject-less app would boot past a
* stray default rather than crash). Here we assert the composition + config
* forwarding the unit tier can reach.
* Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it composes the
* console logger, the agent-core spine (pre-creating the `main` agent from the app config),
* the JSONL backend, and the readline UI in one `ctx.plugin`.
*/
async function mount(config: stdioAgent.Config): Promise<Context> {
const ctx = new Context()
@@ -163,14 +152,7 @@ describe('dsh-stdio-agent app', () => {
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
// Postmortem 0001 guard: a stray `export default apply` makes the Loader's
// `unwrapExports` (`exports.default ?? exports`) collapse the module to the
// bare `apply` function, DROPPING the named `name`/`Config`. This package has
// no `inject` export, so that collapse would NOT crash at load (the keyless
// echo smoke would still boot the tree) — it would silently lose its config
// schema. So guard the shape directly here: assert no `default` export, and
// that the real `unwrapExports` leaves `name`/`Config`/`apply` intact. Adding
// `export default` to src/index.ts fails this test.
// Loader must retain the namespace so name, Config, and apply survive unwrapping.
expect('default' in stdioAgent).toBe(false)
expect(typeof stdioAgent.apply).toBe('function')

View File

@@ -179,11 +179,9 @@ describe('createStdioChat rendering', () => {
})
it('seeds labels for agents already registered before the UI installs', async () => {
// The pre-created `main` agent (and any agent surviving an HMR reload of just
// this fiber) fired its `agent/created` before the UI's listener existed, so
// the live listener alone would miss it. Seeding from `ctx.agents.list()` at
// install time is what keeps its turn header showing `[main turn N]` instead
// of the raw session id.
// The pre-created `main` agent (and any agent surviving an HMR reload of just this fiber)
// fired its `agent/created` before the UI's listener existed, so the live listener alone
// would miss it.
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)

View File

@@ -1,35 +1,9 @@
/**
* 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 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}.
* Scope-filtered dispatch: keyed to `req.agent`.
* @module @deepseek-ai/dsh-user-approval
*/
@@ -52,20 +26,7 @@ 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 the service's shallow-frozen acceptance snapshot: later caller
* mutation cannot redirect the question, while the `agent` and `signal`
* identity capabilities remain exact.
*
* @param req - the accepted decision (agent, tool identity, reason, signal).
* @mode waterfall
*/
@@ -276,18 +237,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.
* 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.
*/
export class ApprovalService extends Service {
static Config: z<Config> = z.object({
@@ -299,12 +251,10 @@ export class ApprovalService extends Service {
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent)
// 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.
// 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.
ctx.inject(['systemPrompt'], (scope: Context) => {
scope.systemPrompt.section({
name: 'approval:policy',
@@ -319,16 +269,10 @@ export class ApprovalService extends Service {
})
})
// Visibility layer 2: the boundary narrator. pre-step runs after prompt
// assembly but before the request history is derived, so the notice is
// seen by THIS step's request: idle-time flip-flops coalesce at the
// turn's first step (net-zero → nothing), and a mid-turn switch is
// narrated no later than the next step. What each session was last told
// is in-memory with a log-derived fallback (the folded header's system
// text), so restarts lose nothing. Attribution is positional: an
// override event after the log's last `request/header*` was a runtime
// switch by the user; otherwise the configured default moved under the
// session (operator/config).
// Visibility layer 2: the boundary narrator. pre-step runs after prompt assembly but before
// the request history is derived, so the notice is seen by this step's request: idle-time
// flip-flops coalesce at the turn's first step (net-zero → nothing), and a mid-turn switch
// is narrated no later than the next step.
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
ctx.on('agent/pre-step', (agent) => {
const session = agent.session
@@ -361,30 +305,13 @@ export class ApprovalService extends Service {
}
/**
* Ask the composed answerers to decide one request. Requires an open turn
* on the requesting agent's session — the audit pair below is turn-enclosed
* by contract (the turn is the log's commit/replay boundary; an idle append
* would be dropped as crash tail on reload) — and throws before appending
* anything when called idle; asking outside a turn is a deferred design.
* Within that precondition it always resolves to an outcome, never rejects:
* an aborted signal yields `'cancelled'`, a missing or throwing answerer
* yields `'unavailable'` (fail closed), and a rogue non-vocabulary return
* value is normalized to `'unavailable'`. The caller-owned request is
* synchronously snapshotted, so later mutation cannot split routing,
* dispatch payload, cancellation, or the audit pair across agents/sessions.
* Appends the
* `approval/asked`/`approval/decided` audit pair (log-only) around the
* decision regardless of outcome. A synchronous session observer failure
* after an audit event entered the append-only log is contained; the event
* is already authoritative, so the pair still completes and the request
* still resolves.
* Ask the composed answerers to decide one request.
*
* @param req - the pending decision (agent, tool identity, reason, signal).
* @returns the closed outcome; `'allowed-once'` is the only grant.
*/
async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
// Accept one immutable request shape before the first async boundary. The
// caller retains its record and may mutate it as soon as this async method
// returns; identity capabilities stay live, but the record is never reread.
// Accept one immutable request shape before the first async boundary.
const agent = req.agent
const toolName = req.toolName
const callId = req.callId
@@ -422,11 +349,9 @@ export class ApprovalService extends Service {
}
/**
* Append one audit event while distinguishing a post-append observer throw
* from a failure that prevented the event entering the log. `Session.append`
* pushes first and then notifies synchronously, so log growth proves the
* event is already authoritative; that observer failure is reported and
* contained so it cannot reject the approval or suppress its matching event.
* Append one audit event while distinguishing a post-append observer throw from a failure
* that prevented the event entering the log.
*
* @param session - the captured session receiving both audit events.
* @param type - the audit event currently being appended.
* @param id - the request id, used to identify the contained failure.
@@ -461,11 +386,7 @@ export class ApprovalService extends Service {
/** Dispatch the waterfall, contained and raced against the accepted signal. */
private async decide(req: Readonly<ApprovalRequest>): Promise<ApprovalOutcome> {
if (req.signal?.aborted) return 'cancelled'
// The 'never' policy is decided HERE, before any dispatch: a listener
// registered with `prepend: true` after this service mounts would sit
// ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
// documented promise that 'never' rejects deterministically regardless
// of registration order — only the service's own request path can.
// Enforce never before dispatch so listener order cannot bypass it.
if (this.effectivePolicy(req.agent) === 'never') return 'rejected'
// Enter the promise chain BEFORE dispatching: a listener that throws
// SYNCHRONOUSLY (before its first await) must land in the same rejection

View File

@@ -460,10 +460,9 @@ describe('approval policy (the approval/policy fold)', () => {
})
it('never is unbypassable even by an answerer PREPENDED after the service mounts', async () => {
// Cordis prepend unshifts ahead of every existing listener, including
// any gate LISTENER the service could register — which is exactly why
// the 'never' decision lives inside request() instead. The eager grant
// below must never be consulted.
// Cordis prepend unshifts ahead of every existing listener, including any gate LISTENER the
// service could register — which is exactly why the 'never' decision lives inside request()
// instead.
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const consulted = vi.fn()