Merge branch 'codex/simp-unify-agent-session-id' into codex/simp-ui-identity-residue
# Conflicts: # docs/config-catalog.md # docs/event-producer-consumer.md # docs/rfc/implemented/feature/2026-07-06-approval-seam.md # packages/ui/acp/README.md # packages/ui/acp/src/index.ts # packages/ui/jsonrpc/README.md # packages/ui/jsonrpc/src/server.ts
This commit is contained in:
@@ -11,12 +11,12 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
|
||||
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
|
||||
| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
|
||||
| `jsonrpc/` | Stdio JSON-RPC SDK server plugin: serves `HarnessSdkServer` to out-of-process SDK clients (the Python SDK) on the process stdio | (drives `ctx.agents`) |
|
||||
| `jsonrpc-agent/` | JSON-RPC SDK server APP: a bin-only boot of an external `cordis.yml` whose `jsonrpc` entry is the serving face; the single-exe runtime entrypoint | (`bin` only) |
|
||||
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
|
||||
| `jsonrpc-agent/` | Bin-only SDK runtime app that boots an external `cordis.yml` | (`bin` only) |
|
||||
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
|
||||
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own.
|
||||
A UI integration is a client-driver plugin, not a loop change or capability seam: it consumes the existing `agent/*` events and `dsh-agent` factory. `jsonrpc` is the SDK-client sibling of the `acp` editor bridge. The readline UI lives inside [`stdio-agent/`](stdio-agent/README.md) because it is scaffolding for that front door, not an independently swappable integration.
|
||||
|
||||
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
|
||||
|
||||
`stdio-agent` and `acp-agent` are the two composing **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. `jsonrpc-agent` is the third app but bin-only — no composition plugin, because the SDK runtime's hard semantic is that the external `cordis.yml` composes everything, the serving `jsonrpc` entry included. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.
|
||||
`stdio-agent` and `acp-agent` compose the [`agent-core`](../core/agent-core/README.md) spine with their front-door plugins and own their boot bins; a leaf `cordis.yml` supplies backends and optional tools. `jsonrpc-agent` is bin-only because its external config also chooses the serving `jsonrpc` plugin. Each lives in `ui/` as a user-facing front door whose artifact owns its stdout policy.
|
||||
|
||||
@@ -1,30 +1,12 @@
|
||||
#!/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 [--config path-to-cordis.yml]` (default
|
||||
* `./cordis.yml`).
|
||||
*
|
||||
* Boot an ACP stdio server from `cordis.yml`; usage is
|
||||
* `dsh-acp-agent [--config path]`, defaulting to `./cordis.yml`. Shared env
|
||||
* loading, Loader guards, snapshot config selection, and settled-tree boot live
|
||||
* in dsh-app-boot. Replay skips `.env` and selects sibling
|
||||
* `cordis.snapshot.yml` so a stray key cannot trigger a model call. EOF disposes
|
||||
* and flushes snapshot runs; editors normally own process lifetime. Stdout is
|
||||
* reserved for JSON-RPC, so diagnostics go only to stderr.
|
||||
* @module @deepseek-ai/dsh-acp-agent/bin
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
/**
|
||||
* 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}),
|
||||
* JSONL session persistence, and the {@link @deepseek-ai/dsh-acp} bridge. It
|
||||
* writes nothing to stdout.
|
||||
* It pre-creates no agents and leaves adapters, executors, and optional tools to
|
||||
* the leaf, which must likewise avoid stdout loggers. Named exports are
|
||||
* required so Loader retains this plugin's `Config` schema (see
|
||||
* docs/postmortem/0001).
|
||||
* @module @deepseek-ai/dsh-acp-agent
|
||||
*/
|
||||
|
||||
|
||||
@@ -139,14 +139,8 @@ 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.
|
||||
// A default export would make `unwrapExports` collapse this inject-less namespace and silently
|
||||
// drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly.
|
||||
expect('default' in acpAgent).toBe(false)
|
||||
expect(typeof acpAgent.apply).toBe('function')
|
||||
|
||||
|
||||
@@ -18,20 +18,10 @@ 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.
|
||||
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
|
||||
* require a valid initialize response. This catches built-only settle races and stdout protocol
|
||||
* leaks that the tsx source-path smoke cannot. It skips before build; initialize is keyless, with a
|
||||
* dummy key used only to boot the adapter. `--expose-internals` enables Cordis bare-plugin loading.
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
@@ -48,12 +38,8 @@ 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.
|
||||
// Resolve ACP's declared third-party dependencies from that package, not this test: pnpm's strict
|
||||
// layout need not hoist them. Symlink those exact paths into the plain-Node consumer.
|
||||
const npmDeps = ['@agentclientprotocol/sdk', 'zod']
|
||||
const acpPkgDir = join(repoRoot, 'packages/ui/acp')
|
||||
|
||||
@@ -161,18 +147,15 @@ 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 nonexistent directory prevents even the include plugin import. Loader logs the failure and
|
||||
// leaves no fiber; boot's settled-entry guard must convert that state into non-zero exit.
|
||||
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,11 @@ 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.
|
||||
* Source-path Loader smoke through the package's own bin, covering initialize, session/new, and
|
||||
* session/load across the `unwrapExports` path implicated by postmortem 0001. Session creation and
|
||||
* unknown-id loading reach factories but not the model, so a dummy key is sufficient. The temp cwd
|
||||
* is also the session workspace, and an explicit root tsconfig keeps unbuilt path aliases resolvable
|
||||
* when the child starts outside the repository.
|
||||
*/
|
||||
|
||||
const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
@@ -131,13 +120,10 @@ 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"). Persistence
|
||||
// and resume run from the JSON-RPC loop outside bridge injection; a healthy tree reaches
|
||||
// not-found, while a collapsed export would fail earlier with missing injection.
|
||||
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') },
|
||||
|
||||
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
|
||||
|
||||
## 6. Session modes / config options / models
|
||||
|
||||
Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): when `ctx.permission` is composed, the bridge advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; `session/set_config_option` switches the preset end to end, with idle switches anchoring at the next turn under the turn-enclosure contract. Session modes stay deliberately unmodeled because config options replace them in ACP v2. Runtime model selection is still not modeled — the harness fixes the model per bridge via `AcpConfig.model` (both reference adapters ship a model selector).
|
||||
Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): when `ctx.permission` is composed, the bridge advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; `session/set_config_option` switches the preset end to end, with idle switches anchoring at the next `agent/prompt-submit` inside its open turn. Session modes stay deliberately unmodeled because config options replace them in ACP v2. Runtime model selection is still not modeled — the harness fixes the model per bridge via `AcpConfig.model` (both reference adapters ship a model selector).
|
||||
|
||||
## 7. Content blocks
|
||||
|
||||
|
||||
@@ -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, total translation between harness vocabulary and ACP wire types.
|
||||
* @module @deepseek-ai/dsh-acp/codec
|
||||
*/
|
||||
|
||||
@@ -16,29 +10,11 @@ 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)
|
||||
* `completed` and the defensive `error` case map to `end_turn`;
|
||||
* `max-tokens` maps to `max_tokens`; `aborted`, `disposed`, and `rejected` map
|
||||
* to `cancelled`. The bridge rejects error turns before this mapping. Unknown
|
||||
* merge-extensible kinds use legal fallback `end_turn` rather than breaking
|
||||
* the prompt RPC.
|
||||
* @param reason - the harness turn-end reason to translate.
|
||||
* @returns the legal ACP wire value per the mapping above.
|
||||
*/
|
||||
@@ -56,23 +32,17 @@ 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'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a harness {@link ContentBlock} from a prompt into ACP content for
|
||||
* replay, or `undefined` for block kinds the bridge does not surface to the
|
||||
* client as message content. Today only `text` maps; `resource_link` is an
|
||||
* ACP prompt-only input rendered into text by {@link acpPromptToText};
|
||||
* `reasoning` is surfaced via `agent_thought_chunk`
|
||||
* streaming rather than as a message block, and `tool-call`/`tool-result`
|
||||
* are handled by the tool-call update path.
|
||||
* Map replayable text to ACP message content. Other block kinds use their
|
||||
* prompt, thought-stream, or tool-update paths.
|
||||
* @param block - the harness content block to translate.
|
||||
* @returns the ACP block, or `undefined` for a kind with no message-content mapping.
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/**
|
||||
* Session config options over the bridge: ONE user-facing `Permissions`
|
||||
* select (`ctx.permission`'s preset table — each choice bundles a sandbox
|
||||
* mode and an approval policy), its current value folded from each session's
|
||||
* own log, switching via `session/set_config_option` (the preset event plus
|
||||
* its knob write-throughs — the log is the store), and a resumed session
|
||||
* reporting its preset back on `session/load` with no catch-up machinery.
|
||||
* Exercises the bridge's per-session Permissions option: validation, idle
|
||||
* turn anchoring, isolation, and persistence through `session/load`.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -20,12 +16,8 @@ import PermissionService from '@deepseek-ai/dsh-permission'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
/**
|
||||
* The REAL local executor reporting a confining default — `sandboxMode` is
|
||||
* the documented capability override point (`dsh-bash-sandbox` overrides it
|
||||
* the same way), so the bridge sees exactly what a sandboxing composition
|
||||
* advertises without this suite dragging in a kernel sandbox stack. It
|
||||
* reports `workspace-write`: the shipped preset's bundle, which
|
||||
* the permission service validates the composition defaults against.
|
||||
* Advertises the real executor through the `sandboxMode` capability without
|
||||
* loading a kernel sandbox, which these bridge tests do not exercise.
|
||||
*/
|
||||
class SandboxedLocalExecutor extends LocalBashExecutor {
|
||||
override get sandboxMode(): SandboxMode {
|
||||
@@ -33,7 +25,6 @@ class SandboxedLocalExecutor extends LocalBashExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/** The exact option payload the bridge advertises (pinned verbatim). */
|
||||
function permissionOption(currentValue: string): object {
|
||||
return {
|
||||
id: 'permission',
|
||||
@@ -43,8 +34,8 @@ function permissionOption(currentValue: string): object {
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace; anything wider asks for your approval.' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access, no approval prompts.' },
|
||||
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -62,11 +53,9 @@ describe('acp bridge — session config options', () => {
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A harness composing the full preset stack (confining executor + approval seam + permission presets). */
|
||||
async function presetStack(options: { script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
|
||||
const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} })
|
||||
// The dev invariants police turn-enclosure: an idle switch that appended
|
||||
// outside a turn would throw right here in the suite, not in production.
|
||||
// Make an out-of-turn switch fail in this suite.
|
||||
await harness.ctx.plugin(Invariants)
|
||||
await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
@@ -90,14 +79,13 @@ describe('acp bridge — session config options', () => {
|
||||
expect(res.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
})
|
||||
|
||||
it('an idle switch is pending (overlaid, not yet logged), then anchors INSIDE the next turn', async () => {
|
||||
it('an idle switch is pending (overlaid, not yet logged), then anchors inside the next prompt\'s turn', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const after = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(after.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
|
||||
// Idle: nothing in the log yet — turn-enclosure forbids a bare append.
|
||||
const session = h.ctx.agents.list()[0]?.session
|
||||
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
|
||||
@@ -112,7 +100,7 @@ describe('acp bridge — session config options', () => {
|
||||
expect(anchored).toBeGreaterThan(turnStart)
|
||||
})
|
||||
|
||||
it('an idle flip-flop anchors as ONE switch (last write wins)', async () => {
|
||||
it('an idle flip-flop anchors as one switch (last write wins)', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
@@ -121,13 +109,12 @@ describe('acp bridge — session config options', () => {
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
|
||||
// Between turns (a closed turn in the log) a switch still pends — the
|
||||
// enclosure fold walks past the turn/end — and anchors with the NEXT turn.
|
||||
// A closed turn does not make a later idle switch appendable.
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(h.ctx.agents.list()[0]?.session.events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a net-zero idle flip-flop anchors NOTHING (switches are recorded, select clicks are not)', async () => {
|
||||
it('a net-zero idle flip-flop anchors nothing (switches are recorded, select clicks are not)', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
@@ -177,7 +164,7 @@ describe('acp bridge — session config options', () => {
|
||||
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'reasoning-effort', value: 'max' }))
|
||||
.rejects.toThrow(/unknown config option/)
|
||||
// `permission` exists as a concept but THIS composition never advertised it.
|
||||
// This composition never advertised `permission`.
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }))
|
||||
.rejects.toThrow(/unknown permission value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', type: 'boolean', value: true }))
|
||||
@@ -196,10 +183,8 @@ describe('acp bridge — session config options', () => {
|
||||
const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
// B sees the composition default, not A's pending switch...
|
||||
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(bAfter.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
// ...and A keeps its own state, untouched by B's.
|
||||
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(aAfter.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
})
|
||||
@@ -207,22 +192,17 @@ describe('acp bridge — session config options', () => {
|
||||
it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => {
|
||||
h = await presetStack()
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
// Drift a knob out from under the table (a plugin writing the knob
|
||||
// directly — the raw setters remain public mechanism), inside its own
|
||||
// turn: the dev invariants enforce turn-enclosure here too.
|
||||
// Simulate a plugin calling the public knob setter inside a valid turn.
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The echo of the derived current is a no-op, not an unknown-value error…
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })
|
||||
const option = echo.configOptions?.[0]
|
||||
expect(option).toMatchObject({ currentValue: 'custom' })
|
||||
if (option === undefined || !('options' in option)) throw new Error('expected a select option')
|
||||
expect(option.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access', 'custom'])
|
||||
// …while custom as a TARGET from a real preset stays rejected: switching
|
||||
// away is ordinary, and the custom entry disappears from the options.
|
||||
const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const afterOption = away.configOptions?.[0]
|
||||
expect(afterOption).toMatchObject({ currentValue: 'danger-full-access' })
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* 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 non-spec fixture that mounts the full in-memory agent/persistence stack and connects the
|
||||
* ACP bridge to a real SDK client over memory streams. Tests exercise the same protocol path as an
|
||||
* editor without a subprocess or stdio.
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
@@ -218,13 +213,10 @@ 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.) Holding the c2a
|
||||
// writer lets tests EOF the agent reader and simulate editor disconnect.
|
||||
const a2c = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const c2a = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const c2aWriter = c2a.writable.getWriter()
|
||||
@@ -253,11 +245,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 +271,19 @@ 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".
|
||||
// Default to `mock` only when the caller omitted the key; explicit `model: undefined` means no
|
||||
// model and must survive the object spread.
|
||||
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)` on the ungated root. Later JSON-RPC callbacks run
|
||||
// outside apply's injection scope, matching production and exposing missing-inject failures.
|
||||
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). The returned fiber permits ACP-only
|
||||
// disposal while root services remain live for HMR assertions.
|
||||
inject: [...AcpPlugin.inject],
|
||||
apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) },
|
||||
})
|
||||
|
||||
@@ -57,12 +57,8 @@ 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").
|
||||
// Persist a real bash call, then replay it through a fresh bridge. A throwaway presenter pairs
|
||||
// call and result in log order so replay uses the shipping tool's same cards as live streaming.
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
@@ -94,10 +90,8 @@ 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 persisted `todo/write` must replay as an ACP plan update so a reopened editor sees the
|
||||
// current plan, not just the tool transcript.
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withTodo: true,
|
||||
@@ -168,11 +162,8 @@ 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.
|
||||
// Stall persistence so transport closes while resume is pending. Whether the SDK rejects first
|
||||
// or the bridge's post-await guard fires, no agent may survive for the dead connection.
|
||||
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: [] })
|
||||
@@ -197,10 +188,9 @@ 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. Resume must retain the header cwd and route bash there rather than reject the
|
||||
// mismatch or substitute the server cwd.
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
const otherCwd = '/some/other/workspace'
|
||||
await loader.ctx.sessionPersistence.create({
|
||||
@@ -236,9 +226,8 @@ 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/external log without `header.cwd` must be rejected; the request cwd does not override
|
||||
// it, and accepting would let bash silently fall back to the server launch directory.
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION, id: SessionId('legacy'), createdAt: 1, // no cwd
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
/**
|
||||
* 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 legal update variants, call-before-result order
|
||||
* per tool id, and deterministic event-to-update translation. Keeping this pure makes live and
|
||||
* replay equivalence deterministic rather than a timing property.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -297,10 +297,9 @@ 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"). The
|
||||
// presenter reports the error and falls back to generic rendering.
|
||||
const boom: ToolDefinition = {
|
||||
name: 'boom',
|
||||
description: 'b',
|
||||
@@ -630,12 +629,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. The real tool is required because its result metadata is the contract.
|
||||
it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => {
|
||||
const ctx = await fsCtx()
|
||||
const presenter = new ToolPresenter(ctx.tools)
|
||||
@@ -672,11 +669,10 @@ 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. Diff and location
|
||||
// paths remain absolute so the editor can open the real file.
|
||||
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' })
|
||||
@@ -698,11 +694,8 @@ 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.
|
||||
// Shipping edit always has a hunk and write falls back to a whole-file diff, so a synthetic
|
||||
// tool is required to cover both absent-title and empty-content result branches.
|
||||
const emptyDiffTool: ToolDefinition = {
|
||||
name: 'writer',
|
||||
description: 'writes a file',
|
||||
@@ -728,11 +721,9 @@ 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 location/diff paths raw. Use real fs tools
|
||||
// and the absolute paths an editor supplies; presentation itself is args-only and lacks cwd.
|
||||
function callUpdate(ctx: Context, sessionCwd: string | undefined, name: string, args: unknown): SessionNotification['update'] {
|
||||
const presenter = new ToolPresenter(ctx.tools)
|
||||
const out: SessionNotification['update'][] = []
|
||||
@@ -775,10 +766,9 @@ 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. Segment-aware guarding must relativize it,
|
||||
// matching targets under `cwd + sep` in the reference adapter.
|
||||
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')
|
||||
|
||||
@@ -124,11 +124,9 @@ 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).
|
||||
// With terminal output advertised, a real bash call emits description then terminal content
|
||||
// plus cwd metadata; its result uses terminal output/exit metadata and omits text that would
|
||||
// clobber the card.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
@@ -164,11 +162,8 @@ 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.
|
||||
// Create the session with terminal support, then disable it connection-wide. The session's
|
||||
// snapshot must keep call and result rendering consistent instead of re-reading changed state.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
@@ -324,11 +319,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.
|
||||
// JSON-RPC timing normally makes this a running mid-step cancellation; pre-step dropping is
|
||||
// covered in agent-loop. Here the prompt must settle cancelled, return idle, and clear queued
|
||||
// work so the scripted second response cannot leak into another turn.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer'), textResponse('leaked')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
@@ -337,18 +330,13 @@ describe('acp bridge — turn outcomes', () => {
|
||||
expect(res.stopReason).toBe('cancelled')
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
await agent.whenIdle()
|
||||
// At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so
|
||||
// no second turn was batched or leaked. (A best-effort abort that left queued
|
||||
// work could have started a second turn.)
|
||||
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start').length
|
||||
expect(turnStarts).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('idle session/cancel then session/prompt runs the prompt (no intervening whenIdle)', async () => {
|
||||
// The ACP bridge settles the cancel RPC synchronously and accepts the next
|
||||
// prompt WITHOUT awaiting quiescence — so this drives cancel→prompt with NO
|
||||
// whenIdle() between, the production race. An idle cancel must be a no-op that
|
||||
// does NOT drop the following prompt.
|
||||
// The bridge settles cancel synchronously, so exercise the production cancel→prompt race with
|
||||
// no `whenIdle()`. An idle cancel must not mark or drop the following prompt.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
// Cancel while idle (no prompt in flight) — a no-op.
|
||||
@@ -364,9 +352,8 @@ describe('acp bridge — turn outcomes', () => {
|
||||
})
|
||||
|
||||
it('mid-stream cancel then an IMMEDIATE next prompt runs (no intervening whenIdle)', async () => {
|
||||
// Cancel a running turn, then send the next prompt WITHOUT awaiting quiescence
|
||||
// (the synchronous-settle path). The new prompt must run — the cancel marker
|
||||
// must not leak onto it.
|
||||
// Cancel a running turn and immediately send another prompt without awaiting quiescence. The
|
||||
// cancellation marker belongs only to the first turn and must not drop the next request.
|
||||
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('next answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const a = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'A' }] })
|
||||
@@ -384,10 +371,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.
|
||||
// Cancellation frees A's slot before its aborted turn/end is appended. Send B in that window;
|
||||
// correlation by turn number must prevent A's late closer from settling B as cancelled.
|
||||
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
@@ -396,8 +381,7 @@ describe('acp bridge — turn outcomes', () => {
|
||||
await harness.client.cancel({ sessionId })
|
||||
expect((await a).stopReason).toBe('cancelled')
|
||||
|
||||
// Immediately send B; its turn (2) is distinct from A's (1). If A's late
|
||||
// turn/end leaked onto B, B would settle 'cancelled' instead of 'end_turn'.
|
||||
// B owns the later turn and must complete on its own turn/end.
|
||||
const b = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'B' }] })
|
||||
expect(b.stopReason).toBe('end_turn')
|
||||
const text = harness.updates
|
||||
|
||||
@@ -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. Replay swaps a `cordis.yml` basename for
|
||||
* `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
|
||||
* @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.
|
||||
*/
|
||||
@@ -52,12 +30,8 @@ export function resolveConfigPath(
|
||||
}
|
||||
|
||||
/**
|
||||
* Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in
|
||||
* `dir` (Node native `process.loadEnvFile`). An absent file is fine — the
|
||||
* environment may already carry the variables; the leaf `cordis.yml` reads
|
||||
* them via the `!!js` tag. A present-but-unreadable `.env` is a real
|
||||
* misconfiguration: surface it via `warn` (one line, default stderr) rather
|
||||
* than silently running with the wrong environment.
|
||||
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
|
||||
* ambient environment; other read failures are reported through `warn`.
|
||||
* @param binName - the diagnostic prefix on the warn line.
|
||||
* @param dir - the directory whose `.env` to load.
|
||||
* @param warn - sink for the one-line misconfiguration diagnostic.
|
||||
@@ -88,15 +62,9 @@ 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).
|
||||
* Install before boot to turn a late unhandled plugin-init rejection into one
|
||||
* labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP;
|
||||
* the returned function removes the handler.
|
||||
* @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 +79,9 @@ 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, reject entries with no fiber, which indicates a
|
||||
* swallowed module-import failure. Disabled entries are the only valid
|
||||
* fiber-less state.
|
||||
* @param ctx - the settled context whose loader entries to audit.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
*/
|
||||
@@ -130,27 +94,11 @@ 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.
|
||||
*
|
||||
* 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.
|
||||
* Boot the Loader against `absoluteConfigPath` and return only after the whole
|
||||
* tree settles. The include uses an absolute file URL while `baseUrl` stays at
|
||||
* the config directory for its relative imports. A missing fiber rejects here;
|
||||
* a later init rejection is handled by {@link installFailLoud}. Built bins need
|
||||
* `--expose-internals` for bare plugin specifiers; relative specifiers do not.
|
||||
* @param binName - the diagnostic prefix for load-failure errors.
|
||||
* @param absoluteConfigPath - the config to include; must already be absolute
|
||||
* (see {@link resolveConfigPath}).
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# @deepseek-ai/dsh-jsonrpc-agent
|
||||
|
||||
The **JSON-RPC SDK server app bin** (`dsh-jsonrpc-agent`): boot a harness from an externally supplied `cordis.yml` and let its [`@deepseek-ai/dsh-jsonrpc`](../jsonrpc/README.md) entry serve SDK clients over newline-delimited JSON-RPC on stdio. Structurally the SDK-runtime sibling of [`acp-agent`](../acp-agent/README.md)'s bin, but bin-only: there is no composition plugin here, because "the plugins that actually start come from the external config" is the SDK runtime's hard semantic — the leaf `cordis.yml` composes the spine, the backends, AND the serving face. This package is the entrypoint of the single-exe distribution (its `lib/bin.js` is what the packaged executable runs) — see [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
||||
Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../jsonrpc/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. `lib/bin.js` is also the [single-executable runtime](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) entry.
|
||||
|
||||
## Config discovery
|
||||
|
||||
Two channels, environment first: `$DSH_CORDIS_CONFIG` (the existing SDK-client convention, wins), then the `argv[2]` positional path (`dsh-jsonrpc-agent <path/to/cordis.yml>`, the human channel). An empty value counts as absent on either channel. Neither given, or the path missing on disk: the bin prints a one-line usage naming both channels to stderr and exits 1 — there is no default `./cordis.yml` and no built-in fallback config. A config that names a plugin which fails to load fails loud through the shared [`dsh-app-boot`](../app-boot/README.md) guards (`assertEntriesLoaded` + the unhandled-rejection handler), never a silent half-boot. There is no `DSH_SNAPSHOT` handling: this protocol is not part of the ACP snapshot tier.
|
||||
The first non-empty channel wins: `$DSH_CORDIS_CONFIG`, then positional `argv[2]`. If neither names an existing file, the bin prints one-line usage to stderr and exits 1; there is no working-directory or built-in fallback. [`dsh-app-boot`](../app-boot/README.md) makes plugin load failures fatal. This protocol does not use `DSH_SNAPSHOT`.
|
||||
|
||||
Note the deliberate flip side of config-decides-everything: a config that loads no `dsh-jsonrpc` entry boots fine and serves nothing — the bin cannot know which plugin is "the server".
|
||||
A config without `dsh-jsonrpc` is valid and serves nothing; the bin does not designate a server plugin.
|
||||
|
||||
## Exit lifecycle
|
||||
|
||||
The bin owns the PROCESS-level exits: stdin EOF (the SDK client is gone — an in-flight turn is deliberately cut off, see the risk note in docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) and `SIGTERM` dispose the root context to quiescence and exit 0; `SIGINT` does the same but exits 130. The PROTOCOL-level exit — a `shutdown` JSON-RPC request answered first, then exit 0 — is owned by the `dsh-jsonrpc` plugin, which holds the server and transport; the two paths are individually idempotent and safe to race.
|
||||
stdin EOF and `SIGTERM` dispose the root to quiescence and exit 0; `SIGINT` exits 130 after the same disposal. EOF may cut off an in-flight turn as documented in the [distribution RFC](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). The `jsonrpc` plugin owns response-before-exit protocol shutdown; both paths are idempotent and safe to race.
|
||||
|
||||
## stdout is the protocol
|
||||
|
||||
stdout carries only JSON-RPC frames; the bin and the app-boot guards write diagnostics to stderr only, and the booted config must load no stdout logger (see the `dsh-jsonrpc` README).
|
||||
stdout carries only JSON-RPC frames. The bin and boot guards diagnose on stderr, and the config must omit stdout loggers.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-jsonrpc-agent",
|
||||
"description": "JSON-RPC SDK server app bin: boots an externally supplied cordis.yml (DSH_CORDIS_CONFIG or argv, no built-in fallback) whose dsh-jsonrpc entry serves SDK clients over stdio; the single-exe runtime entrypoint",
|
||||
"description": "Bin that boots an external Cordis config for the stdio JSON-RPC SDK runtime",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The `dsh-jsonrpc-agent` bin: boot a harness from an externally supplied
|
||||
* `cordis.yml` whose `@deepseek-ai/dsh-jsonrpc` entry serves SDK clients over
|
||||
* newline-delimited JSON-RPC on stdio. The shared 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 stdio/ACP bins; this bin
|
||||
* owns only config discovery and the process-level exit lifecycle:
|
||||
*
|
||||
* - Config discovery is `$DSH_CORDIS_CONFIG` (the existing SDK-client
|
||||
* convention, wins) or the `argv[2]` positional path (the human channel,
|
||||
* for direct launches); an empty value counts as absent. Neither
|
||||
* given, or the path missing on disk, prints the one-line usage to stderr
|
||||
* and exits 1. No built-in fallback — the external config IS the deployment
|
||||
* (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
||||
* No `DSH_SNAPSHOT` handling: this
|
||||
* protocol is not part of the ACP snapshot tier.
|
||||
* - stdin EOF (the SDK client is gone) and SIGTERM dispose the root context
|
||||
* to quiescence and exit 0; SIGINT does the same but exits 130. The
|
||||
* `shutdown` JSON-RPC request's answer-then-exit-0 path is owned by the
|
||||
* `dsh-jsonrpc` plugin, which holds the server (see its README).
|
||||
*
|
||||
* IMPORTANT: stdout is the JSON-RPC channel. Diagnostics go to STDERR only (a
|
||||
* stray stdout write corrupts the protocol frames), which the app-boot guards
|
||||
* already honor.
|
||||
* Boots an external `cordis.yml`; its `@deepseek-ai/dsh-jsonrpc` entry serves
|
||||
* newline-delimited JSON-RPC on stdio. `$DSH_CORDIS_CONFIG` wins over `argv[2]`;
|
||||
* empty or missing paths exit 1, with no default config or `DSH_SNAPSHOT` mode.
|
||||
* App-boot owns env loading, Loader guards, and settled-tree startup.
|
||||
* stdin EOF and SIGTERM dispose the root context and exit 0; SIGINT exits 130.
|
||||
* Protocol `shutdown` belongs to the server plugin. Stdout is reserved for frames.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-jsonrpc-agent/bin
|
||||
*/
|
||||
@@ -32,17 +15,11 @@ import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/
|
||||
|
||||
const NAME = 'dsh-jsonrpc-agent'
|
||||
|
||||
/* v8 ignore start -- thin self-executing composition over the unit-tested
|
||||
dsh-app-boot helpers; the serving lifecycle it boots is unit-tested in
|
||||
@deepseek-ai/dsh-jsonrpc, and the composed artifact is exercised by the
|
||||
single-exe acceptance drive (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) */
|
||||
/* v8 ignore start -- composition over tested app-boot/jsonrpc and executable acceptance paths */
|
||||
installFailLoud(NAME)
|
||||
loadEnv(NAME)
|
||||
|
||||
// Env wins over the positional argument; an empty value on either channel
|
||||
// counts as absent. There is deliberately NO default `./cordis.yml`: "the
|
||||
// plugins that actually start come from an explicit external config" is a
|
||||
// hard semantic of the SDK runtime.
|
||||
// Env wins over argv; empty values are absent. External config defines the deployment.
|
||||
const fromEnv = process.env['DSH_CORDIS_CONFIG']
|
||||
const fromArgv = process.argv[2]
|
||||
const requested = fromEnv !== undefined && fromEnv !== ''
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* The `dsh-jsonrpc-agent` app package IS its bin (see `./bin.ts`): config
|
||||
* discovery plus the process-level exit lifecycle around a booted
|
||||
* `cordis.yml`. This module deliberately exports nothing — unlike the
|
||||
* stdio/ACP app packages there is no composition plugin here, because the
|
||||
* serving face is the {@link @deepseek-ai/dsh-jsonrpc} plugin the external
|
||||
* config loads like any other entry (which plugins actually start is the
|
||||
* config's decision, the hard semantic of the SDK runtime; see
|
||||
* docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
||||
* Bin-only app package: `bin.ts` discovers an external `cordis.yml` and owns
|
||||
* process exit. This module exports no composition plugin; the config chooses
|
||||
* whether to load the {@link @deepseek-ai/dsh-jsonrpc} serving plugin.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-jsonrpc-agent
|
||||
*/
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* jsonrpc-agent ships TWO entries: the doc-only module (`index`) and the CLI
|
||||
* `bin` (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`.
|
||||
* The root tsdown builds only `lib/types/index.js`, so this override adds
|
||||
* `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false),
|
||||
* matching every package.
|
||||
* Build the doc-only module and CLI entry; `tsc -b` supplies declarations.
|
||||
*/
|
||||
export default defineConfig({
|
||||
entry: ['lib/types/index.js', 'lib/types/bin.js'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-jsonrpc",
|
||||
"description": "Stdio JSON-RPC SDK server plugin: serves HarnessSdkServer over newline-delimited JSON-RPC on the process stdio, letting an out-of-process SDK client (e.g. the Python SDK) drive DeepSeek Harness agents",
|
||||
"description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,30 +1,10 @@
|
||||
/**
|
||||
* The SDK-facing stdio JSON-RPC server plugin: mounting it wires a
|
||||
* {@link JsonRpcLineTransport} over the process stdio and serves
|
||||
* {@link HarnessSdkServer} (`initialize` → `session/prompt`* → `shutdown`,
|
||||
* plus the `session.*`/`subagent.*` notifications) to an out-of-process SDK
|
||||
* client (e.g. the Python `deepseek_harness` package). The structured
|
||||
* SDK-client analogue of the `acp` bridge: a client-driver plugin over
|
||||
* `ctx.agents`, not a loop change and not a capability seam. Which process
|
||||
* actually serves this protocol is a `cordis.yml` decision — the tree that
|
||||
* loads this plugin IS the SDK server (the `dsh-jsonrpc-agent` bin boots such
|
||||
* a tree for the single-exe distribution; see
|
||||
* docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
||||
*
|
||||
* stdout is the protocol: this plugin must run in a tree 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.
|
||||
*
|
||||
* Exit-lifecycle split: this plugin owns the PROTOCOL-level exit (the
|
||||
* `shutdown` request answers first, then the plugin disposes its own fiber and
|
||||
* exits 0 — see {@link apply}); process-level exits (stdin EOF, SIGTERM,
|
||||
* SIGINT) belong to the app bin (`dsh-jsonrpc-agent`), which disposes the
|
||||
* whole root context.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`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 silently drop `inject`/`name`/`Config` (see docs/postmortem/0001).
|
||||
* SDK-facing JSON-RPC plugin over stdio. An external `cordis.yml` decides
|
||||
* whether to load it; see the single-executable RFC and package README.
|
||||
* Stdout is reserved for protocol frames, so the tree must not load a stdout logger.
|
||||
* This plugin answers `shutdown`, disposes its own fiber, and exits 0; the app bin
|
||||
* owns EOF and signal exits. Keep named plugin exports with no default export so
|
||||
* Loader `unwrapExports` preserves `name`, `inject`, `Config`, and `apply`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-jsonrpc
|
||||
*/
|
||||
@@ -39,65 +19,29 @@ export * from './server.ts'
|
||||
export * from './transport.ts'
|
||||
|
||||
export const name = 'jsonrpc'
|
||||
// The server programs against the agent factory only: `agents` is read on
|
||||
// every `session/prompt` (get-or-create) and on `subagent/end` demux. The LLM
|
||||
// seam is deliberately NOT injected — `initialize` reads it opportunistically
|
||||
// via `ctx.get('llm')` (the topology-independent lookup for a non-injected
|
||||
// service, per packages/AGENTS.md) to decide whether to lazily mount the
|
||||
// DeepSeek adapter for the requested model.
|
||||
// Only the agent factory is required; initialize reads the optional LLM seam with ctx.get().
|
||||
export const inject = ['agents']
|
||||
|
||||
/**
|
||||
* Plugin config. Every field is a runtime-only test seam — none is part of the
|
||||
* schemastery {@link Config}, so nothing here is settable from a `cordis.yml`
|
||||
* (production always serves the process stdio and exits via `process.exit`).
|
||||
*/
|
||||
/** Runtime-only test seams; no field is configurable from `cordis.yml`. */
|
||||
export interface JsonRpcConfig {
|
||||
/**
|
||||
* Transport input override. Production omits this (the plugin reads
|
||||
* `process.stdin`); tests inject an in-memory `Readable` to drive the server
|
||||
* without a subprocess.
|
||||
*/
|
||||
/** Transport input override; production uses `process.stdin`. */
|
||||
input?: Readable
|
||||
/**
|
||||
* Transport output override. Production omits this (the plugin writes
|
||||
* `process.stdout` — the protocol channel); tests inject an in-memory
|
||||
* `Writable` to capture frames.
|
||||
*/
|
||||
/** Transport output override; production uses `process.stdout`. */
|
||||
output?: Writable
|
||||
/**
|
||||
* Process-exit override for the `shutdown` request path. Production omits
|
||||
* this (`process.exit`); tests inject a recorder so a driven shutdown does
|
||||
* not kill the test process.
|
||||
*/
|
||||
/** Process-exit override; production uses `process.exit`. */
|
||||
exit?: (code: number) => void
|
||||
}
|
||||
|
||||
export const Config: Schema<JsonRpcConfig> = Schema.object({})
|
||||
|
||||
/**
|
||||
* Mount the SDK server on the process stdio: build the line transport and
|
||||
* {@link HarnessSdkServer}, dispatch incoming requests, and start reading
|
||||
* frames. Disposal is an effect: disposing this plugin's fiber runs
|
||||
* `server.shutdown()` (disposes every SDK-created agent to quiescence and
|
||||
* detaches the event subscriptions) and `transport.close()`.
|
||||
*
|
||||
* The `shutdown` request's process-exit semantics live HERE, because the
|
||||
* plugin owns the server and transport: the request is answered first, an
|
||||
* explicit output-write barrier confirms the response frame flushed, then the
|
||||
* plugin disposes its
|
||||
* OWN fiber and calls `exit(0)`. Own-fiber disposal is sufficient — the
|
||||
* request's `server.shutdown()` already brought every SDK-created agent to
|
||||
* quiescence (their session logs are flushed by the awaited agent-handle
|
||||
* disposes), the fiber's effect disposer re-runs the idempotent shutdown and
|
||||
* closes the transport, and the process exit that follows IS the teardown of
|
||||
* the rest of the tree (the bin's EOF/signal handlers own root-context
|
||||
* disposal for the process-level exits).
|
||||
* Serve SDK requests over the configured streams. Effect disposal shuts down
|
||||
* SDK-created agents and closes the transport. A `shutdown` response is flushed
|
||||
* before this plugin's fiber is disposed and the process exits 0; the app bin
|
||||
* owns root-context disposal for EOF and signals.
|
||||
*/
|
||||
export function apply(ctx: Context, config: JsonRpcConfig): void {
|
||||
// Capture the fiber handle NOW, during apply(): the shutdown path runs LATER,
|
||||
// from the transport's read loop, and must dispose exactly this plugin's
|
||||
// fiber (cf. the injection-scope capture note in the acp bridge).
|
||||
// The later transport callback must dispose this plugin's fiber, not its ambient context.
|
||||
const fiber = ctx.fiber
|
||||
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
|
||||
const input = config.input ?? process.stdin
|
||||
@@ -109,10 +53,7 @@ export function apply(ctx: Context, config: JsonRpcConfig): void {
|
||||
const transport = new JsonRpcLineTransport(input, output)
|
||||
const server = new HarnessSdkServer(ctx, transport)
|
||||
|
||||
// The shutdown-request exit path, exactly once (a second `shutdown` frame
|
||||
// racing the dispose shares the same task). Flush and disposal failures are
|
||||
// settled independently: once shutdown was answered, process exit is still
|
||||
// the honest outcome and neither failure may prevent the next teardown step.
|
||||
// Share one exit task and attempt flush and disposal independently before exiting.
|
||||
let exitTask: Promise<void> | undefined
|
||||
const disposeAndExit = (): Promise<void> => {
|
||||
exitTask ??= (async () => {
|
||||
@@ -126,9 +67,7 @@ export function apply(ctx: Context, config: JsonRpcConfig): void {
|
||||
transport.onRequest(async (method, params) => {
|
||||
const result = await server.handleRequest(method, params)
|
||||
if (method === 'shutdown') {
|
||||
// The transport writes the returned result after this handler resolves.
|
||||
// Schedule the explicit flush barrier after that write, then dispose this
|
||||
// plugin's fiber and exit 0 (see apply's doc).
|
||||
// Run after the handler result is written; the task then flushes, disposes, and exits.
|
||||
setImmediate(() => { void disposeAndExit() })
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Newline-delimited JSON-RPC 2.0 transport over a byte stream pair (the SDK
|
||||
* server's stdio channel). One JSON frame per line; a frame with `id`+`method`
|
||||
* is an incoming request, `id` alone matches a pending outgoing request, and
|
||||
* `method` alone is a notification. Malformed lines are ignored (a resilient
|
||||
* wire reader, not a validator); handler failures become JSON-RPC error
|
||||
* responses, never a crashed transport.
|
||||
* Newline-delimited JSON-RPC 2.0 over byte streams. Frames with `id` and
|
||||
* `method` are requests, `id` alone is a response, and `method` alone is a
|
||||
* notification. Malformed lines are ignored; handler failures become error frames.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-jsonrpc/transport
|
||||
*/
|
||||
@@ -18,22 +15,18 @@ type RequestHandler = (method: string, params: Record<string, unknown>) => Promi
|
||||
type NotificationHandler = (method: string, params: Record<string, unknown>) => void
|
||||
|
||||
/**
|
||||
* The outbound half of a JSON-RPC peer — what {@link HarnessSdkServer} needs
|
||||
* to talk BACK to the host: awaited `request`s and fire-and-forget `notify`s.
|
||||
* Narrow on purpose so tests substitute a recording fake without a stream pair.
|
||||
* Outbound request and notification surface used by {@link HarnessSdkServer}.
|
||||
*/
|
||||
export interface JsonRpcTransportPeer {
|
||||
/**
|
||||
* Send a request to the remote peer and await its response.
|
||||
* Send a request and await its response.
|
||||
* @param method - the JSON-RPC method name.
|
||||
* @param params - the request parameters object.
|
||||
* @returns the remote peer's `result`; rejects on a JSON-RPC `error`
|
||||
* response, a write failure, or transport/input closure.
|
||||
* @returns the result; rejects on an error response, write failure, or closure.
|
||||
*/
|
||||
request(method: string, params: Record<string, unknown>): Promise<unknown>
|
||||
/**
|
||||
* Send a notification (no response expected). An omitted `params` sends no
|
||||
* `params` member at all.
|
||||
* Send a notification; omitted params produce no `params` member.
|
||||
* @param method - the JSON-RPC method name.
|
||||
* @param params - the optional notification parameters object.
|
||||
*/
|
||||
@@ -46,14 +39,10 @@ interface PendingRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-delimited JSON-RPC 2.0 endpoint over a `Readable`/`Writable` pair.
|
||||
* Inert until {@link start} attaches the input listeners; {@link close}
|
||||
* detaches them and rejects every pending outgoing request (dispose-safe: the
|
||||
* streams themselves are not destroyed — the caller owns them). Incoming
|
||||
* requests are dispatched to the single {@link onRequest} handler (a missing
|
||||
* handler answers `-32601 method not found`; a throwing handler answers
|
||||
* `-32603` with the message); incoming notifications go to {@link
|
||||
* onNotification} and are dropped without one.
|
||||
* Line-delimited endpoint over caller-owned streams. {@link start} attaches
|
||||
* listeners; {@link close} detaches them and rejects pending requests without
|
||||
* destroying the streams. Missing request handlers return `-32601`; handler
|
||||
* failures return `-32603`. Notifications without a handler are dropped.
|
||||
*/
|
||||
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
private buffer = ''
|
||||
@@ -78,8 +67,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the input listeners and reject every pending outgoing request with
|
||||
* "JSON-RPC transport closed". Safe to call without a prior {@link start}.
|
||||
* Detach listeners and reject pending requests. Safe before {@link start}.
|
||||
*/
|
||||
close(): void {
|
||||
this.input.off('data', this.onData)
|
||||
@@ -89,7 +77,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Install THE handler for incoming requests (a later call replaces it).
|
||||
* Install the request handler, replacing any prior handler.
|
||||
* @param handler - resolves to the response `result`; a rejection becomes a
|
||||
* `-32603` error response carrying the message.
|
||||
*/
|
||||
@@ -98,7 +86,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Install THE handler for incoming notifications (a later call replaces it).
|
||||
* Install the notification handler, replacing any prior handler.
|
||||
* @param handler - invoked per notification with the method and normalized
|
||||
* params object.
|
||||
*/
|
||||
@@ -125,9 +113,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until every frame written before this call has reached the output's
|
||||
* write callback. The empty queued write is a barrier and emits no protocol
|
||||
* bytes.
|
||||
* Wait for prior frame write callbacks. The empty barrier emits no bytes.
|
||||
* @returns a promise that settles with the output write callback.
|
||||
*/
|
||||
flush(): Promise<void> {
|
||||
@@ -170,8 +156,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
try {
|
||||
message = JSON.parse(line)
|
||||
} catch {
|
||||
// Swallows ONLY JSON.parse syntax errors: a malformed wire line is a
|
||||
// peer bug this resilient reader skips; nothing else runs in the try.
|
||||
// Only JSON syntax errors reach this catch; malformed peer lines are ignored.
|
||||
return
|
||||
}
|
||||
if (!message || typeof message !== 'object') return
|
||||
|
||||
@@ -11,21 +11,12 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as jsonrpc from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* apply()-level lifecycle coverage for the @deepseek-ai/dsh-jsonrpc plugin:
|
||||
* the plugin is mounted through the REAL namespace mount path —
|
||||
* `ctx.plugin(jsonrpc, config)` over the module namespace object, exactly what
|
||||
* the Loader hands cordis after `unwrapExports` (plugin-shape.spec pins that
|
||||
* identity) — with the runtime-only `input`/`output`/`exit` seams from
|
||||
* {@link jsonrpc.JsonRpcConfig} replacing the process stdio, so the whole
|
||||
* pipeline (line transport → HarnessSdkServer → notifications back onto the
|
||||
* wire) runs in-process. The scenarios pin the plugin's exit-lifecycle split:
|
||||
* a `shutdown` REQUEST answers first, then disposes the plugin's own fiber and
|
||||
* calls `exit(0)` exactly once (a racing second `shutdown` must not re-exit);
|
||||
* a bare fiber dispose (HMR-style unload, no request) only stops serving and
|
||||
* never touches `exit`.
|
||||
* Mount the real namespace plugin with in-memory stdio and exit seams. Covers
|
||||
* the full transport/server path, response-before-exit shutdown exactly once,
|
||||
* and bare-fiber disposal without process exit.
|
||||
*/
|
||||
|
||||
/** One ordered observation on the plugin's outward-facing seams: a JSON-RPC frame written to `output`, or an `exit(code)` call. */
|
||||
/** One ordered frame, write completion, or exit observation. */
|
||||
type WireEvent =
|
||||
| { kind: 'frame'; frame: Record<string, unknown> }
|
||||
| { kind: 'write-complete'; ids: (string | number)[] }
|
||||
@@ -33,9 +24,9 @@ type WireEvent =
|
||||
|
||||
interface ApplyHarness {
|
||||
ctx: Context
|
||||
/** The jsonrpc plugin's own fiber (NOT the root), for the HMR-style dispose scenario. */
|
||||
/** The plugin fiber used by the bare-dispose case. */
|
||||
fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
/** Every output frame and exit call, in observation order — ordering assertions read this. */
|
||||
/** Frames, write completions, and exits in observation order. */
|
||||
events: WireEvent[]
|
||||
outputErrors: Error[]
|
||||
send(frame: Record<string, unknown>): void
|
||||
@@ -46,7 +37,7 @@ interface ApplyHarness {
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/** Poll `get` until it yields a value (5s cap) — the output side is fed asynchronously from the transport's read loop. */
|
||||
/** Poll asynchronous output for up to five seconds. */
|
||||
async function waitFor<T>(get: () => T | undefined, description: string): Promise<T> {
|
||||
const deadline = Date.now() + 5000
|
||||
for (;;) {
|
||||
@@ -57,16 +48,12 @@ async function waitFor<T>(get: () => T | undefined, description: string): Promis
|
||||
}
|
||||
}
|
||||
|
||||
/** Let pending microtasks, setImmediate callbacks, and stream events drain — for asserting that something did NOT happen. */
|
||||
/** Drain asynchronous work before a negative assertion. */
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a minimal harness context (agent-core bundle + JSONL persistence, the
|
||||
* server.spec recipe) and mount the jsonrpc plugin on it through the real
|
||||
* namespace mount path, with in-memory seams standing in for stdio/exit.
|
||||
*/
|
||||
/** Mount the real plugin on a minimal harness with in-memory stdio and exit. */
|
||||
async function mountPlugin(
|
||||
storageDir: string,
|
||||
options: { writeDelayMs?: number; failFlush?: boolean } = {},
|
||||
@@ -80,9 +67,8 @@ async function mountPlugin(
|
||||
const events: WireEvent[] = []
|
||||
const outputErrors: Error[] = []
|
||||
let pendingOutput = ''
|
||||
// A hand-rolled Writable (not a PassThrough): _write records frames on
|
||||
// admission and write-complete only when its callback fires, so a delayed
|
||||
// output proves exit waits for the transport's flush barrier.
|
||||
// Record frame admission separately from write completion so delayed output
|
||||
// tests the flush barrier.
|
||||
const output = new Writable({
|
||||
write(chunk: Buffer, _encoding, callback) {
|
||||
const ids: (string | number)[] = []
|
||||
@@ -138,7 +124,7 @@ afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
/** The server.spec mock OpenAI-compatible SSE endpoint, so a prompt turn completes without a real key. */
|
||||
/** Keyless SSE endpoint for completing a prompt turn. */
|
||||
async function mockCompletionServer(): Promise<{ url: string; requests: unknown[] }> {
|
||||
const requests: unknown[] = []
|
||||
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||
@@ -206,8 +192,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
expect(body.model).toBe('dsagent-model')
|
||||
expect(body.messages.at(-1)?.role).toBe('user')
|
||||
|
||||
// The server's notify() path rides the SAME transport apply() built:
|
||||
// session.event / session.finished arrive as id-less frames on output.
|
||||
// Notifications use the same transport and arrive as id-less frames.
|
||||
const notifications = harness.frames().filter(frame => frame.id === undefined)
|
||||
expect(notifications.some(frame => frame.method === 'session.event')).toBe(true)
|
||||
expect(notifications.find(frame => frame.method === 'session.finished')).toMatchObject({
|
||||
@@ -224,9 +209,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-'))
|
||||
const harness = await mountPlugin(storageDir, { writeDelayMs: 10 })
|
||||
try {
|
||||
// Two shutdown frames in ONE chunk: both are dispatched from the same
|
||||
// read-loop pass, so both setImmediate exit callbacks get scheduled and
|
||||
// the second must hit the `exiting` guard instead of re-entering.
|
||||
// One chunk makes the two deferred exit callbacks race.
|
||||
const first = { jsonrpc: '2.0', id: 'sd-1', method: 'shutdown' }
|
||||
const second = { jsonrpc: '2.0', id: 'sd-2', method: 'shutdown' }
|
||||
harness.sendRaw(`${JSON.stringify(first)}\n${JSON.stringify(second)}\n`)
|
||||
@@ -234,8 +217,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit recorder call')
|
||||
expect(harness.exits()).toEqual([0])
|
||||
|
||||
// Response-then-exit ordering: both response write callbacks and the
|
||||
// empty flush barrier complete before exit(0), even on delayed output.
|
||||
// Both response writes and the flush barrier complete before exit.
|
||||
const exitIndex = harness.events.findIndex(event => event.kind === 'exit')
|
||||
const firstResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-1')
|
||||
const secondResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-2')
|
||||
@@ -250,11 +232,9 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
expect(flushComplete).toBeGreaterThan(secondComplete)
|
||||
expect(exitIndex).toBeGreaterThan(flushComplete)
|
||||
|
||||
// Idempotent: the racing second shutdown never produces a second exit.
|
||||
await settle()
|
||||
expect(harness.exits()).toEqual([0])
|
||||
|
||||
// The plugin fiber is disposed: the transport reads no further frames.
|
||||
const before = harness.frames().length
|
||||
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, model: 'x' } })
|
||||
await settle()
|
||||
@@ -290,8 +270,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-dispose-'))
|
||||
const harness = await mountPlugin(storageDir)
|
||||
try {
|
||||
// Prove the pipeline is live first (an unknown method still answers, as
|
||||
// a JSON-RPC error frame — the transport's handler-rejection path).
|
||||
// Prove the handler-rejection path is live before disposal.
|
||||
harness.send({ jsonrpc: '2.0', id: 'probe-1', method: 'nope/unknown' })
|
||||
const error = await harness.waitForFrame(frame => frame.id === 'probe-1', 'error response for unknown method')
|
||||
expect(error.error).toMatchObject({
|
||||
@@ -301,8 +280,6 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
|
||||
await harness.fiber.dispose()
|
||||
|
||||
// The effect disposer shut the server and closed the transport — later
|
||||
// frames are never read — and the exit seam was never touched.
|
||||
const before = harness.frames().length
|
||||
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, model: 'x' } })
|
||||
await settle()
|
||||
|
||||
@@ -3,21 +3,11 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import * as jsonrpc from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* REAL-export-path guard for the @deepseek-ai/dsh-jsonrpc namespace plugin
|
||||
* (the packages/AGENTS.md red line: a plugin shipped via `cordis.yml` needs a
|
||||
* test through the real Loader/export path). A hand-built `ctx.plugin({...})`
|
||||
* mount bypasses `unwrapExports` — the exact path that once collapsed a
|
||||
* namespace plugin with a stray `export default` and silently dropped its
|
||||
* `inject` (docs/postmortem/0001) — so this spec drives the REAL
|
||||
* `Loader.unwrapExports` over the module namespace and asserts the
|
||||
* `name`/`inject`/`Config`/`apply` shape survives it intact.
|
||||
* Run the real namespace export through `Loader.unwrapExports`; a stray
|
||||
* default would discard `name`, `inject`, `Config`, and `apply`.
|
||||
*/
|
||||
describe('dsh-jsonrpc plugin export shape', () => {
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
|
||||
// A stray `export default` would make `unwrapExports` (`exports.default ??
|
||||
// exports`) collapse the module to the bare default, dropping `inject` —
|
||||
// the plugin would then throw "cannot get property … without inject" at
|
||||
// its first `ctx.agents` read. Adding `export default` fails this test.
|
||||
expect('default' in jsonrpc).toBe(false)
|
||||
expect(typeof jsonrpc.apply).toBe('function')
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# @deepseek-ai/dsh-permission
|
||||
|
||||
User-facing permission presets. Owns the `ctx.permission` service ([`PermissionService`](src/index.ts)): a config-defined preset table — by default `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`) — where each name bundles the two mechanism knobs, `bash/sandbox-mode` and `approval/policy`. The product surface (the ACP bridge's single `Permissions` select) advertises `names` and calls `set()`; the mechanism tiers stay orthogonal capabilities that never learn the product vocabulary.
|
||||
User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `bash/sandbox-mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). The ACP bridge exposes them as one `Permissions` select, while sandbox execution and approval continue to consume their own knobs.
|
||||
|
||||
A switch WRITES THROUGH: `set(session, name)` appends one log-only `permission/preset` event when the name differs from the session's current preset (the audit fact reverse-mapping cannot recover — two presets may share knob values and differ only in composed policy, the planned `agent` preset being the standing example), then each knob event through its own THE-write-path setter, skipping values the session already effectively has — a net-zero switch appends nothing. The current preset DERIVES from the effective knob values (fold ?? composition default per knob): the last-chosen preset when its bundle still matches (presets may share bundles — the fold breaks the tie), else the first matching table entry, else the reserved `custom` — the honest not-a-preset state, shown as the current value only while it holds, switchable FROM and never a target. Every existing knob consumer (executor stamping, the approval gate, narrators, resume) keeps reading its own fold, untouched.
|
||||
`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
|
||||
|
||||
Composing it requires a confining `ctx.bash` executor and the `ctx.approval` seam; a table entry named `custom` throws at load (the name is reserved), while composition defaults outside the table are not an error — a zero-event session simply derives `custom`. See [the acp-agent example's default tree](../../../examples/acp-agent/) for the composed leaf and [the sandbox RFC § Per-session modes](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) for the switching design this layers over.
|
||||
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [acp-agent composition](../../../examples/acp-agent/) and [sandbox switching design](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
/**
|
||||
* User-facing PERMISSION PRESETS: one product-level knob over the two
|
||||
* mechanism knobs. A preset names a bundle — its sandbox mode
|
||||
* (`bash/sandbox-mode`) and its approval policy (`approval/policy`) — so a
|
||||
* user picks `workspace-write` or `danger-full-access` while the mechanism
|
||||
* tiers stay orthogonal capabilities. Switching a preset WRITES THROUGH: one `permission/preset` event
|
||||
* records the chosen bundle (the audit fact reverse-mapping cannot recover —
|
||||
* two presets may share knob values and differ only in composed policy, the
|
||||
* planned `agent` preset being the standing example), then each knob event
|
||||
* follows through its own THE-write-path setter, skipping values the session
|
||||
* already effectively has. Every existing consumer (executor stamping, the
|
||||
* approval gate, narrators, resume) keeps reading its own knob fold,
|
||||
* untouched.
|
||||
* User-facing permission presets over the independent sandbox-mode and
|
||||
* approval-policy knobs. A switch records the selected preset, then writes
|
||||
* changed knobs through their canonical setters. Execution, prompt narration,
|
||||
* and replay keep reading their knob folds. The preset event preserves user
|
||||
* intent when two presets share a bundle.
|
||||
*
|
||||
* @module dsh-permission
|
||||
*/
|
||||
@@ -32,21 +25,16 @@ declare module 'cordis' {
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* The session's permission preset was switched — log-only (the
|
||||
* `bash/sandbox-mode` precedent): durable and replayable, never in the
|
||||
* model transcript. The LAST such event is the session's preset
|
||||
* ({@link effectivePermissionPreset}); the knob events the switch wrote
|
||||
* through follow it in the same turn, and they — not this record of the
|
||||
* user's choice — are what execution reads.
|
||||
* Records the selected preset as durable, log-only user intent. The knob
|
||||
* events follow in the same turn and control execution; this event stays
|
||||
* out of the model transcript and lets {@link effectivePermissionPreset}
|
||||
* preserve a selection when bundles match.
|
||||
*/
|
||||
'permission/preset': { preset: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One preset's knob bundle — the sandbox mode and approval policy a session
|
||||
* runs under while the preset is active — plus its presentation.
|
||||
*/
|
||||
/** One preset's sandbox/approval bundle and optional client presentation. */
|
||||
export interface PresetSpec {
|
||||
/** The `bash/sandbox-mode` value the preset writes through. */
|
||||
sandbox: SandboxMode
|
||||
@@ -69,21 +57,16 @@ export interface PresetOption {
|
||||
}
|
||||
|
||||
/**
|
||||
* The derived not-a-preset state: the session's effective knob values match
|
||||
* no table entry (composition defaults outside the table, or a knob moved
|
||||
* out from under the last-chosen preset). Never a switch target and never
|
||||
* an event payload — {@link PermissionService.current} derives it, and the
|
||||
* presentation layer shows it as a selectable-FROM-only current value.
|
||||
* Returned when effective knob values match no table entry. Clients may show
|
||||
* it as the current value, but it is never a switch target or event payload.
|
||||
*/
|
||||
export const CUSTOM_PRESET = 'custom'
|
||||
|
||||
/**
|
||||
* The session's permission-preset override: the last `permission/preset` event in the
|
||||
* log, or undefined when the session never switched (callers apply the
|
||||
* plugin's configured default). The pure fold — resume needs no catch-up
|
||||
* machinery because replaying the log IS the state.
|
||||
* @param events - session events in log order (other event types are skipped).
|
||||
* @returns the preset of the last switch event, or undefined without one.
|
||||
* Fold the last selected preset from the durable log; replay needs no catch-up
|
||||
* state.
|
||||
* @param events - session events in log order; other event types are ignored.
|
||||
* @returns the last selected preset, or undefined when none was recorded.
|
||||
*/
|
||||
export function effectivePermissionPreset(events: readonly SessionEvent[]): string | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
@@ -104,13 +87,9 @@ export interface Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* The permission service (`ctx.permission`). Owns the deployment's preset
|
||||
* table and THE write path for preset switches; presentation layers (the ACP
|
||||
* bridge's single `Permissions` select) advertise {@link names} and call
|
||||
* {@link set}. Composing it REQUIRES both mechanism knobs — a confining
|
||||
* `ctx.bash` executor and the `ctx.approval` seam. A knob state matching no
|
||||
* table entry is not an error but the derived {@link CUSTOM_PRESET} state:
|
||||
* shown as the current value, never a switch target.
|
||||
* Owns the deployment's permission presets and their write path. Requires a
|
||||
* confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are
|
||||
* reported as {@link CUSTOM_PRESET}, not an error.
|
||||
*/
|
||||
export class PermissionService extends Service {
|
||||
// Inline schema call: the config catalog walks `static Config` statically.
|
||||
@@ -121,14 +100,13 @@ export class PermissionService extends Service {
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
})).default({
|
||||
// Keep the user-facing preset names explicit about filesystem reach.
|
||||
'workspace-write': {
|
||||
sandbox: 'workspace-write', approval: 'ask',
|
||||
name: 'workspace-write', description: 'Write inside the workspace; anything wider asks for your approval.',
|
||||
name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.',
|
||||
},
|
||||
'danger-full-access': {
|
||||
sandbox: 'danger-full-access', approval: 'never',
|
||||
name: 'danger-full-access', description: 'Full file access, no approval prompts.',
|
||||
name: 'danger-full-access', description: 'Full file access without approval prompts.',
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -158,11 +136,9 @@ export class PermissionService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* The preset a session is on right now, derived from the EFFECTIVE knob
|
||||
* values (fold ?? composition default per knob): the last-chosen preset
|
||||
* when its bundle still matches (presets may share bundles — the fold
|
||||
* breaks the tie), else the first table entry that matches, else
|
||||
* {@link CUSTOM_PRESET} — a mismatch is a state, not an error.
|
||||
* Resolve the preset matching the effective knob values. A still-matching
|
||||
* last selection wins shared-bundle ties; otherwise the first table match
|
||||
* wins, or {@link CUSTOM_PRESET} when no entry matches.
|
||||
* @param events - the session's events in log order.
|
||||
* @returns the effective preset name, or `custom` when nothing matches.
|
||||
*/
|
||||
@@ -182,10 +158,10 @@ export class PermissionService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* A preset's knob bundle, for consumers presenting or validating one.
|
||||
* Resolve a preset's knob bundle.
|
||||
* @param name - the preset name to resolve.
|
||||
* @returns the bundle; throws on a name outside the table (fails loud —
|
||||
* an unvalidated caller handed the service an unknown preset).
|
||||
* @returns the configured bundle.
|
||||
* @throws when `name` is not in the table.
|
||||
*/
|
||||
resolve(name: string): PresetSpec {
|
||||
const spec = this.presets[name]
|
||||
@@ -196,28 +172,25 @@ export class PermissionService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* The select-option presentation of one advertisable value: a table entry
|
||||
* (label/description from its spec, the raw key standing in for a missing
|
||||
* label) or the derived {@link CUSTOM_PRESET} with its fixed presentation.
|
||||
* Build the client option for a table entry or {@link CUSTOM_PRESET}. A
|
||||
* missing label falls back to the table key.
|
||||
* @param name - a table key, or `custom`.
|
||||
* @returns the option a client renders; throws on any other name.
|
||||
* @returns the option a client renders.
|
||||
* @throws when `name` is neither a table key nor `custom`.
|
||||
*/
|
||||
optionOf(name: string): PresetOption {
|
||||
if (name === CUSTOM_PRESET) {
|
||||
return { value: CUSTOM_PRESET, name: 'Custom', description: 'A hand-set knob combination outside the preset table.' }
|
||||
return { value: CUSTOM_PRESET, name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }
|
||||
}
|
||||
const spec = this.resolve(name)
|
||||
return { value: name, name: spec.name ?? name, ...spec.description !== undefined ? { description: spec.description } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* THE write path for a preset switch: appends one `permission/preset` event when
|
||||
* `name` differs from the session's current preset, then writes each knob
|
||||
* through its own setter, skipping values the session already effectively
|
||||
* has — a net-zero switch appends nothing (the log records switches, not
|
||||
* select clicks).
|
||||
* Record a changed preset, then update each changed knob through its own
|
||||
* setter. Selecting the effective preset again appends nothing.
|
||||
* @param session - the session the switch belongs to.
|
||||
* @param name - the preset to switch to (validated via {@link resolve}).
|
||||
* @param name - the preset to switch to; unknown names throw.
|
||||
*/
|
||||
set(session: Session, name: string): void {
|
||||
const spec = this.resolve(name)
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission'
|
||||
import type { Config } from '@deepseek-ai/dsh-permission'
|
||||
|
||||
/** Mount the service over stand-in bash/approval capabilities (the two facts it validates against). */
|
||||
async function mounted(options: {
|
||||
config?: Config
|
||||
bashDefault?: SandboxMode | undefined
|
||||
@@ -19,7 +18,6 @@ async function mounted(options: {
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** A real Session seeded with one opened turn (events append without ceremony in unit scope). */
|
||||
function freshSession(id: string): Session {
|
||||
return new Session(SessionId(id))
|
||||
}
|
||||
@@ -55,8 +53,6 @@ describe('PermissionService', () => {
|
||||
const session = freshSession('sess-custom')
|
||||
session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
|
||||
// Switching FROM custom is an ordinary write-through; custom itself is
|
||||
// never a target.
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
|
||||
expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
|
||||
@@ -75,10 +71,8 @@ describe('PermissionService', () => {
|
||||
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' },
|
||||
} } })
|
||||
const session = freshSession('sess-tie')
|
||||
// Same bundle as workspace-write, chosen explicitly: the fold names it.
|
||||
ctx.permission.set(session, 'agentish')
|
||||
expect(ctx.permission.current(session.events)).toBe('agentish')
|
||||
// A knob drifts: the fold's bundle no longer matches → reverse map wins.
|
||||
session.append('approval/policy', { policy: 'never' })
|
||||
session.append('bash/sandbox-mode', { mode: 'danger-full-access' })
|
||||
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
|
||||
@@ -106,9 +100,8 @@ describe('PermissionService', () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-drift')
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
// A knob drifts out from under the preset (a direct setter call, a test
|
||||
// scenario): the session derives custom, and re-asserting the preset is
|
||||
// a real switch again — choice re-recorded, only the drifted knob moves.
|
||||
// Re-selecting from a drifted state records the choice and repairs only
|
||||
// the changed knob.
|
||||
session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
const tail = session.events.slice(4)
|
||||
@@ -125,8 +118,8 @@ describe('PermissionService', () => {
|
||||
|
||||
it('optionOf() presents shipped labels/descriptions, falls back to the raw key, and fixes custom', async () => {
|
||||
const ctx = await mounted()
|
||||
expect(ctx.permission.optionOf('danger-full-access')).toEqual({ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access, no approval prompts.' })
|
||||
expect(ctx.permission.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'A hand-set knob combination outside the preset table.' })
|
||||
expect(ctx.permission.optionOf('danger-full-access')).toEqual({ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' })
|
||||
expect(ctx.permission.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' })
|
||||
const bare = await mounted({ config: { presets: { plain: { sandbox: 'workspace-write', approval: 'ask' } } } })
|
||||
expect(bare.permission.optionOf('plain')).toEqual({ value: 'plain', name: 'plain' })
|
||||
expect(() => ctx.permission.optionOf('plan')).toThrow(/unknown preset/)
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
#!/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.
|
||||
*
|
||||
* Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-agent [config]`, defaulting to the
|
||||
* cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in
|
||||
* dsh-app-boot. The echo and REPL demos invoke this bin with their own leaf configs.
|
||||
* @module @deepseek-ai/dsh-stdio-agent/bin
|
||||
*/
|
||||
|
||||
|
||||
@@ -7,31 +7,17 @@ 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).
|
||||
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
|
||||
* require the banner plus echo round-trip. This catches built-only early-exit and config-resolution
|
||||
* failures masked by tsx source smokes. It skips before build; `--expose-internals` enables Cordis
|
||||
* bare-plugin loading, matching the demo command.
|
||||
*/
|
||||
|
||||
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.
|
||||
// Symlink each required workspace package by package name so plain Node resolves its built `main`,
|
||||
// matching an installed dependency rather than tsconfig paths.
|
||||
const dshPackages = [
|
||||
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
|
||||
@@ -50,14 +36,9 @@ async function pkgName(absDir: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a temp consumer dir: `node_modules` with the workspace + vendor packages
|
||||
* symlinked in, a `src/` carrying the example mock backend, and a `cordis.yml`
|
||||
* that wires them onto the stdio app. Returns the dir (caller removes it).
|
||||
*
|
||||
* `disabledBrokenEntry` appends an entry that points at a non-existent plugin but
|
||||
* is marked `disabled: true`. The Loader leaves a disabled entry fiber-less by
|
||||
* design, so it exercises that the fail-loud entry-load guard does NOT mistake a
|
||||
* valid disabled entry for a failed import.
|
||||
* Build a temporary external consumer with built workspace/vendor links and a mock-backed config.
|
||||
* The optional missing-but-disabled plugin verifies load guards accept intentionally fiber-less
|
||||
* entries rather than treating them as import failures.
|
||||
*/
|
||||
async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-'))
|
||||
@@ -153,9 +134,9 @@ 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. The nonexistent path makes that distinction
|
||||
// observable while the successful round-trip proves boot continued.
|
||||
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,8 @@ 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 nonexistent directory prevents even the include plugin import. Loader leaves no fiber, and
|
||||
// boot's settled-entry guard must turn that state into a clear non-zero failure.
|
||||
consumer = await makeConsumer('unused')
|
||||
const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '')
|
||||
expect(code).not.toBe(0)
|
||||
@@ -176,9 +155,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)
|
||||
|
||||
@@ -11,20 +11,10 @@ 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 app composition and config forwarding: console logger, pre-created main agent,
|
||||
* agent-core spine, JSONL backend, and readline UI. HMR is a Loader-only leaf concern covered by the
|
||||
* keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise
|
||||
* survive namespace collapse while silently losing its schema.
|
||||
*/
|
||||
async function mount(config: stdioAgent.Config): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
@@ -181,14 +171,8 @@ 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.
|
||||
// A default export would make `unwrapExports` collapse this inject-less namespace and silently
|
||||
// drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly.
|
||||
expect('default' in stdioAgent).toBe(false)
|
||||
expect(typeof stdioAgent.apply).toBe('function')
|
||||
|
||||
|
||||
@@ -1,35 +1,6 @@
|
||||
/**
|
||||
* Approval seam: `ctx.approval` answers exactly one question — "may this
|
||||
* specific action proceed?" — by dispatching the `approval/request` waterfall
|
||||
* to whatever answerers the deployment composed (an ACP editor prompt, an
|
||||
* auto-decide policy, a scripted test listener) and returning a closed
|
||||
* {@link ApprovalOutcome}. With no answerer the waterfall falls through to the
|
||||
* built-in default `'unavailable'`: absence of a UI can never grant anything.
|
||||
*
|
||||
* The service is the MECHANISM (dispatch, cancellation, audit); answerers are
|
||||
* the POLICY. It serves both ask paths the sandbox RFC names — the
|
||||
* `tools/pre-execute` `ask` decision and the sandbox post-denial escalation —
|
||||
* so every asker shares one outcome
|
||||
* vocabulary and one audit trail. Grants are one-shot by design: an
|
||||
* `'allowed-once'` outcome authorizes the single action it was asked about,
|
||||
* never a class of future actions.
|
||||
*
|
||||
* Every request lands two log-only session events on the requesting agent's
|
||||
* log (`approval/asked` / `approval/decided`, paired by
|
||||
* {@link ApprovalRequestId}) — an audit trail, deliberately NOT part of the
|
||||
* model-visible transcript: the model only ever sees the tool result the
|
||||
* caller derives from the outcome.
|
||||
*
|
||||
* The seam also owns the per-session POLICY tier (the sandbox RFC § Per-session mode switching):
|
||||
* `effective = fold(the session's 'approval/policy' events, last one wins)
|
||||
* ?? config.policy` — the session log is the store, so an override survives
|
||||
* restart by replay. The service resolves `'never'` sessions to
|
||||
* `'rejected'` inside `request()` before dispatching any answerer (no
|
||||
* registration order, including a later `prepend`, can precede it); a prompt section states `'never'`
|
||||
* (and only `'never'` — an availability promise is unknowable without
|
||||
* asking); an `agent/pre-step` narrator explains a switch to the model in at
|
||||
* most one coalesced notice per step.
|
||||
*
|
||||
* Approval request, cancellation, audit, and per-session policy seam. Missing
|
||||
* answerers fail closed; grants apply only to the requested action.
|
||||
* @module @deepseek-ai/dsh-user-approval
|
||||
*/
|
||||
|
||||
@@ -51,19 +22,9 @@ declare module 'cordis' {
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Waterfall asking the composed answerers to decide one approval request.
|
||||
* Dispatched only from {@link ApprovalService.request} — callers go through
|
||||
* the service (which owns cancellation and the audit events), never through
|
||||
* `ctx.waterfall` directly. A listener that can answer for this request's
|
||||
* agent returns an outcome WITHOUT calling `next()` (the decision slot is
|
||||
* single-occupancy, first listener to answer wins); a listener that does
|
||||
* not recognize the agent MUST call `next()` so another answerer — or the
|
||||
* fail-closed default `'unavailable'` — gets the question. Throwing is
|
||||
* contained by the service and yields `'unavailable'`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a
|
||||
* listener registered through `agent.ctx` receives only that agent's
|
||||
* questions, while a plain-context listener receives every agent's.
|
||||
* `req` is a readonly same-process value borrowed from the caller.
|
||||
* Ask composed answerers for one decision. Return an outcome to claim the
|
||||
* request or call `next()`; failure yields the fail-closed default.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @param req - the pending decision (agent, tool identity, reason, signal).
|
||||
* @mode waterfall
|
||||
*/
|
||||
@@ -124,16 +85,8 @@ export function ApprovalRequestId(id: string): ApprovalRequestId {
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed outcome vocabulary of one approval request.
|
||||
*
|
||||
* - `'allowed-once'` — a one-shot grant for exactly the asked-about action;
|
||||
* consumed by proceeding, never a durable authorization.
|
||||
* - `'rejected'` — an answerer (human or policy) said no.
|
||||
* - `'cancelled'` — the question was withdrawn: the prompt was dismissed, or
|
||||
* the requesting execution aborted while the question was pending.
|
||||
* - `'unavailable'` — nobody composed could answer (no listener, none that
|
||||
* recognizes the agent, or an answerer failed). Callers MUST fail closed on
|
||||
* it, exactly like `'rejected'` — the two differ only for audit and wording.
|
||||
* Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn
|
||||
* request, or unavailable answerer. Callers fail closed on `unavailable`.
|
||||
*/
|
||||
export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
|
||||
@@ -218,14 +171,10 @@ function hasOpenTurn(events: readonly SessionEvent[]): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* THE write path for a session's approval-policy override: appends exactly
|
||||
* one `approval/policy` event — the switch IS its event; nothing mutates
|
||||
* policy state out of band. Takes effect on the session's next ask and next
|
||||
* prompt assembly (the consumers fold on every read). Rejects a value outside
|
||||
* {@link APPROVAL_POLICIES} before appending anything.
|
||||
* Append the sole durable representation of a session policy override. Invalid
|
||||
* values throw before the log changes; consumers fold the new value on each read.
|
||||
* @param session - the session the override belongs to.
|
||||
* @param policy - the policy every subsequent ask for this session resolves
|
||||
* under (until the next switch).
|
||||
* @param policy - the policy in effect until the next switch.
|
||||
*/
|
||||
export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void {
|
||||
if (!APPROVAL_POLICIES.includes(policy)) {
|
||||
@@ -235,13 +184,8 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi
|
||||
}
|
||||
|
||||
/**
|
||||
* One concrete permission question. Identifies the action precisely enough
|
||||
* for an answerer to present it and for the audit events to reconstruct what
|
||||
* was asked — it deliberately does NOT carry tool arguments: a UI answerer
|
||||
* attaches the prompt to the already-streamed tool call via `callId` instead
|
||||
* of re-rendering the call. This is a readonly same-process contract:
|
||||
* `request()` borrows the request and its `agent` and `signal` capabilities
|
||||
* directly rather than treating them as serialized input.
|
||||
* Readonly same-process permission question. `callId` links to an already
|
||||
* presented tool call, so arguments are not duplicated here.
|
||||
*/
|
||||
export interface ApprovalRequest {
|
||||
/**
|
||||
@@ -278,18 +222,9 @@ export interface Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ctx.approval` service: dispatches {@link ApprovalRequest}s to the
|
||||
* `approval/request` waterfall and audits every ask/outcome pair to the
|
||||
* requesting agent's session log. Stateless between requests — grants are
|
||||
* returned to the caller, never stored here.
|
||||
*
|
||||
* Owns the policy tier too (`effective = fold(the session's 'approval/policy'
|
||||
* events) ?? config.policy`): `request()` resolves `'never'` to `'rejected'`
|
||||
* before dispatching any interactive answerer, a per-agent prompt section
|
||||
* states a `'never'` policy (and only that one in prose — an `'ask'` promise
|
||||
* could overclaim an answerer that headless compositions do not have), and an
|
||||
* `agent/pre-step` narrator injects at most one coalesced notice when a
|
||||
* session's effective policy moved past what the model was last told.
|
||||
* Approval service that applies session policy before answerers and logs every
|
||||
* ask/outcome pair to the requesting session. It exposes deterministic policy
|
||||
* changes to the model through prompt and pre-step notices.
|
||||
*/
|
||||
export class ApprovalService extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -301,12 +236,7 @@ export class ApprovalService extends Service {
|
||||
|
||||
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
|
||||
|
||||
// Visibility layer 1, scoped on the prompt registry so headless
|
||||
// compositions mount the seam without it: state the one deterministic
|
||||
// policy per session. 'ask' renders only a source-owned state marker —
|
||||
// stating "you will be asked" would overclaim in a composition with no
|
||||
// answerer. The marker, not deployment-controlled prose, is what the
|
||||
// restart narrator reads back from the logged request header.
|
||||
// State only deterministic policy; a marker records the otherwise silent state.
|
||||
ctx.inject(['systemPrompt'], (scope: Context) => {
|
||||
scope.systemPrompt.section({
|
||||
name: 'approval:policy',
|
||||
|
||||
@@ -436,10 +436,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. This eager grant would bypass a listener-based gate and therefore must never run.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const consulted = vi.fn()
|
||||
|
||||
@@ -21,11 +21,11 @@ When an answer includes `custom`, `selected` is empty; custom text is an overrid
|
||||
|
||||
## Role
|
||||
|
||||
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop.
|
||||
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or the exact `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: no user-interaction provider is registered`, and `Error: <message>` failures while waiting for the human adds no tokens.
|
||||
Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: no user-interaction provider is registered`, or `Error: <message>`. Waiting for the human adds no tokens.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
Reference in New Issue
Block a user