docs: trim generated prose
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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: [] })
|
||||
|
||||
@@ -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) },
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}).
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user