Merge remote-tracking branch 'origin/master' into cross-family-fs-sandbox
# Conflicts: # .agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md # .agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md # docs/capability-seams.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # docs/rfc/INDEX.md # examples/acp-agent/README.md # examples/acp-agent/fs.cordis.snapshot.yml # examples/acp-agent/fs.cordis.yml # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/permission-switching/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md # examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json # examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md # examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json # packages/bash/bash/src/index.ts # packages/bash/tool-bash/package.json # packages/bash/tool-bash/src/index.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/fs/README.md # packages/fs/tool-fs/src/edit.ts # packages/fs/tool-fs/src/write.ts # packages/sandbox/README.md # pnpm-lock.yaml
This commit is contained in:
@@ -2,19 +2,20 @@
|
||||
|
||||
Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target.
|
||||
|
||||
It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
|
||||
It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui`/`dsh-stdio` channels — NOT a loop change and NOT a [capability seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
|
||||
|
||||
## Service / plugin
|
||||
|
||||
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
|
||||
|
||||
The plugin injects `agents`, `sessions`, `sessionPersistence`, `tools`, and `userInteraction`, never the concrete loop. Persistence backs `session/load`; tool definitions own presentation; user interaction maps agent questions to ACP forms.
|
||||
The plugin injects `agents`, `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms.
|
||||
|
||||
### Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `model` | — | Model name for created agents (must have a registered adapter). |
|
||||
| `provider` | — | Initial provider route for created agents (must have a registered adapter). |
|
||||
| `model` | — | Initial model id for created agents. |
|
||||
|
||||
(No persona key: `dsh-system-prompt`'s own `persona` config supplies the global default section, so ACP-created agents render it without the bridge carrying prompt text. An agent-scoped same-name section may still shadow that default.)
|
||||
|
||||
@@ -32,15 +33,17 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
|
||||
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, and tool render intents |
|
||||
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
|
||||
| `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" |
|
||||
| `session/set_config_option` | `ctx.permission.set()` | per-session permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
|
||||
| `session/set_config_option` | agent-scoped request target / `ctx.permission.set()` | per-session provider+model and permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
|
||||
|
||||
## Multi-session
|
||||
|
||||
Forward and reverse indexes route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md).
|
||||
One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md).
|
||||
|
||||
## Session config options
|
||||
|
||||
When `ctx.permission` is composed, the bridge advertises one `permission` select in `session/new` and `session/load`. Options come from the deployment's preset table; the current value comes from the session fold, with switch-away-only `custom` for unmatched knobs. `session/set_config_option` accepts advertised presets and writes both sandbox-mode and approval-policy events through `PermissionService.set()`. Open-turn switches append immediately; idle switches overlay responses and anchor at the next `agent/prompt-submit`, before request assembly. A crash before anchoring restores the durable fold. See the [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), [`dsh-permission`](../permission/README.md), and [protocol matrix](acp-feature-support.md#6-session-config-options).
|
||||
The bridge advertises a `model`-category select in `session/new` and `session/load` when the session has a complete target whose provider is registered. Values encode the complete provider/model pair, are grouped by provider when more than one group is available, and come from `ctx.llm.listProviders()` / `listModels()`. The configured or last-requested model is added when absent because catalogs are advisory and private adapters may accept unlisted ids. A selection changes only that ACP session. Agent-scoped prompt assembly snapshots the selected pair for one step, supplies matching `{{provider}}` / `{{model}}` variables, and the `agent/request` waterfall applies the same pair; a concurrent selection therefore takes effect on the next step instead of splitting prompt text from routing. The resulting request header is the durable record restored by `session/load`; a selection never used by a request remains in-memory only.
|
||||
|
||||
When `ctx.permission` is composed, the bridge also advertises a `permission` select. Options come from the deployment's preset table; the current value comes from the session fold, with switch-away-only `custom` for unmatched knobs. `session/set_config_option` accepts advertised presets and writes both sandbox-mode and approval-policy events through `PermissionService.set()`. Open-turn switches append immediately; idle switches overlay responses and anchor at the next `agent/prompt-submit`, before request assembly. A crash before anchoring restores the durable fold. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md), [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md), [`dsh-permission`](../permission/README.md), and [protocol matrix](acp-feature-support.md#6-session-modes--config-options--models).
|
||||
|
||||
The shared [`ctx.tasks` runtime](../../tasks/tasks/) fences access to predictable task ids by the owning session; ACP sessions therefore cannot read or stop one another's background work.
|
||||
|
||||
@@ -54,7 +57,7 @@ Tools return provider-neutral `generic`, `terminal`, or `diff` render intents fr
|
||||
|
||||
## Terminal card (capability-gated)
|
||||
|
||||
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
|
||||
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md).
|
||||
|
||||
## Settle-exactly-once
|
||||
|
||||
@@ -70,7 +73,7 @@ Disposal and client disconnect share one memoized teardown. It cancels pending p
|
||||
|
||||
## stdout is the protocol
|
||||
|
||||
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
|
||||
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -91,32 +94,77 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa
|
||||
|
||||
### User messages
|
||||
|
||||
**What the model sees**: Each ACP `session/prompt` becomes an agent user message: text passes through verbatim and each `resource_link` becomes exactly a leading newline, `[resource_link name=<JSON-string> uri=<JSON-string>]`, and a trailing newline. Unsupported image, audio, and embedded-resource blocks are rejected rather than silently omitted.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions keep separate contexts.
|
||||
Each ACP `session/prompt` becomes an agent user message: text passes through verbatim and each `resource_link` becomes exactly a leading newline, `[resource_link name=<JSON-string> uri=<JSON-string>]`, and a trailing newline. Unsupported image, audio, and embedded-resource blocks are rejected rather than silently omitted.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions keep separate contexts.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Human answers and permission decisions
|
||||
|
||||
**What the model sees**: When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens.
|
||||
When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Permission preset switches
|
||||
|
||||
**What the model sees**: `session/set_config_option` emits no model message itself. When `dsh-permission` is composed, the bridge writes the selected preset through that service; the resulting model-visible policy prompt and change notice belong to [`dsh-user-approval`](../user-approval/README.md), while sandbox-mode effects belong to [`dsh-tool-bash`](../../bash/tool-bash/README.md). The ACP `Permissions` select, its option descriptions, pending idle value, and refreshed config response remain client-only.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Zero direct tokens from the ACP option or the log-only `permission/preset` event. Downstream cost is limited to the owning plugins' policy prompt, conditional retained change notice, and any changed tool outcome.
|
||||
`session/set_config_option` emits no model message itself. When `dsh-permission` is composed, the bridge writes the selected preset through that service; the resulting model-visible policy prompt and change notice belong to [`dsh-user-approval`](../user-approval/README.md), while sandbox-mode effects belong to [`dsh-tool-bash`](../../bash/tool-bash/README.md). The ACP `Permissions` select, its option descriptions, pending idle value, and refreshed config response remain client-only.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero direct tokens from the ACP option or the log-only `permission/preset` event. Downstream cost is limited to the owning plugins' policy prompt, conditional retained change notice, and any changed tool outcome.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The ACP option and log event cause no direct invalidation. The downstream policy-prompt change may invalidate reuse from that system section, while its change notice appends to history.
|
||||
|
||||
### Model switches
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The ACP selector itself emits no message. The selected provider/model pair supplies the next step's `{{provider}}` / `{{model}}` prompt variables and request routing together; all other call-config fields continue through the `agent/request` waterfall unchanged.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The selector adds no direct tokens. A changed model may tokenize the same retained prompt/history differently, and any persona text that interpolates provider or model changes accordingly.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Switching provider or model selects a different cache domain. If the persona interpolates either value, the rendered system prompt also changes and prevents reuse from its first changed token.
|
||||
|
||||
### Loaded sessions
|
||||
|
||||
**What the model sees**: `session/load` resumes the persisted log, after which the loop sends its reconstructed history and request header. Replaying that log to the editor is not an extra model message.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Restored context has the persistence and session packages' normal retained cost; ACP replay to the client adds none.
|
||||
`session/load` resumes the persisted log, after which the loop sends its reconstructed history and request header. Replaying that log to the editor is not an extra model message.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Restored context has the persistence and session packages' normal retained cost; ACP replay to the client adds none.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Loading does not rewrite the stored log, but the next request is reconstructed under the current envelope and route. Reuse requires that reconstruction to match; ACP replay to the client has no cache effect.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented.
|
||||
- **Prompt content is `text` + `resource_link` only** — image, audio, and embedded-resource blocks are rejected, as is a non-empty `mcpServers` list at `session/new`.
|
||||
- **One configured `model` for every created session** — per-session model selection has no config or protocol surface here yet.
|
||||
- **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
|
||||
- **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
|
||||
- **Permission answers are one-shot only** — the bridge offers `allow_once` / `reject_once`; durable `allow_always` grants and their storage/revocation policy remain deferred to the approval seam.
|
||||
|
||||
@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
|
||||
|
||||
## At a glance
|
||||
|
||||
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, and per-session permission presets. The largest **unbuilt** areas are **MCP passthrough**, runtime model selection, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
|
||||
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough**, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
|
||||
|
||||
## 1. Agent methods (client → agent)
|
||||
|
||||
@@ -26,8 +26,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
|
||||
| `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. |
|
||||
| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. |
|
||||
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). |
|
||||
| `session/set_config_option` | S | ✅ | ✅ | ✅ | One `permission` select when `ctx.permission` is composed; values come from the deployment preset table, a switch writes its preset event through to both knob events, and the response carries the complete refreshed state ([sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). |
|
||||
| model selection | S | ❌ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. The bridge fixes the model per-bridge via config; no runtime switch. Codex still uses the legacy `unstable_setSessionModel` ext method. |
|
||||
| `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |
|
||||
| model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. |
|
||||
| `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. |
|
||||
| `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. |
|
||||
| `session/fork` | U | ❌ | ✅ | ❌ | Claude ships `unstable_forkSession`; Codex does not. |
|
||||
@@ -86,13 +86,13 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
|
||||
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). |
|
||||
| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. |
|
||||
| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. |
|
||||
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). |
|
||||
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox Agent Note § Per-session mode switching](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). |
|
||||
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
|
||||
| `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. |
|
||||
|
||||
## 5. Tool-call rendering
|
||||
|
||||
Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
|
||||
Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
|
||||
|
||||
| Feature | Stable | Bridge | Claude | Codex | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -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 `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).
|
||||
Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. Session modes stay deliberately unmodeled because config options replace them in ACP v2. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
## 7. Content blocks
|
||||
|
||||
@@ -130,7 +130,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
|
||||
| Feature | Stable | Bridge | Notes |
|
||||
|---|---|---|---|
|
||||
| `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. |
|
||||
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). |
|
||||
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md). |
|
||||
| Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. |
|
||||
| `_meta` extensibility | S | ⚠️ | Consumed (Zed terminal cap) and emitted (terminal `_meta`); no other custom extensions. |
|
||||
| Background-task ownership isolation | — | ✅ | Generic `task_output`/`task_kill` reject tasks whose branded owner `SessionId` belongs to another session. |
|
||||
@@ -141,13 +141,12 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
|
||||
Ranked by how commonly the reference adapters ship them and how much UX they unlock:
|
||||
|
||||
1. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
|
||||
2. **Model selection** — sandbox and approval config options are implemented; selecting the bridge's model at runtime remains open.
|
||||
3. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
|
||||
4. **Slash commands** (`available_commands_update`).
|
||||
5. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
|
||||
6. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
|
||||
7. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
|
||||
8. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
|
||||
2. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
|
||||
3. **Slash commands** (`available_commands_update`).
|
||||
4. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
|
||||
5. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
|
||||
6. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
|
||||
7. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
|
||||
|
||||
## Out of scope
|
||||
|
||||
@@ -157,4 +156,4 @@ Unstable/draft ACP features that **neither** reference adapter ships are not tra
|
||||
|
||||
- Stable spec: `schema/v1/schema.json` (schema `1.14.0`) and `docs/protocol/v1/*.mdx` in the [agent-client-protocol](https://github.com/agentclientprotocol/agent-client-protocol) repo.
|
||||
- Reference adapters: [`claude-agent-acp`](https://github.com/zed-industries/claude-code-acp) and [`codex-acp`](https://github.com/zed-industries/codex-acp).
|
||||
- Bridge: [`README.md`](README.md), [`src/index.ts`](src/index.ts), and the ACP RFCs under [`docs/rfc/`](../../../docs/rfc/README.md).
|
||||
- Bridge: [`README.md`](README.md), [`src/index.ts`](src/index.ts), and the ACP Agent Notes under [`.agents/notes/`](../../../.agents/notes/README.md).
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
@@ -42,6 +43,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
|
||||
@@ -12,14 +12,14 @@ sequenceDiagram
|
||||
participant Workspace
|
||||
participant Replay as llm-replay adapter
|
||||
participant ACP as acp-agent subprocess
|
||||
participant Golden as stdout golden
|
||||
participant Expected as stdout expected output
|
||||
Recorder->>Fixture: session.jsonl + workspace inputs
|
||||
Fixture->>Workspace: seed files and hook configs
|
||||
Fixture->>Replay: recorded StreamChunk script
|
||||
Replay->>ACP: deterministic <code>llm/stream</code> chunks
|
||||
ACP->>Workspace: bash, fs, and hook side effects
|
||||
ACP->>Golden: normalized sessionUpdate stream
|
||||
Golden-->>ACP: diff must be empty
|
||||
ACP->>Expected: normalized sessionUpdate stream
|
||||
Expected-->>ACP: diff must be empty
|
||||
```
|
||||
|
||||
The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Multi-session ACP server bridge over JSON-RPC stdio. Creates or resumes
|
||||
* agents, routes their events, settles prompts by turn, and answers approvals.
|
||||
* Each session keeps independent presentation and prompt-correlation state so
|
||||
* concurrent streams cannot cross. Stdout is reserved for protocol frames.
|
||||
* Multi-session ACP bridge over JSON-RPC stdio. Creates or resumes agents,
|
||||
* routes session-scoped events and approvals, and settles prompts by turn.
|
||||
* Stdout is reserved for protocol frames.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-acp
|
||||
*/
|
||||
|
||||
@@ -34,16 +34,17 @@ import {
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type SessionConfigOption,
|
||||
type SessionConfigSelectGroup,
|
||||
type SessionConfigSelectOption,
|
||||
type SessionNotification,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionConfigOptionResponse,
|
||||
type Stream,
|
||||
type StopReason,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Side-effect type import: resolves `ctx.get('permission')` to the service.
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
@@ -52,6 +53,9 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f
|
||||
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
|
||||
// Context (the bridge injects it and reads `list()` for load cwd validation).
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
// Side-effect type import: declaration-merges prompt assembly onto Context and
|
||||
// the scoped waterfall used to keep persona variables aligned with requests.
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
// Side-effect type import: declaration-merges the `approval/request` waterfall
|
||||
// the bridge answers for its own agents (see the approval answerer below).
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
@@ -71,16 +75,15 @@ import {
|
||||
} from './codec.ts'
|
||||
|
||||
export const name = 'acp'
|
||||
// Interface services back advertised loading, tool-owned presentation with a generic fallback, and interaction.
|
||||
// TODO(acp-session-inject): remove `sessions`; the bridge never reads it, and ownership is already behind `agents`.
|
||||
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']
|
||||
// Interface services back loading, presentation, interaction, and prompt assembly.
|
||||
export const inject = ['agents', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt']
|
||||
|
||||
/** Build an ACP invalid-params error with visible human detail. */
|
||||
/** Preserve invalid-parameter detail in the SDK wire error message. */
|
||||
function invalidParams(detail: string): RequestError {
|
||||
return RequestError.invalidParams(undefined, detail)
|
||||
}
|
||||
|
||||
/** Build an ACP internal error with visible detail; plain handler errors are flattened on wire. */
|
||||
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
|
||||
function internalError(detail: string): RequestError {
|
||||
return RequestError.internalError(undefined, detail)
|
||||
}
|
||||
@@ -201,36 +204,62 @@ function stringArrayContent(
|
||||
|
||||
/** Plugin config: the agent template ACP sessions are created from. */
|
||||
export interface AcpConfig {
|
||||
/** Provider route for created agents. */
|
||||
provider?: string
|
||||
/** Model name for created agents (must have a registered adapter). */
|
||||
model?: string
|
||||
/** Runtime-only transport override for tests; production uses stdio. */
|
||||
/** Runtime-only transport override; production uses stdio. */
|
||||
stream?: Stream
|
||||
}
|
||||
|
||||
export const Config: Schema<AcpConfig> = Schema.object({
|
||||
provider: Schema.string(),
|
||||
model: Schema.string(),
|
||||
})
|
||||
|
||||
/** Provider/model pair selected for one ACP session. */
|
||||
interface LlmTarget {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** Mutable target shared by one agent's scoped assembly and request listeners. */
|
||||
interface LlmTargetRef {
|
||||
current: LlmTarget | undefined
|
||||
/** Step snapshot captured by prompt assembly so target switches cannot split prompt and request. */
|
||||
assembled: LlmTarget | undefined
|
||||
}
|
||||
|
||||
/** One resolved ACP model selector plus its opaque value lookup. */
|
||||
interface ModelDirectory {
|
||||
option: Extract<SessionConfigOption, { type: 'select' }> | undefined
|
||||
targets: ReadonlyMap<string, LlmTarget>
|
||||
}
|
||||
|
||||
/** One provider and its adapter-advertised models, detached for one RPC. */
|
||||
interface ModelCatalogEntry {
|
||||
provider: LlmProviderInfo
|
||||
models: LlmModelInfo[]
|
||||
}
|
||||
|
||||
/** Per-session bridge state keyed by ACP session id. */
|
||||
interface SessionRecord {
|
||||
sessionId: SessionId
|
||||
agent: Agent
|
||||
/** Owned-agent disposer that reaches per-session quiescence. */
|
||||
/** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */
|
||||
dispose: () => Promise<void>
|
||||
/** Per-session tool presenter and in-flight call correlation. */
|
||||
/** Per-session tool presentation and call/result correlation. */
|
||||
presenter: ToolPresenter
|
||||
/** Session-creation snapshot of terminal-card support for call/result consistency. */
|
||||
/** Terminal capability snapshot shared by matching call and result updates. */
|
||||
terminalEnabled: boolean
|
||||
/** Session-local provider/model selection and the current step snapshot. */
|
||||
target: LlmTargetRef
|
||||
/** In-flight prompt and its captured turn number for exact settlement. */
|
||||
inflight: {
|
||||
resolve: (reason: StopReason) => void
|
||||
reject: (error: Error) => void
|
||||
turn: number | undefined
|
||||
} | undefined
|
||||
/**
|
||||
* Idle config changes awaiting a turn-enclosed log anchor; last write wins.
|
||||
* Responses overlay them, but a restart before anchoring restores the logged fold.
|
||||
*/
|
||||
/** Last idle switch per knob, anchored before the next prompt assembles. */
|
||||
pendingSwitches: { preset?: string }
|
||||
}
|
||||
|
||||
@@ -241,25 +270,118 @@ interface SessionRecord {
|
||||
* correlation in a `finally` so presentation failure cannot starve settlement.
|
||||
*/
|
||||
export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// Handlers run later outside this injection scope, so capture services now.
|
||||
// ACP handlers execute outside this plugin's injection scope, so capture
|
||||
// injected services during apply(); lazy service reads in a handler fail.
|
||||
const agents = ctx.agents
|
||||
const llm = ctx.llm
|
||||
const sessionPersistence = ctx.sessionPersistence
|
||||
const logger = ctx.logger
|
||||
const tools = ctx.tools
|
||||
const userInteraction = ctx.userInteraction
|
||||
// Presenter failures are logged and contained per session or replay.
|
||||
// Presenter callbacks are contained so display failures cannot break protocol handling.
|
||||
const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent)
|
||||
|
||||
// TODO(derive-acp-session-id): derive event ids from `agent.session`, verify ownership, then remove the reverse map.
|
||||
// Agent events currently carry only the Agent, so retain `SessionRecord.sessionId` and update both indexes together.
|
||||
// Dropping the forward record lets the weak reverse entry expire.
|
||||
/** Resolve a complete target only; partial config remains available to other request listeners. */
|
||||
const configuredTarget = (): LlmTarget | undefined => config.provider !== undefined && config.model !== undefined
|
||||
? { provider: config.provider, model: config.model }
|
||||
: undefined
|
||||
|
||||
/** Install the ACP target as an agent-scoped prompt/request override. */
|
||||
const installTarget = (agentCtx: Context, target: LlmTargetRef): void => {
|
||||
const agent = agentCtx.agent
|
||||
/* v8 ignore next -- setup is invoked only with the freshly created agent's scoped context. */
|
||||
if (agent === undefined) throw new Error('acp: agent setup has no scoped agent')
|
||||
const logged = agent.session.requestHeader()?.config
|
||||
if (logged !== undefined) target.current = { provider: logged.provider, model: logged.model }
|
||||
|
||||
// Capture once at assembly entry and apply the same pair after downstream
|
||||
// prompt listeners. A selector change during async assembly therefore takes
|
||||
// effect on the following step instead of splitting {{model}} from routing.
|
||||
agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const selected = target.current
|
||||
const assembled = await next()
|
||||
target.assembled = selected
|
||||
if (selected === undefined) return assembled
|
||||
return {
|
||||
...assembled,
|
||||
variables: {
|
||||
...assembled.variables,
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
},
|
||||
}
|
||||
})
|
||||
agentCtx.on('agent/request', async (_agent, _turn, _step, _callConfig, next): Promise<LlmCallConfig> => {
|
||||
const resolved = await next()
|
||||
const selected = target.assembled
|
||||
return selected === undefined ? resolved : {
|
||||
...resolved,
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Opaque ACP value preserving both routing dimensions. */
|
||||
const targetValue = (target: LlmTarget): string => JSON.stringify([target.provider, target.model])
|
||||
|
||||
/** Read one detached advisory catalog snapshot before mutating session state. */
|
||||
const readModelCatalog = async (): Promise<ModelCatalogEntry[]> => Promise.all(
|
||||
llm.listProviders().map(async provider => ({
|
||||
provider,
|
||||
models: await llm.listModels(provider.id),
|
||||
})),
|
||||
)
|
||||
|
||||
/** Resolve one catalog snapshot into the ACP model selector for a session. */
|
||||
const modelDirectory = (catalog: readonly ModelCatalogEntry[], current: LlmTarget | undefined): ModelDirectory => {
|
||||
if (current === undefined) return { option: undefined, targets: new Map() }
|
||||
const models = catalog.map(entry => ({ provider: entry.provider, models: [...entry.models] }))
|
||||
const currentProvider = models.find(entry => entry.provider.id === current.provider)
|
||||
if (currentProvider === undefined) return { option: undefined, targets: new Map() }
|
||||
if (!currentProvider.models.some(model => model.id === current.model)) {
|
||||
currentProvider.models = [...currentProvider.models, {
|
||||
provider: current.provider,
|
||||
id: current.model,
|
||||
name: current.model,
|
||||
}]
|
||||
}
|
||||
|
||||
const targets = new Map<string, LlmTarget>()
|
||||
const groups = models.flatMap(({ provider, models: entries }) => {
|
||||
if (entries.length === 0) return []
|
||||
const options = entries.map((model): SessionConfigSelectOption => {
|
||||
const target = { provider: model.provider, model: model.id }
|
||||
const value = targetValue(target)
|
||||
targets.set(value, target)
|
||||
return {
|
||||
value,
|
||||
name: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
}
|
||||
})
|
||||
return [{ group: provider.id, name: provider.name, options } satisfies SessionConfigSelectGroup]
|
||||
})
|
||||
return {
|
||||
option: {
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'Sets this session\'s provider and model.',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue: targetValue(current),
|
||||
options: groups.length === 1 ? groups.flatMap(group => group.options) : groups,
|
||||
},
|
||||
targets,
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = new Map<SessionId, SessionRecord>()
|
||||
const bySession = new WeakMap<Agent, SessionId>()
|
||||
// Reserve ids across asynchronous resume; distinct ids still load concurrently.
|
||||
// Reserve an id before resume so pipelined load/new requests cannot duplicate it.
|
||||
const loadingIds = new Set<SessionId>()
|
||||
// Post-await checks prevent a closing bridge from publishing resumed sessions.
|
||||
// Async creation checks this after awaits to avoid publishing after teardown.
|
||||
let closed = false
|
||||
// Connection-level capability copied into each new session record.
|
||||
// Each new or loaded session snapshots the latest connection capability.
|
||||
let terminalOutputCap = false
|
||||
|
||||
// Assigned at the bottom, before any agent event can fire (a session only
|
||||
@@ -267,20 +389,26 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// `notify` never observes it unset — no undefined guard needed.
|
||||
let conn: AgentSideConnection
|
||||
|
||||
/** Return the bridge-owned record for an agent, rejecting same-id impostors. */
|
||||
const ownedRecord = (agent: Agent): SessionRecord | undefined => {
|
||||
const rec = sessions.get(agent.session.id)
|
||||
return rec?.agent === agent ? rec : undefined
|
||||
}
|
||||
|
||||
userInteraction.registerProvider({
|
||||
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
|
||||
if (request.agent === undefined) {
|
||||
throw new UserInteractionError('ACP user questions must come from an agent-owned request', 'NO_AGENT')
|
||||
}
|
||||
const sessionId = bySession.get(request.agent)
|
||||
if (sessionId === undefined) {
|
||||
const rec = ownedRecord(request.agent)
|
||||
if (rec === undefined) {
|
||||
throw new UserInteractionError('ACP user question has no matching session', 'NO_SESSION')
|
||||
}
|
||||
const answers: AskUserQuestionAnswerItem[] = []
|
||||
for (const question of request.questions) {
|
||||
const options = question.options ?? []
|
||||
const response = await withAbort(conn.unstable_createElicitation(
|
||||
elicitationForQuestion(sessionId, question, options),
|
||||
elicitationForQuestion(rec.agent.session.id, question, options),
|
||||
), request.signal).catch((error: unknown) => {
|
||||
if (error instanceof UserInteractionError) throw error
|
||||
throw new UserInteractionError('ACP elicitation request failed', 'ASK_FAILED', { cause: error })
|
||||
@@ -375,13 +503,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// whose end arrives late is ignored (see
|
||||
// SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP
|
||||
// has no error stop reason); other reasons resolve via the codec. Demux
|
||||
// strictly by session id: a `session/event` is routed to its own record, so
|
||||
// two sessions streaming at once never cross-settle or interleave updates.
|
||||
// strictly by session id: concurrent updates may alternate on the shared
|
||||
// connection, but they retain the owning id and never cross-settle.
|
||||
ctx.on('session/event', (session, event: SessionEvent) => {
|
||||
const rec = sessions.get(session.header.id)
|
||||
if (rec === undefined) return
|
||||
try {
|
||||
streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, {
|
||||
streamSessionEventUpdate(rec.agent.session.id, event, notify, rec.presenter, {
|
||||
enabled: rec.terminalEnabled,
|
||||
cwd: session.header.cwd,
|
||||
}, { includeUserMessages: false })
|
||||
@@ -409,15 +537,15 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// the fail-closed `unavailable` default) takes the question. A rejected
|
||||
// `requestPermission` (client gone, bridge torn down) propagates and the
|
||||
// ApprovalService contains it as `unavailable`. Options are one-shot only:
|
||||
// allow_always is a grant-storage design the approval RFC defers, so the
|
||||
// allow_always is a grant-storage design the approval Agent Note defers, so the
|
||||
// prompt never offers a durable grant the harness could not honor.
|
||||
ctx.on('approval/request', (req, next) => {
|
||||
const sessionId = bySession.get(req.agent)
|
||||
const rec = ownedRecord(req.agent)
|
||||
// The protocol requires `toolCall` (the prompt renders attached to it), so
|
||||
// a request without a callId has nothing to attach to — delegate.
|
||||
if (sessionId === undefined || req.callId === undefined) return next()
|
||||
if (rec === undefined || req.callId === undefined) return next()
|
||||
return conn.requestPermission({
|
||||
sessionId,
|
||||
sessionId: rec.agent.session.id,
|
||||
toolCall: { toolCallId: req.callId },
|
||||
options: [
|
||||
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
|
||||
@@ -433,38 +561,32 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
|
||||
// --- The ACP Agent method surface -----------------------------------------
|
||||
|
||||
/**
|
||||
* Build the single Permissions option when `ctx.permission` is composed.
|
||||
* Its value comes from the session log, overlaid by an unanchored idle
|
||||
* switch, so `session/load` needs no catch-up state.
|
||||
*/
|
||||
const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => {
|
||||
/** Build every ACP session option from the model directory and live services. */
|
||||
const configOptionsFor = (
|
||||
agent: Agent,
|
||||
directory: ModelDirectory,
|
||||
pending: SessionRecord['pendingSwitches'] = {},
|
||||
): SessionConfigOption[] => {
|
||||
const options = directory.option === undefined ? [] : [directory.option]
|
||||
const presets = ctx.get('permission')
|
||||
if (presets === undefined) return []
|
||||
if (presets === undefined) return options
|
||||
const currentValue = pending.preset ?? presets.current(agent.session.events)
|
||||
return [{
|
||||
return [...options, {
|
||||
id: 'permission',
|
||||
name: 'Permissions',
|
||||
description: 'Sets this session\'s sandbox and approval behavior.',
|
||||
description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
...presets.names.map((name: string) => presets.optionOf(name)),
|
||||
// `custom` is offered only as the current-value echo, never as a target.
|
||||
// `custom` echoes the current derived state but is never a target.
|
||||
...currentValue === 'custom' ? [presets.optionOf('custom')] : [],
|
||||
],
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session's log currently has an open turn — the last boundary
|
||||
* event is a `turn/start`. Decides whether a config switch may append NOW
|
||||
* (enclosed) or must wait for the next prompt submission (see
|
||||
* {@link SessionRecord.pendingSwitches}). Read from the LOG, not
|
||||
* `agent.status`: status stays `running` across the gap between two queued
|
||||
* turns, where a bare append would still land outside any turn.
|
||||
*/
|
||||
/** Whether the log has an open turn in which a config switch can be enclosed. */
|
||||
const isTurnOpen = (agent: Agent): boolean => {
|
||||
const events = agent.session.events
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
@@ -475,29 +597,22 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor a pending preset in the open turn. `PermissionService.set()` skips
|
||||
* net-zero changes, so the log records switches rather than select clicks.
|
||||
*/
|
||||
/** Anchor last-write-wins idle switches into a just-opened turn. */
|
||||
const flushPendingSwitches = (rec: SessionRecord): void => {
|
||||
const pending = rec.pendingSwitches
|
||||
rec.pendingSwitches = {}
|
||||
if (pending.preset === undefined) return
|
||||
const presets = ctx.get('permission')
|
||||
/* v8 ignore next -- a pending preset exists only if the service answered the
|
||||
switch; a valid composition cannot unmount it before anchoring. */
|
||||
switch; it cannot unmount between that and the next turn in any composition. */
|
||||
if (presets === undefined) return
|
||||
presets.set(rec.agent.session, pending.preset)
|
||||
}
|
||||
|
||||
// Anchor idle switches on the next prompt submission: its turn is open, but
|
||||
// request assembly has not begun. This handler runs outside log emission, so
|
||||
// invariants and persistence observe the events in log order; the first flush
|
||||
// clears pending state. Promptless injection turns leave the switch pending,
|
||||
// with no request or execution under stale settings.
|
||||
// Prompt-submit is inside the new turn but before prompt assembly. Promptless
|
||||
// injection turns leave the switch pending because they execute no request.
|
||||
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
|
||||
const sessionId = bySession.get(agent)
|
||||
const rec = sessionId === undefined ? undefined : sessions.get(sessionId)
|
||||
const rec = ownedRecord(agent)
|
||||
if (rec !== undefined) flushPendingSwitches(rec)
|
||||
return next()
|
||||
})
|
||||
@@ -512,7 +627,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
const protocolVersion = params.protocolVersion === PROTOCOL_VERSION ? params.protocolVersion : PROTOCOL_VERSION
|
||||
// Remember the Zed terminal-output `_meta` capability: when set, bash and
|
||||
// other shell tools render as a terminal card (see streamSessionEventUpdate
|
||||
// + the terminal-rendering RFC). `_meta` is `{[k]: unknown} | null`, so
|
||||
// + the terminal-rendering Agent Note). `_meta` is `{[k]: unknown} | null`, so
|
||||
// narrow defensively to a strict boolean true.
|
||||
terminalOutputCap = params.clientCapabilities?._meta?.['terminal_output'] === true
|
||||
return Promise.resolve({
|
||||
@@ -541,33 +656,33 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
validateWorkspaceParams(params)
|
||||
validateMcpServers(params)
|
||||
const sessionId = SessionId(randomUUID())
|
||||
const target: LlmTargetRef = { current: configuredTarget(), assembled: undefined }
|
||||
const directory = modelDirectory(await readModelCatalog(), target.current)
|
||||
assertOpen()
|
||||
const handle = await agents.create({
|
||||
agentId: AgentId(sessionId),
|
||||
sessionId,
|
||||
meta: { cwd: params.cwd },
|
||||
agentOptions: agentOptions(config),
|
||||
setup: (agentCtx) => { installTarget(agentCtx, target) },
|
||||
})
|
||||
// Creation awaits the unpublished setup transaction. A client disconnect
|
||||
// can therefore close this bridge
|
||||
// after the entry check but before the handle resolves; never install a
|
||||
// post-close record that quiesce() could not have seen.
|
||||
// Agent creation may resolve after the bridge closes; dispose the handle
|
||||
// instead of publishing a record that teardown could not observe.
|
||||
/* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC
|
||||
immediately on close; real stdio may let the handler resume */
|
||||
if (closed) {
|
||||
await handle.dispose()
|
||||
throw internalError('connection closed during session/new')
|
||||
}
|
||||
bySession.set(handle.agent, sessionId)
|
||||
sessions.set(sessionId, {
|
||||
sessionId,
|
||||
agent: handle.agent,
|
||||
dispose: () => handle.dispose(),
|
||||
presenter: makePresenter(handle.agent),
|
||||
terminalEnabled: terminalOutputCap,
|
||||
target,
|
||||
inflight: undefined,
|
||||
pendingSwitches: {},
|
||||
})
|
||||
const configOptions = configOptionsFor(handle.agent)
|
||||
const configOptions = configOptionsFor(handle.agent, directory)
|
||||
return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} }
|
||||
},
|
||||
|
||||
@@ -612,10 +727,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
throw invalidParams(`session ${sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`)
|
||||
}
|
||||
}
|
||||
const catalog = await readModelCatalog()
|
||||
assertOpen()
|
||||
const target: LlmTargetRef = { current: configuredTarget(), assembled: undefined }
|
||||
const handle = await agents.resume({
|
||||
agentId: AgentId(sessionId),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: agentOptions(config),
|
||||
setup: (agentCtx) => { installTarget(agentCtx, target) },
|
||||
})
|
||||
// The bridge may have torn down (disposal / client disconnect) while
|
||||
// resume() was pending. Its listeners are gone, so installing a record
|
||||
@@ -631,18 +749,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
await handle.dispose()
|
||||
throw invalidParams('connection closed during session/load')
|
||||
}
|
||||
const directory = modelDirectory(catalog, target.current)
|
||||
const agent = handle.agent
|
||||
bySession.set(agent, sessionId)
|
||||
// Snapshot the terminal capability ONCE for this session (used by both
|
||||
// the replay below and the post-load live stream) so a later
|
||||
// `initialize` can't desync the call/result of a tool card.
|
||||
const terminalEnabled = terminalOutputCap
|
||||
const record: SessionRecord = {
|
||||
sessionId,
|
||||
agent,
|
||||
dispose: () => handle.dispose(),
|
||||
presenter: makePresenter(agent),
|
||||
terminalEnabled,
|
||||
target,
|
||||
inflight: undefined,
|
||||
pendingSwitches: {},
|
||||
}
|
||||
@@ -668,7 +786,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
for (const event of agent.session.events) {
|
||||
streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
|
||||
}
|
||||
const configOptions = configOptionsFor(agent)
|
||||
const configOptions = configOptionsFor(agent, directory)
|
||||
return configOptions.length > 0 ? { configOptions } : {}
|
||||
} finally {
|
||||
loadingIds.delete(sessionId)
|
||||
@@ -722,25 +840,41 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
return Promise.resolve()
|
||||
},
|
||||
|
||||
setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
|
||||
async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
|
||||
assertOpen()
|
||||
const rec = requireSession(SessionId(params.sessionId))
|
||||
// The advertised option is a select, so the boolean-shaped variant of
|
||||
// the request is a protocol misuse regardless of configId.
|
||||
// Every advertised option is a select, so the boolean-shaped variant
|
||||
// is a protocol misuse regardless of configId.
|
||||
if (typeof params.value !== 'string') {
|
||||
throw invalidParams(`config option ${params.configId} is a select; boolean values are not accepted`)
|
||||
}
|
||||
let directory = modelDirectory(await readModelCatalog(), rec.target.current)
|
||||
// Open-turn switches append immediately; idle switches wait for the
|
||||
// next prompt-submit. Only values advertised by this composition are
|
||||
// accepted, and the session log remains the durable store.
|
||||
switch (params.configId) {
|
||||
case 'model': {
|
||||
const target = directory.targets.get(params.value)
|
||||
if (target === undefined) {
|
||||
throw invalidParams(`unknown model value ${JSON.stringify(params.value)}`)
|
||||
}
|
||||
rec.target.current = { ...target }
|
||||
const option = directory.option
|
||||
/* v8 ignore next -- `targets` is populated only while constructing
|
||||
this selector; a found target therefore proves it exists. */
|
||||
if (option === undefined) throw internalError('model directory target has no selector')
|
||||
directory = {
|
||||
...directory,
|
||||
option: { ...option, currentValue: params.value },
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'permission': {
|
||||
const presets = ctx.get('permission')
|
||||
if (presets === undefined) {
|
||||
throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`)
|
||||
}
|
||||
// Clients may re-send the current selection on session start. Accept
|
||||
// that echo without logging; this is the only valid `custom` request.
|
||||
// A current-value echo is acknowledged without recording a switch.
|
||||
const current = rec.pendingSwitches.preset ?? presets.current(rec.agent.session.events)
|
||||
if (params.value === current) break
|
||||
if (!presets.names.includes(params.value)) {
|
||||
@@ -755,7 +889,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
}
|
||||
// The spec requires the COMPLETE refreshed config state in the response
|
||||
// (a change may cascade); ours are independent, but the contract holds.
|
||||
return Promise.resolve({ configOptions: configOptionsFor(rec.agent, rec.pendingSwitches) })
|
||||
return { configOptions: configOptionsFor(rec.agent, directory, rec.pendingSwitches) }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -851,11 +985,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
* Build per-agent options from the plugin config, omitting absent fields
|
||||
* (exactOptionalPropertyTypes: never assign `undefined` to an optional key).
|
||||
* Exported for unit coverage of both the present and absent branches.
|
||||
* @param config - the plugin config carrying the optional model name.
|
||||
* @returns the per-agent options, with `model` present only when configured.
|
||||
* @param config - the plugin config carrying the optional provider/model target.
|
||||
* @returns the per-agent options, with each configured target field present.
|
||||
*/
|
||||
export function agentOptions(config: AcpConfig): { model?: string } {
|
||||
export function agentOptions(config: AcpConfig): { provider?: string; model?: string } {
|
||||
return {
|
||||
...config.provider !== undefined ? { provider: config.provider } : {},
|
||||
...config.model !== undefined ? { model: config.model } : {},
|
||||
}
|
||||
}
|
||||
@@ -922,7 +1057,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
|
||||
* zero or more times per event (best-effort UI feed, never load-bearing).
|
||||
* @param presenter - resolves tool-owned render intent for tool events;
|
||||
* defaults to the generic-fallback {@link nullToolPresenter}.
|
||||
* @param terminal - the connection's terminal-rendering context; defaults to
|
||||
* @param terminal - the session's terminal-rendering context; defaults to
|
||||
* disabled (the plain-text console-block fallback).
|
||||
* @param options - `includeUserMessages` (default `true`): live streaming
|
||||
* passes `false` so a prompt the client just sent is not echoed back.
|
||||
@@ -981,16 +1116,16 @@ export function streamSessionEventUpdate(
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a whole harness todo list to an ACP plan, assigning medium priority.
|
||||
* Statuses map directly and ACP replaces its whole plan on each update.
|
||||
* @param todos - the harness todo list (the whole list, not a diff).
|
||||
* @returns the ACP plan body, one entry per todo.
|
||||
* Map a whole harness todo list to an ACP replacement plan, using medium
|
||||
* priority because harness todos do not carry one.
|
||||
* @param todos - complete harness todo list.
|
||||
* @returns one ACP plan entry per todo.
|
||||
*/
|
||||
export function todosToPlan(todos: TodoItem[]): Plan {
|
||||
return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) }
|
||||
}
|
||||
|
||||
/** Terminal-card capability and workspace context for event rendering. */
|
||||
/** Per-session terminal capability and workspace used while translating updates. */
|
||||
export interface TerminalRendering {
|
||||
enabled: boolean
|
||||
/** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */
|
||||
@@ -1001,31 +1136,31 @@ export interface TerminalRendering {
|
||||
const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined }
|
||||
|
||||
/**
|
||||
* Resolve tool-owned call/result views with generic fallbacks. Per-session
|
||||
* call-id state supplies the tool name and arguments omitted from result events.
|
||||
* Each entry is consumed by its result; any remainder dies with the session.
|
||||
* Resolve tool-owned call/result views with a generic fallback. Per-session
|
||||
* state correlates results with call arguments; interrupted calls may retain an
|
||||
* entry only until that session's presenter is discarded.
|
||||
*/
|
||||
export class ToolPresenter {
|
||||
private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>()
|
||||
|
||||
/**
|
||||
* @param tools the registry to resolve tool definitions by name.
|
||||
* @param onError receives contained presenter failures before generic fallback.
|
||||
* @param tools - registry used to resolve executing definitions.
|
||||
* @param onError - contained presenter-error sink before generic fallback.
|
||||
* @param agent - optional scoped registry view for the executing agent.
|
||||
*/
|
||||
constructor(
|
||||
private readonly tools: Pick<ToolRegistry, 'get'>,
|
||||
private readonly onError: (message: string) => void = () => {},
|
||||
/** Agent scope for tool lookup; absent during replay without a live agent. */
|
||||
private readonly agent?: Agent,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve a pending call and remember its state for the matching result.
|
||||
* Pending-state render intent for a `tool/call`; remembers `(name, args, card)`
|
||||
* for the matching result.
|
||||
* @param callId - the call id the matching `tool/result` will look up.
|
||||
* @param name - the tool name, resolved against the registry for `presentCall`.
|
||||
* @param argsJson - the raw arguments JSON from the event; parsed for the view
|
||||
* (a non-JSON string is surfaced raw).
|
||||
* @returns the tool-owned view, or a generic parsed-input fallback.
|
||||
* @param argsJson - raw event arguments parsed for presentation.
|
||||
* @returns the tool-owned view or generic fallback.
|
||||
*/
|
||||
call(callId: CallId, name: string, argsJson: string): ToolCallView {
|
||||
const args = parseToolArguments(argsJson)
|
||||
@@ -1037,22 +1172,20 @@ export class ToolPresenter {
|
||||
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
|
||||
present = undefined
|
||||
}
|
||||
// No tool-owned presentation: fall back to the tool name as the title, the
|
||||
// full parsed args as the raw input, and kind `other` (the generic card).
|
||||
// The kind is never sniffed from the name — the bridge does not special-case
|
||||
// tool names; a tool that wants a richer kind declares `presentCall`.
|
||||
// Tool names never imply presentation kind; richer cards are tool-owned.
|
||||
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args }
|
||||
this.pending.set(callId, { name, args, card: view.card })
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a completed result and consume its remembered call state.
|
||||
* Completed-state render intent for a `tool/result`; consumes the remembered
|
||||
* `(name, args, card)`.
|
||||
* @param callId - matching call id; unknown or late ids use raw content.
|
||||
* @param content - the result's content blocks (the fallback and fill-in body).
|
||||
* @param content - result content used by the fallback and fill-in body.
|
||||
* @param isError - whether the result is an error, forwarded to `presentResult`.
|
||||
* @param meta - the result's machine-readable meta, forwarded when present.
|
||||
* @returns the normalized tool-owned view, or a raw-content generic fallback.
|
||||
* @returns a normalized tool-owned view or raw-content fallback.
|
||||
*/
|
||||
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
|
||||
const call = this.pending.get(callId)
|
||||
@@ -1122,11 +1255,11 @@ type AcpToolCallContent =
|
||||
| { type: 'diff'; path: string; oldText: string | null; newText: string }
|
||||
| { type: 'terminal'; terminalId: string }
|
||||
|
||||
/** Relativize an in-workspace file path in a card title; keep target paths raw. */
|
||||
/** Relativize only in-workspace title text; location and diff paths stay raw. */
|
||||
function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string {
|
||||
if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title
|
||||
const rel = relativePath(sessionCwd, rawPath)
|
||||
// Reject an empty relative path or a leading parent-directory segment.
|
||||
// Test the `..` segment, not a character prefix: `..cache/x` is in-workspace.
|
||||
if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title
|
||||
return title.split(rawPath).join(rel)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
|
||||
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* The bridge's `approval/request` answerer: an ask for an agent the bridge
|
||||
@@ -31,7 +33,7 @@ describe('acp bridge — approval answerer', () => {
|
||||
): Promise<{ agent: Agent; request: ApprovalRequest }> {
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = h.ctx.agents.get(AgentId(sessionId))
|
||||
const agent = h.ctx.agents.get(SessionId(sessionId))
|
||||
if (agent === undefined) throw new Error('newSession created no agent')
|
||||
// In production an ask always fires mid-turn (tool execution); open one so
|
||||
// request()'s turn-enclosure precondition holds for the direct drive below.
|
||||
@@ -88,9 +90,12 @@ describe('acp bridge — approval answerer', () => {
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
|
||||
|
||||
// Not created through the bridge: no bySession entry, so the answerer must
|
||||
// call next() — nobody else answers, so the seam fails closed.
|
||||
const foreign = { session: { events: [{ type: 'turn/start' }], append: () => ({}) } } as unknown as Agent
|
||||
const { agent } = await ownedAgentRequest(harness)
|
||||
// Even an impostor that claims the bridge-owned session id must delegate:
|
||||
// ownership requires the exact Agent object stored in the session record.
|
||||
const foreign = {
|
||||
session: { id: agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) },
|
||||
} as unknown as Agent
|
||||
await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'echo', callId: CallId('c') }))
|
||||
.resolves.toBe('unavailable')
|
||||
expect(harness.permissionRequests).toHaveLength(0)
|
||||
|
||||
@@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* End-to-end bridge specs over an in-memory transport: a real
|
||||
@@ -98,7 +98,7 @@ describe('acp bridge', () => {
|
||||
required: [],
|
||||
},
|
||||
})
|
||||
const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result')
|
||||
const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result')
|
||||
const toolResultBlock = toolResult?.type === 'tool/result' ? toolResult.data.content[0] : undefined
|
||||
const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined
|
||||
expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}')
|
||||
@@ -127,7 +127,7 @@ describe('acp bridge', () => {
|
||||
required: ['custom'],
|
||||
},
|
||||
})
|
||||
const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result')
|
||||
const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result')
|
||||
expect(JSON.stringify(toolResult)).toContain('apollo')
|
||||
})
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('acp bridge', () => {
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
const result = await harness.ctx.userInteraction.ask({
|
||||
agent,
|
||||
@@ -167,7 +167,7 @@ describe('acp bridge', () => {
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
await expect(harness.ctx.userInteraction.ask({
|
||||
agent,
|
||||
@@ -184,7 +184,7 @@ describe('acp bridge', () => {
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
await expect(harness.ctx.userInteraction.ask({
|
||||
agent,
|
||||
@@ -201,11 +201,12 @@ describe('acp bridge', () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] }))
|
||||
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' })
|
||||
await expect(harness.ctx.userInteraction.ask({ agent: { id: 'other' } as typeof agent, questions: [{ id: 'x', question: 'No session?' }] }))
|
||||
const impostor = { session: { id: agent.session.id } } as typeof agent
|
||||
await expect(harness.ctx.userInteraction.ask({ agent: impostor, questions: [{ id: 'x', question: 'No session?' }] }))
|
||||
.rejects.toMatchObject({ code: 'NO_SESSION' })
|
||||
|
||||
harness.onElicitation = () => ({ action: 'cancel' })
|
||||
@@ -225,7 +226,7 @@ describe('acp bridge', () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
const alreadyAborted = new AbortController()
|
||||
alreadyAborted.abort()
|
||||
@@ -265,8 +266,8 @@ describe('acp bridge', () => {
|
||||
expect(b.sessionId).toBeTruthy()
|
||||
expect(a.sessionId).not.toBe(b.sessionId)
|
||||
// Both agents are live and independently registered.
|
||||
expect(harness.ctx.agents.get(AgentId(a.sessionId))).toBeDefined()
|
||||
expect(harness.ctx.agents.get(AgentId(b.sessionId))).toBeDefined()
|
||||
expect(harness.ctx.agents.get(SessionId(a.sessionId))).toBeDefined()
|
||||
expect(harness.ctx.agents.get(SessionId(b.sessionId))).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => {
|
||||
@@ -281,7 +282,7 @@ describe('acp bridge', () => {
|
||||
const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] })
|
||||
expect(res.sessionId).toBeTruthy()
|
||||
// The session header records that cwd, so its bash tools run there.
|
||||
expect(harness.ctx.agents.get(AgentId(res.sessionId))!.session.header.cwd).toBe('/tmp')
|
||||
expect(harness.ctx.agents.get(SessionId(res.sessionId))!.session.header.cwd).toBe('/tmp')
|
||||
})
|
||||
|
||||
it('rejects non-empty additionalDirectories', async () => {
|
||||
@@ -321,7 +322,7 @@ describe('acp bridge', () => {
|
||||
],
|
||||
})
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
const user = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'user/message')
|
||||
const user = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'user/message')
|
||||
expect(JSON.stringify(user)).toContain('resource_link')
|
||||
})
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ function permissionOption(currentValue: string): object {
|
||||
return {
|
||||
id: 'permission',
|
||||
name: 'Permissions',
|
||||
description: 'Sets this session\'s sandbox and approval behavior.',
|
||||
description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
@@ -40,6 +40,26 @@ function permissionOption(currentValue: string): object {
|
||||
}
|
||||
}
|
||||
|
||||
function modelValue(provider = 'mock', model = 'mock'): string {
|
||||
return JSON.stringify([provider, model])
|
||||
}
|
||||
|
||||
function modelOption(currentValue = modelValue()): object {
|
||||
return {
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'Sets this session\'s provider and model.',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [{ value: modelValue(), name: 'Mock' }],
|
||||
}
|
||||
}
|
||||
|
||||
function optionsWithPermission(currentValue: string): object[] {
|
||||
return [modelOption(), permissionOption(currentValue)]
|
||||
}
|
||||
|
||||
describe('acp bridge — session config options', () => {
|
||||
let storageDir: string
|
||||
let h: BridgeHarness | undefined
|
||||
@@ -64,19 +84,111 @@ describe('acp bridge — session config options', () => {
|
||||
return harness
|
||||
}
|
||||
|
||||
it('advertises no configOptions without the permission service — even with both knobs composed', async () => {
|
||||
it('advertises the model selector without requiring the permission service', async () => {
|
||||
h = await makeBridgeHarness({ storageDir })
|
||||
await h.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toBeUndefined()
|
||||
expect(res.configOptions).toEqual([modelOption()])
|
||||
})
|
||||
|
||||
it('groups models by provider and switches routing plus prompt variables as one session target', async () => {
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('ok')],
|
||||
config: { provider: 'alpha', model: 'a1' },
|
||||
persona: 'Route {{provider}} / {{model}}',
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }],
|
||||
models: [
|
||||
{ provider: 'alpha', id: 'a1', name: 'Alpha One', description: 'Fast' },
|
||||
{ provider: 'beta', id: 'b1', name: 'Beta One' },
|
||||
],
|
||||
},
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const created = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(created.configOptions).toEqual([{
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'Sets this session\'s provider and model.',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue: modelValue('alpha', 'a1'),
|
||||
options: [
|
||||
{ group: 'alpha', name: 'Alpha', options: [{ value: modelValue('alpha', 'a1'), name: 'Alpha One', description: 'Fast' }] },
|
||||
{ group: 'beta', name: 'Beta', options: [{ value: modelValue('beta', 'b1'), name: 'Beta One' }] },
|
||||
],
|
||||
}])
|
||||
|
||||
const switched = await h.client.setSessionConfigOption({
|
||||
sessionId: created.sessionId,
|
||||
configId: 'model',
|
||||
value: modelValue('beta', 'b1'),
|
||||
})
|
||||
expect(switched.configOptions?.[0]).toMatchObject({ currentValue: modelValue('beta', 'b1') })
|
||||
await h.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use beta' }] })
|
||||
expect(h.adapter.requests[0]).toMatchObject({
|
||||
provider: 'beta',
|
||||
model: 'b1',
|
||||
})
|
||||
expect(h.adapter.requests[0]?.system).toContain('Route beta / b1')
|
||||
expect(h.ctx.agents.list()[0]?.session.requestHeader()?.config).toMatchObject({ provider: 'beta', model: 'b1' })
|
||||
})
|
||||
|
||||
it('adds the configured private model to an advisory catalog and ignores empty non-current groups', async () => {
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
config: { provider: 'alpha', model: 'private-model' },
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'empty', name: 'Empty' }],
|
||||
models: [{ provider: 'alpha', id: 'public-model', name: 'Public Model' }],
|
||||
},
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions?.[0]).toMatchObject({
|
||||
currentValue: modelValue('alpha', 'private-model'),
|
||||
options: [
|
||||
{ value: modelValue('alpha', 'public-model'), name: 'Public Model' },
|
||||
{ value: modelValue('alpha', 'private-model'), name: 'private-model' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits model selection without a complete or registered current target', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, config: { model: undefined } })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const missing = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(missing.configOptions).toBeUndefined()
|
||||
await h.dispose()
|
||||
|
||||
h = await makeBridgeHarness({ storageDir, config: { provider: 'unregistered', model: 'm' } })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const unknown = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(unknown.configOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves model-less agents available to another agent/request supplier', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, config: { model: undefined }, script: [textResponse('ok')] })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.ctx.on('agent/request', async (_agent, _turn, _step, callConfig, _next) => ({
|
||||
...callConfig,
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
}))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'supplied elsewhere' }] })
|
||||
expect(h.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' })
|
||||
})
|
||||
|
||||
it('advertises the Permissions select with the default preset current', async () => {
|
||||
h = await presetStack()
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
expect(res.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
})
|
||||
|
||||
it('an idle switch is pending (overlaid, not yet logged), then anchors inside the next prompt\'s turn', async () => {
|
||||
@@ -84,7 +196,7 @@ describe('acp bridge — session config options', () => {
|
||||
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')])
|
||||
expect(after.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
|
||||
const session = h.ctx.agents.list()[0]?.session
|
||||
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'sandbox/mode' || e.type === 'approval/policy')).toBe(false)
|
||||
@@ -105,7 +217,7 @@ describe('acp bridge — session config options', () => {
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(again.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(again.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
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)
|
||||
@@ -119,7 +231,7 @@ describe('acp bridge — session config options', () => {
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(back.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
expect(back.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.some(e => e.type === 'permission/preset' || e.type === 'sandbox/mode' || e.type === 'approval/policy')).toBe(false)
|
||||
@@ -129,10 +241,10 @@ describe('acp bridge — session config options', () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(echo.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
expect(echo.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(repeat.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(repeat.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
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').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
|
||||
@@ -167,6 +279,8 @@ describe('acp bridge — session config options', () => {
|
||||
// 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: 'model', value: modelValue('mock', 'missing') }))
|
||||
.rejects.toThrow(/unknown model value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', type: 'boolean', value: true }))
|
||||
.rejects.toThrow(/select; boolean values are not accepted/)
|
||||
})
|
||||
@@ -184,9 +298,31 @@ describe('acp bridge — session config options', () => {
|
||||
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(bAfter.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
expect(bAfter.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(aAfter.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(aAfter.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
})
|
||||
|
||||
it('keeps model targets isolated across concurrent sessions', async () => {
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('a'), textResponse('b')],
|
||||
config: { provider: 'mock', model: 'one' },
|
||||
catalog: {
|
||||
providers: [{ id: 'mock', name: 'Mock' }],
|
||||
models: [
|
||||
{ provider: 'mock', id: 'one', name: 'One' },
|
||||
{ provider: 'mock', id: 'two', name: 'Two' },
|
||||
],
|
||||
},
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
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: 'model', value: modelValue('mock', 'two') })
|
||||
await h.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: 'a' }] })
|
||||
await h.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: 'b' }] })
|
||||
expect(h.adapter.requests.map(request => request.model)).toEqual(['two', 'one'])
|
||||
})
|
||||
|
||||
it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => {
|
||||
@@ -199,12 +335,12 @@ describe('acp bridge — session config options', () => {
|
||||
agent.session.append('sandbox/mode', { mode: 'read-only' })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })
|
||||
const option = echo.configOptions?.[0]
|
||||
const option = echo.configOptions?.find(entry => entry.id === 'permission')
|
||||
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'])
|
||||
const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const afterOption = away.configOptions?.[0]
|
||||
const afterOption = away.configOptions?.find(entry => entry.id === 'permission')
|
||||
expect(afterOption).toMatchObject({ currentValue: 'danger-full-access' })
|
||||
if (afterOption === undefined || !('options' in afterOption)) throw new Error('expected a select option')
|
||||
expect(afterOption.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access'])
|
||||
@@ -223,6 +359,52 @@ describe('acp bridge — session config options', () => {
|
||||
|
||||
loader = await presetStack()
|
||||
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(res.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
})
|
||||
|
||||
it('session/load restores the last requested provider/model from the request header', async () => {
|
||||
const catalog = {
|
||||
providers: [{ id: 'mock', name: 'Mock' }],
|
||||
models: [
|
||||
{ provider: 'mock', id: 'one', name: 'One' },
|
||||
{ provider: 'mock', id: 'two', name: 'Two' },
|
||||
],
|
||||
}
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('ok')],
|
||||
config: { provider: 'mock', model: 'one' },
|
||||
catalog,
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'model', value: modelValue('mock', 'two') })
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist target' }] })
|
||||
await h.dispose()
|
||||
h = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, config: { provider: 'mock', model: 'one' }, catalog })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const loaded = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(loaded.configOptions?.find(option => option.id === 'model')).toMatchObject({
|
||||
currentValue: modelValue('mock', 'two'),
|
||||
})
|
||||
})
|
||||
|
||||
it('session/load omits config options when the persisted session has no target or permission service', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, config: { model: undefined } })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.inject([{ type: 'text', text: 'checkpoint' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
await agent.whenIdle()
|
||||
await h.dispose()
|
||||
h = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, config: { model: undefined } })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const loaded = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(loaded.configOptions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { makeBridgeHarness, textResponse } from './harness.ts'
|
||||
|
||||
describe('acp bridge — disposal & HMR safety', () => {
|
||||
@@ -17,25 +16,29 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
// Start a prompt that hangs in the model stream.
|
||||
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
// Teardown must abort and await the loop: once it resolves the agent is settled, and the
|
||||
// hanging prompt itself completes as cancelled rather than remaining pending.
|
||||
// Dispose the whole context. The bridge's teardown must abort the agent and
|
||||
// AWAIT whenIdle() — so right after dispose resolves, the agent is settled
|
||||
// (not still running). Proves disposal waited, not just requested.
|
||||
await harness.ctx.fiber.dispose()
|
||||
expect(agent.status).not.toBe('running')
|
||||
|
||||
// The in-flight prompt settled (cancelled) rather than hanging forever.
|
||||
const res = await promptDone
|
||||
expect(res.stopReason).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => {
|
||||
// Unload only the bridge while transport and shared services remain live. Its closed guard must
|
||||
// reject late creation before an orphan agent can enter the registry.
|
||||
// Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop
|
||||
// stay up and the transport is still live. A late session/new must hit the
|
||||
// `closed` guard and reject — NOT create an agent the disposed bridge can no
|
||||
// longer stream or settle. Verify the world: no agent appeared.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const before = harness.ctx.agents.list().length
|
||||
@@ -47,21 +50,29 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => {
|
||||
// The traced service proxy binds loop registration to the caller (bridge) fiber. ACP-only
|
||||
// disposal must therefore reclaim the agent even while agent-loop itself remains mounted.
|
||||
// The factory (`ctx.agents.create`) is reached through the bridge's
|
||||
// traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)`
|
||||
// registration binds to the CALLER context — the bridge fiber — not the
|
||||
// AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload)
|
||||
// must therefore reclaim the agent's registry entry, even though agents/
|
||||
// agent-loop stay up. This pins the fiber-ownership the bridge's teardown
|
||||
// doc comment relies on; if a refactor rebinds the registration to the
|
||||
// AgentLoop fiber, the agent would survive bridge dispose and this fails.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeDefined()
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeDefined()
|
||||
|
||||
await harness.acpFiber.dispose() // tear down ONLY the bridge
|
||||
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => {
|
||||
// Disconnect sets the closed guard and severs the RPC, so registry state—not the rejection
|
||||
// shape—proves a late request did not create an undriveable agent.
|
||||
// After teardown (here a client disconnect sets `closed`), a late
|
||||
// `session/new` must NOT create an orphan agent the bridge can no longer
|
||||
// drive/settle. The transport is gone so the RPC rejects; assert the world:
|
||||
// no new agent appeared in the registry.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const before = harness.ctx.agents.list().length
|
||||
@@ -73,43 +84,59 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => {
|
||||
// Disconnect mid-stream must dispose, not merely idle, the owned agent; otherwise updates would
|
||||
// be swallowed while a registered session survived without a client.
|
||||
// The ACP transport closes (editor quits) while a turn runs. The bridge must
|
||||
// settle the in-flight prompt cancelled and DISPOSE the agent (the session's
|
||||
// per-agent AgentHandle teardown) rather than leaving an orphaned running —
|
||||
// or even idled-but-still-registered — agent whose updates are swallowed.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
// The transport will close before this hanging RPC settles.
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
// Start a prompt that hangs in the model stream. The prompt RPC will never
|
||||
// return (its transport is severed), so do not await it.
|
||||
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
// Sever the transport — the bridge's conn.closed teardown runs and drives the
|
||||
// agent's AgentHandle dispose to quiescence on its OWN (before any dispose()).
|
||||
await harness.closeClientTransport()
|
||||
await agent.whenIdle()
|
||||
// The agent's loop has stopped: status `disposed`.
|
||||
expect(agent.status).toBe('disposed')
|
||||
|
||||
// Await the same memoized bridge teardown without removing root services. It must finish the
|
||||
// AgentHandle teardown and remove both registry records, not just stop the loop.
|
||||
// Await the bridge teardown to completion WITHOUT tearing down the root
|
||||
// agents/sessions services (so we can still query them). acpFiber.dispose()
|
||||
// invokes the SAME memoized quiesce() the disconnect started and awaits its
|
||||
// promise — which resolves only after every rec.dispose() (loop exit +
|
||||
// session removal) has finished, closing the whenIdle()/owned.dispose()
|
||||
// microtask race. The AgentHandle dispose has run: the agent is unregistered
|
||||
// and its session removed from the store, not merely idled (the old
|
||||
// behavior). The services live on the root ctx, so they survive this.
|
||||
await harness.acpFiber.dispose()
|
||||
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => {
|
||||
// Transport close and fiber disposal can race. Both must await one memoized teardown; a guard
|
||||
// based only on record removal could let the second caller return while the first still drains.
|
||||
// conn.closed teardown and ctx.fiber.dispose() can fire near-simultaneously.
|
||||
// They must share one teardown promise: dispose() must NOT return before the
|
||||
// disconnect teardown's whenIdle() has settled (a `record === undefined`-only
|
||||
// guard would let the second caller return early mid-teardown).
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
// Fire both teardown paths without awaiting the first, then await both.
|
||||
const close = harness.closeClientTransport()
|
||||
const dispose = harness.ctx.fiber.dispose()
|
||||
await Promise.all([close, dispose])
|
||||
// After BOTH settle, the agent has fully drained (not still running).
|
||||
expect(agent.status).not.toBe('running')
|
||||
})
|
||||
|
||||
@@ -117,7 +144,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const session = harness.ctx.agents.get(AgentId(sessionId))!.session
|
||||
const session = harness.ctx.agents.get(SessionId(sessionId))!.session
|
||||
|
||||
await harness.ctx.fiber.dispose()
|
||||
const before = harness.updates.length
|
||||
@@ -129,18 +156,27 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => {
|
||||
// AgentHandle teardown stops and awaits the loop, flushes through still-attached store hooks,
|
||||
// then detaches the session. Reloading verifies that order from durable state.
|
||||
// The teardown-ORDER guarantee: a per-agent dispose must stop the loop,
|
||||
// AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire
|
||||
// through the still-attached store observer → `session/event`), and only
|
||||
// THEN remove its publication hooks and session entry. If the order were inverted
|
||||
// (detach first), the closing events would never reach persistence. Drive a
|
||||
// CLEAN turn to completion, dispose JUST the bridge, then re-load the
|
||||
// persisted log from disk and assert the closing turn/end is on disk — the
|
||||
// world, not the agent's self-report.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
const liveEvents = harness.ctx.agents.get(AgentId(sessionId))!.session.events.length
|
||||
const liveEvents = harness.ctx.agents.get(SessionId(sessionId))!.session.events.length
|
||||
expect(liveEvents).toBeGreaterThan(0)
|
||||
|
||||
// Tear down JUST the bridge (the AgentHandle dispose runs to quiescence).
|
||||
await harness.acpFiber.dispose()
|
||||
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
|
||||
// Re-load the session from disk: every live event (incl. the closing
|
||||
// turn/end) was flushed before the session was detached.
|
||||
const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId))
|
||||
expect(reloaded.events.length).toBe(liveEvents)
|
||||
const last = reloaded.events.at(-1)!
|
||||
@@ -149,20 +185,35 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => {
|
||||
// Here disposal itself makes the loop append `turn/end {disposed}` and flush. Reload must find
|
||||
// that real closer, not crash recovery's synthetic `interrupted`, proving detach ran last.
|
||||
// The teardown-order contract only earns its keep when the closing events are
|
||||
// produced BY the dispose itself. Here the model stream HANGS, so the turn is
|
||||
// still open when teardown runs: the composite agent effect stops the loop,
|
||||
// the loop unwinds and appends `turn/end {disposed}` + runs its final
|
||||
// `session/flush` — all while the store-owned publication hooks are still attached (the session
|
||||
// detach is the LAST disposer in the same effect's LIFO chain) — and only
|
||||
// THEN is the session detached. If the order were inverted (or the session
|
||||
// were a racing SIBLING effect), the abort-produced `turn/end` would never
|
||||
// reach disk and a re-load would instead show crash-recovery's synthetic
|
||||
// `interrupted` closer. Re-load from disk and assert the REAL `disposed`
|
||||
// reason landed — proving the loop's own closing event was captured, not a
|
||||
// recovered substitute.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
// The turn is OPEN in the log (turn/start appended, no turn/end yet).
|
||||
const openTurnEnds = agent.session.events.filter(e => e.type === 'turn/end').length
|
||||
|
||||
// Dispose JUST the bridge: a fiber unload that must STILL honor the ordered
|
||||
// teardown (the composite effect runs its disposer chain as a unit).
|
||||
await harness.acpFiber.dispose()
|
||||
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
|
||||
// The loop's own `turn/end {disposed}` is on disk (re-load: the world, not
|
||||
// self-report) — NOT a crash-recovery `interrupted` substitute.
|
||||
const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId))
|
||||
const persistedTurnEnds = reloaded.events.filter(e => e.type === 'turn/end')
|
||||
expect(persistedTurnEnds.length).toBe(openTurnEnds + 1)
|
||||
@@ -171,55 +222,71 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('per-session AgentHandle dispose leaves sibling agents untouched', async () => {
|
||||
// A per-session handle owns exactly one agent and session. Dispose A and assert B remains fully
|
||||
// published, which guards against context-wide teardown.
|
||||
// The factory returns a per-agent AgentHandle whose dispose() tears down
|
||||
// EXACTLY that agent + its session — RFC 011 isolation. Create two agents
|
||||
// directly through the registry factory (the same path the ACP bridge uses),
|
||||
// dispose one handle, and assert the other survives, registered and
|
||||
// queryable, with its session still in the store.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
const handleA = await harness.ctx.agents.create({
|
||||
agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
|
||||
sessionId: SessionId('sib-a'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const handleB = await harness.ctx.agents.create({
|
||||
agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' },
|
||||
sessionId: SessionId('sib-b'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent)
|
||||
expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent)
|
||||
expect(harness.ctx.agents.get(SessionId('sib-a'))).toBe(handleA.agent)
|
||||
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
|
||||
|
||||
await handleA.dispose()
|
||||
expect(harness.ctx.agents.get(AgentId('sib-a'))).toBeUndefined()
|
||||
// A is gone — unregistered AND its session removed from the store.
|
||||
expect(harness.ctx.agents.get(SessionId('sib-a'))).toBeUndefined()
|
||||
expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined()
|
||||
expect(handleA.agent.status).toBe('disposed')
|
||||
expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent)
|
||||
// B is wholly unaffected.
|
||||
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
|
||||
expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined()
|
||||
expect(handleB.agent.status).not.toBe('disposed')
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => {
|
||||
// Composite disposers run in sequence. A throwing `agent/disposed` listener must be contained or
|
||||
// it would skip later session detach, leaking publication hooks and creating a durability hole.
|
||||
// The AgentHandle teardown folds session-detach, register, and loop-stop
|
||||
// into ONE composite effect whose disposers run as a `.then()` chain. The
|
||||
// register disposer emits `agent/disposed`; if a listener throws and the
|
||||
// emit is UNCONTAINED, the rejected chain skips the LATER session-detach
|
||||
// disposer — stranding the session in the store with its publication hooks attached (a
|
||||
// leak AND a durability hole, since the new design relies on detach
|
||||
// running). The emit must be contained. Register a throwing listener, drive
|
||||
// a clean turn, dispose, and assert the session was STILL removed.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
|
||||
const handle = await harness.ctx.agents.create({
|
||||
agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' },
|
||||
sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await handle.agent.whenIdle()
|
||||
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined()
|
||||
|
||||
// Dispose: the throwing listener must NOT break the chain before detach.
|
||||
await handle.dispose()
|
||||
expect(harness.ctx.agents.get(AgentId('guard-a'))).toBeUndefined()
|
||||
expect(harness.ctx.agents.get(SessionId('guard-a'))).toBeUndefined()
|
||||
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => {
|
||||
// The Cordis effect disposer is single-shot and would let a second call return after its epoch
|
||||
// clears. AgentHandle must memoize the whole async teardown so every caller awaits quiescence.
|
||||
// The handle's dispose() must memoize: the underlying cordis effect disposer
|
||||
// is single-shot, so a second dispose() while the first is mid-teardown would
|
||||
// otherwise resolve IMMEDIATELY (effect epoch already cleared) — before the
|
||||
// first call's await agent.done + final flush finished. Every caller must
|
||||
// observe the same quiescence boundary.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
const handle = await harness.ctx.agents.create({
|
||||
agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },
|
||||
sessionId: SessionId('conc-a'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
// A hanging turn makes disposal produce a final flush; gate it so the second call arrives while
|
||||
// teardown is observably in flight.
|
||||
// Drive a turn that hangs in the model stream, so the loop is mid-turn when
|
||||
// disposed — its exit runs a final session/flush we can gate to hold the
|
||||
// teardown observably in-flight.
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(handle.agent.status).toBe('running')
|
||||
@@ -227,21 +294,25 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
const flushGate = new Promise<void>((resolve) => { releaseFlush = resolve })
|
||||
harness.ctx.on('session/flush', () => flushGate)
|
||||
|
||||
// First dispose enters teardown (aborts the hanging step) and blocks in the
|
||||
// gated final flush.
|
||||
const first = handle.dispose()
|
||||
let firstSettled = false
|
||||
void first.then(() => { firstSettled = true })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(firstSettled).toBe(false)
|
||||
|
||||
// Second dispose MUST await the same in-flight teardown, not resolve early.
|
||||
const second = handle.dispose()
|
||||
let secondSettled = false
|
||||
void second.then(() => { secondSettled = true })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(secondSettled).toBe(false) // memoized: still pending with the first
|
||||
|
||||
// Release the flush; both resolve together and the session is gone.
|
||||
releaseFlush()
|
||||
await Promise.all([first, second])
|
||||
expect(harness.ctx.agents.get(AgentId('conc-a'))).toBeUndefined()
|
||||
expect(harness.ctx.agents.get(SessionId('conc-a'))).toBeUndefined()
|
||||
expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined()
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
@@ -27,7 +26,7 @@ describe('acp bridge — demux & config edges', () => {
|
||||
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const before = harness.updates.length
|
||||
|
||||
const { agent: foreign } = await harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } })
|
||||
const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
foreign.send([{ type: 'text', text: 'hi' }])
|
||||
await foreign.whenIdle()
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
@@ -5,13 +5,10 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
@@ -39,10 +36,24 @@ import { type AcpConfig } from '../src/index.ts'
|
||||
/** A scripted mock adapter (mirrors the agent-loop test adapter). */
|
||||
class MockAdapter extends LlmAdapter {
|
||||
requests: GenerateOptions[] = []
|
||||
constructor(private script: (StreamChunk[] | 'hang')[]) {
|
||||
constructor(
|
||||
private script: (StreamChunk[] | 'hang')[],
|
||||
private readonly providers: readonly LlmProviderInfo[],
|
||||
private readonly models: readonly LlmModelInfo[],
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
const info = this.providers.find(entry => entry.id === provider)
|
||||
if (info === undefined) throw new Error(`MockAdapter: unknown provider ${provider}`)
|
||||
return info
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve(this.models.filter(model => model.provider === provider))
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
@@ -142,6 +153,9 @@ export interface BridgeHarness {
|
||||
storageDir: string
|
||||
}
|
||||
|
||||
/** Test-only overrides preserve explicit undefined to suppress harness defaults. */
|
||||
type AcpConfigOverrides = { [K in keyof AcpConfig]?: AcpConfig[K] | undefined }
|
||||
|
||||
/**
|
||||
* Build the bridge + a connected client over an in-memory transport pair.
|
||||
*
|
||||
@@ -150,12 +164,13 @@ export interface BridgeHarness {
|
||||
* The bridge's `apply` receives the agent-side `Stream` via `config.stream`;
|
||||
* the test holds the `ClientSideConnection`.
|
||||
*
|
||||
* Pass `config: { model: undefined }` to override the default `model: 'mock'`
|
||||
* (the model key is dropped entirely when explicitly undefined).
|
||||
* Pass an explicit undefined route field to suppress its mock default.
|
||||
*/
|
||||
export async function makeBridgeHarness(options: {
|
||||
script?: (StreamChunk[] | 'hang')[]
|
||||
config?: Partial<AcpConfig>
|
||||
config?: AcpConfigOverrides
|
||||
/** Provider-neutral directory exposed to ACP model-selection tests. */
|
||||
catalog?: { providers: LlmProviderInfo[]; models: LlmModelInfo[] }
|
||||
/** Deployment persona for the tree (the system-prompt plugin's config). */
|
||||
persona?: string
|
||||
storageDir: string
|
||||
@@ -185,14 +200,16 @@ export async function makeBridgeHarness(options: {
|
||||
withFs?: boolean
|
||||
fsCwd?: string
|
||||
} = { storageDir: '' }): Promise<BridgeHarness> {
|
||||
const adapter = new MockAdapter(options.script ?? [])
|
||||
const catalog = options.catalog ?? {
|
||||
providers: [{ id: 'mock', name: 'Mock' }],
|
||||
models: [{ provider: 'mock', id: 'mock', name: 'Mock' }],
|
||||
}
|
||||
const adapter = new MockAdapter(options.script ?? [], catalog.providers, catalog.models)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: options.persona ?? '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await mountAgentLoopTestDependencies(ctx, {
|
||||
systemPrompt: { persona: options.persona ?? '' },
|
||||
})
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
|
||||
await ctx.plugin(UserInteractionService)
|
||||
@@ -211,7 +228,7 @@ export async function makeBridgeHarness(options: {
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
}
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.llm.registerAdapter(catalog.providers.map(provider => provider.id), 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
|
||||
@@ -271,9 +288,9 @@ export async function makeBridgeHarness(options: {
|
||||
},
|
||||
})
|
||||
|
||||
// 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 }
|
||||
// Default route fields only when the caller omitted them; explicit undefined values must survive.
|
||||
const cfg = { stream: agentStream, ...options.config } as AcpConfig
|
||||
if (!(options.config && 'provider' in options.config)) cfg.provider = 'mock'
|
||||
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)` on the ungated root. Later JSON-RPC callbacks run
|
||||
|
||||
@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
|
||||
/** Concatenate the text of all agent_message_chunk updates. */
|
||||
@@ -185,7 +184,7 @@ describe('acp bridge — session/load replay', () => {
|
||||
release() // resume() finishes AFTER teardown
|
||||
expect(await loadResult).toBe('rejected')
|
||||
// No live agent was installed for the closed connection.
|
||||
expect(loader.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
|
||||
expect(loader.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects load when the requested cwd does not match the persisted session cwd', async () => {
|
||||
@@ -205,11 +204,11 @@ describe('acp bridge — session/load replay', () => {
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/cwd mismatch/)
|
||||
expect(loader.ctx.agents.get(AgentId('elsewhere'))).toBeUndefined()
|
||||
expect(loader.ctx.agents.get(SessionId('elsewhere'))).toBeUndefined()
|
||||
|
||||
const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] })
|
||||
expect(res).toBeDefined()
|
||||
expect(loader.ctx.agents.get(AgentId('elsewhere'))!.session.header.cwd).toBe(otherCwd)
|
||||
expect(loader.ctx.agents.get(SessionId('elsewhere'))!.session.header.cwd).toBe(otherCwd)
|
||||
})
|
||||
|
||||
it('rejects load for a non-absolute cwd (still required to be absolute)', async () => {
|
||||
@@ -243,7 +242,7 @@ describe('acp bridge — session/load replay', () => {
|
||||
// Rejected BEFORE resume (metadata-only check) — no agent was registered, so
|
||||
// the id is not wedged: a later attempt hits the same clean rejection, not a
|
||||
// duplicate-registration error.
|
||||
expect(loader.ctx.agents.get(AgentId('legacy'))).toBeUndefined()
|
||||
expect(loader.ctx.agents.get(SessionId('legacy'))).toBeUndefined()
|
||||
await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/no absolute persisted cwd/)
|
||||
})
|
||||
|
||||
@@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Text of the agent_message_chunk updates scoped to one session id. */
|
||||
function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[], sessionId: string): string {
|
||||
@@ -102,8 +102,8 @@ describe('acp bridge — RFC 011 multi-session isolation', () => {
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
const agentA = harness.ctx.agents.get(AgentId(a))!
|
||||
const agentB = harness.ctx.agents.get(AgentId(b))!
|
||||
const agentA = harness.ctx.agents.get(SessionId(a))!
|
||||
const agentB = harness.ctx.agents.get(SessionId(b))!
|
||||
|
||||
// Wait deterministically for BOTH agents to enter `running` (not a fixed
|
||||
// sleep — agent startup latency is unbounded on a loaded worker).
|
||||
|
||||
@@ -794,5 +794,6 @@ describe('agentOptions', () => {
|
||||
it('includes only the fields present in config', () => {
|
||||
expect(agentOptions({})).toEqual({})
|
||||
expect(agentOptions({ model: 'm' })).toEqual({ model: 'm' })
|
||||
expect(agentOptions({ provider: 'p', model: 'm' })).toEqual({ provider: 'p', model: 'm' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import {
|
||||
errorResponse,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
toolCallResponse,
|
||||
type BridgeHarness,
|
||||
} from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Boilerplate: initialize + create one session, returning its id. */
|
||||
async function newSession(h: BridgeHarness, clientCapabilities: Record<string, unknown> = {}): Promise<string> {
|
||||
@@ -274,7 +274,7 @@ describe('acp bridge — turn outcomes', () => {
|
||||
// OWN turn with the real model answer.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
// On the queued prompt, synchronously inject a one-shot context turn (idle
|
||||
// inject writes turn/start{injection} → context/message → turn/end). Fire
|
||||
// once so it lands between install and the prompt turn.
|
||||
@@ -328,7 +328,7 @@ describe('acp bridge — turn outcomes', () => {
|
||||
await harness.client.cancel({ sessionId })
|
||||
const res = await promptDone
|
||||
expect(res.stopReason).toBe('cancelled')
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
await agent.whenIdle()
|
||||
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start').length
|
||||
expect(turnStarts).toBeLessThanOrEqual(1)
|
||||
|
||||
Reference in New Issue
Block a user