diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index c6b3e89b6e..06dfdc1497 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -117,6 +117,7 @@ flowchart LR
svc_fs --> pkg_tool_fs
svc_llm --> pkg_agent_loop
svc_llm --> pkg_compact_basic
+ svc_modes --> pkg_acp
svc_modes --> pkg_stdio_agent
svc_sessionPersistence --> pkg_acp
svc_sessionPersistence --> pkg_agent_loop
@@ -154,7 +155,7 @@ flowchart LR
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. |
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
-| `ctx.modes` | `core` | [`mode`](../packages/mode/mode) | - | [`stdio-agent`](../packages/ui/stdio-agent) | - | Folds the logged per-agent mode (mode/set), flushes user flips at turn boundaries, and enforces the mode through the assemble filter and the tools/pre-execute gate. |
+| `ctx.modes` | `core` | [`mode`](../packages/mode/mode) | - | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | Folds the logged per-agent mode (mode/set), flushes user flips at turn boundaries, and enforces the mode through the assemble filter and the tools/pre-execute gate. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index ff68238cd7..7faf0de009 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -31,7 +31,7 @@ export interface AcpConfig {
Depends on: `Stream` (`@agentclientprotocol/sdk`)
-Source: [`packages/ui/acp/src/index.ts:236`](../packages/ui/acp/src/index.ts)
+Source: [`packages/ui/acp/src/index.ts:242`](../packages/ui/acp/src/index.ts)
## `@deepseek-ai/dsh-acp-agent`
@@ -425,7 +425,7 @@ export interface ModeDefinition {
}
```
-Source: [`packages/mode/mode/src/index.ts:94`](../packages/mode/mode/src/index.ts)
+Source: [`packages/mode/mode/src/index.ts:96`](../packages/mode/mode/src/index.ts)
## `@deepseek-ai/dsh-repeat-tool-guard`
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 7d74b3826f..d269ab133c 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -153,13 +153,13 @@ Source: [`packages/llm/llm/src/index.ts:88`](../../packages/llm/llm/src/index.ts
```ts cordis-catalog
list(): string[]
-get(agent: Agent): { current: string, pending?: string }
+get(agent: Agent): { current: string; pending?: string }
set(agent: Agent, mode: string): void
```
Types: [Agent](../core-data-structures/core.md)
-Source: [`packages/mode/mode/src/index.ts:179`](../../packages/mode/mode/src/index.ts)
+Source: [`packages/mode/mode/src/index.ts:202`](../../packages/mode/mode/src/index.ts)
## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam)
diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md
index 60de01ef81..0c55e0a5f1 100644
--- a/docs/graph-atlas.md
+++ b/docs/graph-atlas.md
@@ -16,6 +16,7 @@ The process decision behind this index is recorded in [the documentation graph R
| [coding-agent app composition](../examples/coding-agent/composition.md) | `hybrid generated` |
| [cordis-agent app composition](../examples/cordis-agent/composition.md) | `hybrid generated` |
| [acp-agent app composition](../examples/acp-agent/composition.md) | `hybrid generated` |
+| [../examples/plan-acp-agent/composition.md](../examples/plan-acp-agent/composition.md) | `generated` |
| [event producer/consumer matrix](event-producer-consumer.md) | `hybrid generated` |
| [agent turn and step lifecycle](agent-lifecycle.md) | `curated` |
| [tool execution pipeline](tool-execution-pipeline.md) | `curated` |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 65b6e4e488..3e761ca14f 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -180,18 +180,13 @@ flowchart TD
pkg_mode --> pkg_session
pkg_mode --> pkg_system_prompt
pkg_mode --> pkg_tools
+ pkg_mode --> pkg_user_interaction
pkg_tool_cordis --> pkg_tools
pkg_hooks_codex --> pkg_agent
pkg_hooks_codex --> pkg_hook_protocol
pkg_hooks_codex --> pkg_llm
pkg_hooks_codex --> pkg_session
pkg_hooks_codex --> pkg_tools
- pkg_acp --> pkg_agent
- pkg_acp --> pkg_llm
- pkg_acp --> pkg_session
- pkg_acp --> pkg_session_persistence
- pkg_acp --> pkg_tools
- pkg_acp --> pkg_user_interaction
pkg_tool_ask_user --> pkg_agent
pkg_tool_ask_user --> pkg_tools
pkg_tool_ask_user --> pkg_user_interaction
@@ -227,6 +222,13 @@ flowchart TD
pkg_subagent_mock --> pkg_agent
pkg_subagent_mock --> pkg_llm
pkg_subagent_mock --> pkg_subagent
+ pkg_acp --> pkg_agent
+ pkg_acp --> pkg_llm
+ pkg_acp --> pkg_mode
+ pkg_acp --> pkg_session
+ pkg_acp --> pkg_session_persistence
+ pkg_acp --> pkg_tools
+ pkg_acp --> pkg_user_interaction
pkg_subagent_fork --> pkg_agent
pkg_subagent_fork --> pkg_session
pkg_subagent_fork --> pkg_subagent
@@ -292,10 +294,9 @@ flowchart TD
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
-| [`mode`](../packages/mode/mode) | `mode` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`mode`](../packages/mode/mode) | `mode` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`tools`](../packages/core/tools) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
-| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) |
| [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tools`](../packages/core/tools) |
@@ -304,6 +305,7 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
+| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`mode`](../packages/mode/mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 84ae7567dc..75537fae06 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -117,7 +117,7 @@ The session mode in force from this point on: log-only, non-surface, whole-value
'mode/set': { mode: string }
```
-Source: [`packages/mode/mode/src/index.ts:39`](../packages/mode/mode/src/index.ts)
+Source: [`packages/mode/mode/src/index.ts:41`](../packages/mode/mode/src/index.ts)
### `prompt/*`
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index b74cc9bdd6..5ea669e0db 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -17,6 +17,7 @@ This table connects model-visible tool names to the plugin package and service s
| --- | --- | --- | --- | --- | --- |
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. |
| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch per bridged sub-call`, `tool/result` | - | Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time. |
+| `@deepseek-ai/dsh-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `mode/set back to default on an approved review`, `tool/result` | - | exit_plan_mode presents the plan for the user's review over the user-interaction seam (approve / keep planning with feedback); approval flips the logged session mode back to default. The assemble filter shows it only while the folded mode is plan. |
| `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
@@ -119,6 +120,31 @@ Source: [`packages/core/tools/src/code-mode.ts`](../packages/core/tools/src/code
Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time.
+## `@deepseek-ai/dsh-mode`
+
+### `exit_plan_mode`
+
+Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (the full toolset returns on your next step) or keep planning — their feedback comes back in the tool result; revise and present again.
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "plan": {
+ "type": "string",
+ "description": "The complete plan, as markdown, starting with a # heading that names it."
+ }
+ },
+ "required": [
+ "plan"
+ ]
+}
+```
+
+Source: [`packages/mode/mode/src/index.ts`](../packages/mode/mode/src/index.ts)
+
+exit_plan_mode presents the plan for the user's review over the user-interaction seam (approve / keep planning with feedback); approval flips the logged session mode back to default. The assemble filter shows it only while the folded mode is plan.
+
## `@deepseek-ai/dsh-tool-bash`
### `bash`
diff --git a/examples/AGENTS.md b/examples/AGENTS.md
index 3d2a700ca0..ff64f28cf9 100644
--- a/examples/AGENTS.md
+++ b/examples/AGENTS.md
@@ -23,5 +23,6 @@ A keyless smoke that spawns the example from a temp cwd must set `TSX_TSCONFIG_P
| `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit; `tests/code-mode-keyless-smoke.e2e.ts` — the same boot guard for the Code Mode overlay | `tests/{full-loop,coding-task,resume,compaction,todo-write}.e2e.ts` — real model + real bash + real todo_write, world-verified; `tests/code-mode.e2e.ts` — a real model composes two bash calls in one `run_code` program; collapsed header, dispatch events, written file all verified |
| `cordis-agent` | `tests/keyless-smoke.e2e.ts` — boots the real tree incl. `@deepseek-ai/dsh-tool-cordis` by package name; the tool logic is unit-tested in `packages/cordis/tool-cordis` | `tests/cordis-tools.e2e.ts` — real model mounts a listener (tagged line fires), builds+calls its own tool, composes two mounts via provide/inject |
| `acp-agent` | `pnpm run test:snapshot` — boots the real ACP subprocess and replays a recorded session keyless (incl. the hook matrix: a scenario per hook point × outcome for BOTH the Claude and Codex bridges — block, deny, ask, context-fold, force-continue); `tests/acp.e2e.ts` also asserts stdout purity without a key | `tests/acp.e2e.ts` — real ACP prompt, verifies a file the agent wrote; `tests/hooks.e2e.ts` — a real `PreToolUse` hook blocks bash, verifies the file is NOT written |
+| `plan-acp-agent` | `pnpm run test:snapshot` — the session-mode wire surface as committed protocol bytes | none yet (the recorded plan-mode scenarios are the pending with-key tier) |
See [the root AGENTS.md](../AGENTS.md) for repo-wide conventions and [docs/architecture.md](../docs/architecture.md) for the design.
diff --git a/examples/README.md b/examples/README.md
index e8a41a366b..d72148cce0 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -32,3 +32,9 @@ Run with: `pnpm run demo:cordis` (needs `DEEPSEEK_API_KEY`). See [cordis-agent/R
An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests.
Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode acp` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design.
+
+## plan-acp-agent
+
+The ACP server with **session modes** composed ([`@deepseek-ai/dsh-mode`](../packages/mode/mode)) — the editor's mode picker switches the session into plan mode, the model works under the read-only allowlist, and it leaves through the user-reviewed `exit_plan_mode` tool (the review arrives as an elicitation form).
+
+Run with: `pnpm run demo:plan-acp` (needs `DEEPSEEK_API_KEY`). See [plan-acp-agent/README.md](plan-acp-agent/README.md).
diff --git a/examples/plan-acp-agent/README.md b/examples/plan-acp-agent/README.md
new file mode 100644
index 0000000000..652c438454
--- /dev/null
+++ b/examples/plan-acp-agent/README.md
@@ -0,0 +1,19 @@
+# plan-acp-agent
+
+The coding agent as an ACP server with **session modes** composed — the live composition of [the plan-mode RFC](../../docs/rfc/proposed/feature/2026-07-07-plan-mode.md).
+
+## What it demonstrates
+
+`session/new` advertises the mode picker (`default` / `plan`); the editor's `session/set_mode` switches the session, applied at the next turn boundary. In plan mode the model sees only the read-only allowlist (`read`, `todo_write`, `exit_plan_mode` here — this tree loads no web tools) plus the plan-mode guidance section, and every call outside the allowlist is denied at `tools/pre-execute` with a reason that steers it back to planning. The model leaves by presenting its plan through `exit_plan_mode`: the plan markdown renders as the tool's call card, the review question arrives as an elicitation form (approve / keep planning, free text welcome), and a keep-planning answer returns the feedback to the model verbatim.
+
+## Run
+
+```sh
+pnpm run demo:plan-acp # needs DEEPSEEK_API_KEY (repo-root .env works)
+```
+
+Drive it from Zed or any ACP client; the mode picker appears on the session. Switching back to `default` (or an approved `exit_plan_mode`) restores the full toolset on the next step.
+
+## Tests
+
+`pnpm run test:snapshot` replays the keyless `modes-advertise` scenario: the `modes` advertisement on `session/new`, both `session/set_mode` round-trips with their optimistic `current_mode_update`, and the loud rejection of an unknown mode id, as committed wire bytes. The recorded plan-mode arc (explore → denied write → `exit_plan_mode` → scripted approve / keep-planning) awaits a with-key recording session; its deny/review texts are pinned at the unit tier meanwhile (`packages/mode/mode/tests`, `packages/ui/acp/tests/modes.spec.ts`).
diff --git a/examples/plan-acp-agent/composition.md b/examples/plan-acp-agent/composition.md
new file mode 100644
index 0000000000..8b46156310
--- /dev/null
+++ b/examples/plan-acp-agent/composition.md
@@ -0,0 +1,46 @@
+
+
+# Plan-Mode ACP Agent App Composition
+
+The plan-mode demo composes session modes onto the ACP server: the editor mode picker drives plan mode, and the model exits through the user-reviewed exit_plan_mode tool.
+
+```mermaid
+flowchart LR
+ cfg["examples/plan-acp-agent
cordis.yml"]
+ plugin_plan-acp_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"]
+ cfg --> plugin_plan-acp_llm_deepseek
+ plugin_plan-acp_acp_agent["acp-agent
@deepseek-ai/dsh-acp-agent"]
+ cfg --> plugin_plan-acp_acp_agent
+ plugin_plan-acp_acp_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-core"]
+ plugin_plan-acp_acp_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"]
+ plugin_plan-acp_acp_agent --> frontdoor_acp["@deepseek-ai/dsh-acp
JSON-RPC stdio bridge
sessions created by client"]
+ bundle_agent_core --> spine_llm["ctx.llm"]
+ bundle_agent_core --> spine_sessions["ctx.sessions"]
+ bundle_agent_core --> spine_tools["ctx.tools + tool-bash"]
+ bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"]
+ plugin_plan-acp_mode["mode
@deepseek-ai/dsh-mode"]
+ cfg --> plugin_plan-acp_mode
+ plugin_plan-acp_fs_local["fs-local
@deepseek-ai/dsh-fs-local"]
+ cfg --> plugin_plan-acp_fs_local
+ plugin_plan-acp_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"]
+ cfg --> plugin_plan-acp_fs_policy
+ plugin_plan-acp_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"]
+ cfg --> plugin_plan-acp_tool_fs
+ plugin_plan-acp_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"]
+ cfg --> plugin_plan-acp_tool_todo
+```
+
+| Plugin id | Package / module |
+| --- | --- |
+| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
+| `acp-agent` | `@deepseek-ai/dsh-acp-agent` |
+| `mode` | `@deepseek-ai/dsh-mode` |
+| `fs-local` | `@deepseek-ai/dsh-fs-local` |
+| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
+| `tool-fs` | `@deepseek-ai/dsh-tool-fs` |
+| `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
+
+Source config: [`examples/plan-acp-agent/cordis.yml`](cordis.yml).
+
+Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source.
diff --git a/examples/plan-acp-agent/cordis.snapshot.yml b/examples/plan-acp-agent/cordis.snapshot.yml
new file mode 100644
index 0000000000..9ea934445a
--- /dev/null
+++ b/examples/plan-acp-agent/cordis.snapshot.yml
@@ -0,0 +1,27 @@
+# Snapshot-test REPLAY overlay for the plan-mode composition: the SAME app
+# tree as cordis.yml, derived from it by an include — the one difference is
+# the model backend. A keyless replay run cannot boot the real adapter
+# (llm-deepseek's apply() throws without DEEPSEEK_API_KEY), so the include
+# patches the live tree at load time: the llm-deepseek entry is disabled by
+# id, and the llm-replay entry (which serves a recorded session JSONL — no
+# API key, no network) is inserted. Every other entry — the mode plugin, the
+# filesystem stack, the app — IS the live tree.
+#
+# The dsh-acp-agent bin selects this file for DSH_SNAPSHOT=replay (the
+# sibling-swap of whatever config path it was handed). The replay fixture
+# path comes from $DSH_SNAPSHOT_FILE, set by the snapshot harness. stdout
+# stays reserved for the ACP JSON-RPC protocol.
+- id: base
+ name: '@cordisjs/plugin-include'
+ config:
+ path: ./cordis.yml
+ patches:
+ # The name is an assertion, not an override: the include skips the patch
+ # (warning) when the id points at a different plugin, so this can never
+ # disable the wrong entry.
+ - id: llm-deepseek
+ name: '@deepseek-ai/dsh-llm-deepseek'
+ disabled: true
+ - insert:
+ - id: llm-replay
+ name: '@deepseek-ai/dsh-llm-replay'
diff --git a/examples/plan-acp-agent/cordis.yml b/examples/plan-acp-agent/cordis.yml
new file mode 100644
index 0000000000..451ab3c8ee
--- /dev/null
+++ b/examples/plan-acp-agent/cordis.yml
@@ -0,0 +1,65 @@
+# The plan-acp-agent plugin tree: the coding agent served over the Agent
+# Client Protocol WITH session modes composed — the plan-mode RFC's live
+# composition. The editor's mode picker (session/set_mode) switches the
+# session between `default` and `plan`; in plan mode the model works under the
+# read-only allowlist and leaves through the user-reviewed exit_plan_mode tool
+# (the review rides the same elicitation flow as ask_user_question).
+#
+# CRITICAL: this tree loads NO stdout logger and NO hmr — stdout is reserved
+# for the ACP JSON-RPC protocol (a property of @deepseek-ai/dsh-acp-agent,
+# same as examples/acp-agent).
+#
+# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) — the
+# dsh-acp-agent bin loads the gitignored repo-root .env first (on STDERR only).
+
+# The DeepSeek adapter.
+- id: llm-deepseek
+ name: '@deepseek-ai/dsh-llm-deepseek'
+ config:
+ apiKey: !!js process.env.DEEPSEEK_API_KEY
+ baseURL: !!js process.env.DEEPSEEK_BASE_URL
+ models:
+ - deepseek-v4-flash
+
+# The ACP server app: the agent-core spine + JSONL persistence + the ACP
+# bridge (which advertises the mode picker and answers the plan review).
+- id: acp-agent
+ name: '@deepseek-ai/dsh-acp-agent'
+ config:
+ model: deepseek-v4-flash
+ # Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness
+ # sets it (so a record run's logs land where the harness harvests them),
+ # else the local ./.sessions default.
+ persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
+ persona: |
+ You are a coding assistant powered by the {{model}} model. Your working
+ directory is {{cwd}}.
+
+ Verify your work by running the code or tests. Keep answers brief and
+ factual.
+
+# Session modes (ctx.modes — the shipped `plan` definition, no overrides): the
+# mode/set vocabulary, the assemble filter + mode section, the pre-execute
+# gate, and the exit_plan_mode tool. The ACP bridge above reads it
+# opportunistically and advertises the picker.
+- id: mode
+ name: '@deepseek-ai/dsh-mode'
+
+# Filesystem capability stack: local provider, read-before-write/edit policy
+# gate, then the model-facing read/write/edit tools — `read` is on plan mode's
+# allowlist; `write`/`edit` are what the plan-mode gate denies.
+- id: fs-local
+ name: '@deepseek-ai/dsh-fs-local'
+ config:
+ cwd: !!js process.cwd()
+
+- id: fs-policy
+ name: '@deepseek-ai/dsh-fs-policy'
+
+- id: tool-fs
+ name: '@deepseek-ai/dsh-tool-fs'
+
+# The model-facing todo_write tool — allowlisted in plan mode, so the model
+# can track its plan while exploring.
+- id: tool-todo
+ name: '@deepseek-ai/dsh-tool-todo'
diff --git a/examples/plan-acp-agent/package.json b/examples/plan-acp-agent/package.json
new file mode 100644
index 0000000000..7e93fe0267
--- /dev/null
+++ b/examples/plan-acp-agent/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "plan-acp-agent-example",
+ "description": "Runnable demo: the coding agent as an ACP server with session modes — the editor's mode picker drives plan mode",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module"
+}
diff --git a/examples/plan-acp-agent/tests/acp.snapshot.ts b/examples/plan-acp-agent/tests/acp.snapshot.ts
new file mode 100644
index 0000000000..f65e52e4c0
--- /dev/null
+++ b/examples/plan-acp-agent/tests/acp.snapshot.ts
@@ -0,0 +1,38 @@
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { defineAcpSnapshotSuite, type Scenario } from '@deepseek-ai/dsh-acp-snapshot'
+
+/**
+ * Snapshot suite for the plan-mode composition (`../cordis.yml`, swapped to
+ * the sibling `cordis.snapshot.yml` replay overlay by the bin under
+ * `DSH_SNAPSHOT=replay`).
+ *
+ * Deliberately ABSENT (pending a with-key recording session — the plan-mode
+ * RFC's recorded-scenario section): the two model-turn scenarios driving the
+ * full arc (setMode → explore → denied write → exit_plan_mode → a scripted
+ * `elicitationAnswers` approve, and the keep-planning sibling). Their deny and
+ * keep-planning texts are meanwhile pinned at the unit tier
+ * (packages/mode/mode/tests) and the ACP mode round-trip in the bridge's
+ * protocol tests (packages/ui/acp/tests/modes.spec.ts).
+ */
+const SCENARIOS: Scenario[] = [
+ // Protocol-only (keyless, authored): the session-mode surface this
+ // composition adds. No model turn runs, so no header pin
+ // is needed here — the pinning scenario arrives with the recorded plan-mode
+ // arc. Composition-wise it adds — availableModes/currentModeId advertised on
+ // session/new, the optimistic current_mode_update a session/set_mode
+ // answers with, and the loud rejection of an unknown mode id — as committed
+ // wire bytes. No model turn, so it replays keyless.
+ { name: 'modes-advertise', hasModelTurn: false, recorded: false },
+]
+
+defineAcpSnapshotSuite({
+ agent: {
+ binScript: fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)),
+ configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
+ tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
+ },
+ snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'),
+ scenarios: SCENARIOS,
+ mode: process.env.DSH_SNAPSHOT === 'record' ? 'record' : 'replay',
+})
diff --git a/examples/plan-acp-agent/tests/snapshots/modes-advertise/input.json b/examples/plan-acp-agent/tests/snapshots/modes-advertise/input.json
new file mode 100644
index 0000000000..26c56b3425
--- /dev/null
+++ b/examples/plan-acp-agent/tests/snapshots/modes-advertise/input.json
@@ -0,0 +1,22 @@
+{
+ "steps": [
+ {
+ "op": "initialize"
+ },
+ {
+ "op": "newSession"
+ },
+ {
+ "op": "setMode",
+ "modeId": "plan"
+ },
+ {
+ "op": "setMode",
+ "modeId": "default"
+ },
+ {
+ "op": "setModeExpectError",
+ "modeId": "yolo"
+ }
+ ]
+}
diff --git a/examples/plan-acp-agent/tests/snapshots/modes-advertise/session.jsonl b/examples/plan-acp-agent/tests/snapshots/modes-advertise/session.jsonl
new file mode 100644
index 0000000000..a6f73319bc
--- /dev/null
+++ b/examples/plan-acp-agent/tests/snapshots/modes-advertise/session.jsonl
@@ -0,0 +1 @@
+{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0}
diff --git a/examples/plan-acp-agent/tests/snapshots/modes-advertise/stdout.golden.jsonl b/examples/plan-acp-agent/tests/snapshots/modes-advertise/stdout.golden.jsonl
new file mode 100644
index 0000000000..c4f706888e
--- /dev/null
+++ b/examples/plan-acp-agent/tests/snapshots/modes-advertise/stdout.golden.jsonl
@@ -0,0 +1,7 @@
+{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
+{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"current_mode_update","currentModeId":"plan"}}}
+{"jsonrpc":"2.0","id":3,"result":{}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"current_mode_update","currentModeId":"default"}}}
+{"jsonrpc":"2.0","id":4,"result":{}}
+{"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown mode \"yolo\" — available modes: default, plan"}}
diff --git a/package.json b/package.json
index c4cc307893..f507c31c09 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
"demo:code-mode": "node scripts/demo-code-mode.mjs",
"demo:cordis": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/cordis-agent/cordis.yml",
"demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml",
+ "demo:plan-acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/plan-acp-agent/cordis.yml",
"postinstall": "node scripts/install-lefthook.mjs"
},
"devDependencies": {
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 6a90267746..98cd620f62 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -130,7 +130,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
summary: '`ctx.modes`: the session-mode service.',
methods: [
'list(): string[]',
- 'get(agent: Agent): { current: string, pending?: string }',
+ 'get(agent: Agent): { current: string; pending?: string }',
'set(agent: Agent, mode: string): void',
],
},
diff --git a/packages/mode/README.md b/packages/mode/README.md
index e3ba8fbaf7..0354c54a03 100644
--- a/packages/mode/README.md
+++ b/packages/mode/README.md
@@ -4,6 +4,6 @@ Session modes: named, logged, per-agent policy states, with **plan mode** as the
| Package | Role | ctx key |
|---|---|---|
-| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the soft layer (assemble filter + `mode:policy` section) and the hard layer (`tools/pre-execute` deny-by-default gate) | `ctx.modes` |
+| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the soft layer (assemble filter + `mode:policy` section), the hard layer (`tools/pre-execute` deny-by-default gate), and the model-facing `exit_plan_mode` review tool | `ctx.modes` |
The mode in force is a pure function of the session log (`SessionEventMap['mode/set']`, last one wins), so resume and fork restore it with no extra machinery; the default mode is the absence of policy, keeping the plugin invisible until a mode is set. UIs read flips off `session/event`: the [stdio app](../ui/stdio-agent) exposes `/mode`, the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker. RFC: [plan mode](../../docs/rfc/proposed/feature/2026-07-07-plan-mode.md).
diff --git a/packages/mode/mode/README.md b/packages/mode/mode/README.md
index f3ec88d403..b955a84bbb 100644
--- a/packages/mode/mode/README.md
+++ b/packages/mode/mode/README.md
@@ -10,7 +10,7 @@ The `default` mode is the absence of policy: no section, no filtering, no gate.
## Two layers of enforcement
-**Soft — what the model sees.** A `system-prompt/assemble` listener filters the returned assembly's tools down to the mode's allowlist and the `mode:policy` section (order 50) renders the mode's guidance text. Every transition therefore surfaces as an attributable `request/header-delta` on the next step. The `exit_plan_mode` tool is visible IFF the folded mode is `plan`.
+**Soft — what the model sees.** A `system-prompt/assemble` listener filters the returned assembly's tools down to the mode's allowlist and the `mode:policy` section (order 50) renders the mode's guidance text. Every transition therefore surfaces as an attributable `request/header` event on the next step (a delta when expressible; adding `exit_plan_mode` resorts the canonical tool list, which the delta encoding cannot express, so entering plan mode logs the full fallback snapshot). The `exit_plan_mode` tool is visible IFF the folded mode is `plan`.
**Hard — what can run.** A `tools/pre-execute` listener denies, deny-by-default against the same allowlist, any call the mode does not permit — a hallucinated call to a still-registered (or freshly re-widened) tool cannot run. Agent-less executions and the default mode pass through; the gate judges by the LOGGED mode only, never a pending intent.
@@ -20,6 +20,10 @@ The `default` mode is the absence of policy: no section, no filtering, no gate.
`AgentOptions.mode` (declaration-merged) seeds a child's initial mode through the same pending-intent flush; explicit options beat the logged baseline on create AND resume. A fork child needs no mechanism — the parent's `mode/set` is inside the seeded prefix.
+## `exit_plan_mode`
+
+The model-facing exit tool. Its single required argument is the plan text — a durable, replayable log artifact riding the ordinary `tool/call` event. `execute` re-checks the folded mode, then conducts the review over the user-interaction seam (`ctx.get('userInteraction')`, opportunistic): one single-select question — Approve, or Keep planning — with the free-text channel open. Approve appends `mode/set { mode: 'default' }` in-turn and the next step's assembly restores the full toolset; every other outcome (keep-planning with the user's feedback verbatim, an aborted question, no provider) returns the corrective `isError` and the mode stays `plan`. `presentCall` renders a `generic` card titled by the plan's first heading with the plan markdown as content; over ACP the review rides the same elicitation flow as `ask_user_question`, in the terminal the stdio provider's prompt queue.
+
## Config
```yaml
diff --git a/packages/mode/mode/package.json b/packages/mode/mode/package.json
index a80ab12bea..7d0052f130 100644
--- a/packages/mode/mode/package.json
+++ b/packages/mode/mode/package.json
@@ -26,6 +26,7 @@
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
+ "@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
@@ -35,6 +36,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
+ "@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
diff --git a/packages/mode/mode/src/index.ts b/packages/mode/mode/src/index.ts
index 35196aad23..4c2b26596b 100644
--- a/packages/mode/mode/src/index.ts
+++ b/packages/mode/mode/src/index.ts
@@ -26,8 +26,10 @@
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import { defineTool } from '@deepseek-ai/dsh-tools'
import type { PreToolDecision } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
+import type {} from '@deepseek-ai/dsh-user-interaction'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
@@ -112,6 +114,27 @@ const PLAN_SECTION
const PLAN_TOOLS = ['read', 'todo_write', 'web_search', 'web_fetch', EXIT_PLAN_MODE]
+/** The review question's approve option label — the answer item is matched by it. */
+const APPROVE_LABEL = 'Approve'
+
+/** The review question's keep-planning option label. */
+const KEEP_PLANNING_LABEL = 'Keep planning'
+
+const EXIT_DESCRIPTION
+ = 'Present your plan for the user\'s review and, on approval, leave plan mode. '
+ + 'Send the COMPLETE plan as markdown, starting with a # heading that names it. '
+ + 'The user may approve (the full toolset returns on your next step) or keep '
+ + 'planning — their feedback comes back in the tool result; revise and present again.'
+
+/** The plan's first markdown heading (any level), or `undefined` when it has none. */
+function firstHeading(plan: string): string | undefined {
+ for (const line of plan.split('\n')) {
+ const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)
+ if (match) return match[1]
+ }
+ return undefined
+}
+
/**
* Validate the config and merge the built-in `plan` definition (explicit
* resolve step — the `dsh-bash` request/spec template). Fail-loud: a
@@ -240,6 +263,61 @@ export class ModesService extends Service {
: `tool "${exec.name}" is not available in "${active.name}" mode`
return Promise.resolve({ kind: 'deny', reason })
})
+
+ ctx.tools.register(defineTool({
+ name: EXIT_PLAN_MODE,
+ description: EXIT_DESCRIPTION,
+ parameters: {
+ plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },
+ },
+ execute: async (_args, exec) => {
+ const agent = exec.agent
+ if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
+ if (this.activeDefinition(agent.session)?.name !== PLAN_MODE) {
+ throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
+ }
+ const interaction = ctx.get('userInteraction')
+ if (interaction === undefined) {
+ throw new Error('no user-interaction channel is available to review the plan; ask the user to switch the session mode instead')
+ }
+ const answer = await interaction.ask({
+ questions: [{
+ id: 'plan-review',
+ header: 'Plan review',
+ question: 'Approve this plan and leave plan mode?',
+ options: [
+ { label: APPROVE_LABEL, description: 'Leave plan mode; the full toolset returns on the next step.' },
+ { label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
+ ],
+ }],
+ agent,
+ ...exec.signal ? { signal: exec.signal } : {},
+ })
+ const item = answer.answers.find(entry => entry.id === 'plan-review')
+ if (!item?.selected.includes(APPROVE_LABEL)) {
+ // A custom-text-only answer is feedback, not consent — approval is
+ // exactly the approve option (an unknown selection never exits).
+ const feedback = item?.custom ?? ''
+ throw new Error(feedback === ''
+ ? 'The user chose to keep planning; revise the plan and present it again.'
+ : `The user chose to keep planning; their feedback: ${feedback}`)
+ }
+ agent.session.append('mode/set', { mode: DEFAULT_MODE })
+ const note = item.custom === undefined || item.custom === '' ? '' : ` User note: ${item.custom}`
+ return [{ type: 'text', text: `Plan approved — plan mode exited; the full toolset returns on your next step.${note}` }]
+ },
+ presentCall: args => ({
+ card: 'generic',
+ title: firstHeading(args.plan) ?? 'Plan',
+ kind: 'other',
+ content: [{ type: 'text', text: args.plan }],
+ }),
+ presentResult: (_args, result) => ({
+ card: 'generic',
+ title: 'Plan review',
+ content: result.content,
+ }),
+ }))
}
/**
diff --git a/packages/mode/mode/tests/integration.spec.ts b/packages/mode/mode/tests/integration.spec.ts
index 6dfc40af9b..e9a58fafc4 100644
--- a/packages/mode/mode/tests/integration.spec.ts
+++ b/packages/mode/mode/tests/integration.spec.ts
@@ -78,7 +78,7 @@ describe('plan mode through the agent loop', () => {
const header = findEvent(log, 'request/header')
expect(modeSet.seq).toBeLessThan(header.seq)
expect(header.data.reason).toBe('initial')
- expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['read'])
+ expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read'])
expect(header.data.header.system).toContain('plan mode')
const result = findEvent(log, 'tool/result')
@@ -110,8 +110,12 @@ describe('plan mode through the agent loop', () => {
expect(findEvent(log, 'context/message').data.content).toEqual([
{ type: 'text', text: 'The user switched this session to plan mode.' },
])
- const delta = findEvent(log, 'request/header-delta')
- expect(delta.data.tools).toBeDefined()
- expect(delta.data.system).toBeDefined()
+ // The narrowing header change is logged as a FULL fallback snapshot, not a
+ // delta: adding exit_plan_mode reorders the canonical tool list (it sorts
+ // first), and a pure reordering is inexpressible in the delta encoding.
+ const second = findEvent(log, 'request/header', 'last')
+ expect(second.data.reason).toBe('fallback')
+ expect(second.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read'])
+ expect(second.data.header.system).toContain('plan mode')
})
})
diff --git a/packages/mode/mode/tests/mode.spec.ts b/packages/mode/mode/tests/mode.spec.ts
index 84da222cee..760492c58f 100644
--- a/packages/mode/mode/tests/mode.spec.ts
+++ b/packages/mode/mode/tests/mode.spec.ts
@@ -6,6 +6,7 @@ import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
+import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts'
import type { ModeConfig } from '../src/index.ts'
@@ -283,7 +284,7 @@ describe('the boundary flush', () => {
describe('the soft layer', () => {
it('keeps a default-mode assembly identical to a no-dsh-mode deployment (exit tool dropped)', async () => {
const ctx = await setup()
- registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
+ registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
@@ -292,7 +293,7 @@ describe('the soft layer', () => {
it('leaves an agent-less assembly untouched', async () => {
const ctx = await setup()
- registerNamedTools(ctx, ['read', EXIT_PLAN_MODE])
+ registerNamedTools(ctx, ['read'])
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
@@ -300,7 +301,7 @@ describe('the soft layer', () => {
it('filters plan-mode tools to the allowlist and renders the mode section', async () => {
const ctx = await setup()
- registerNamedTools(ctx, ['read', 'write', 'todo_write', EXIT_PLAN_MODE])
+ registerNamedTools(ctx, ['read', 'write', 'todo_write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
@@ -310,7 +311,7 @@ describe('the soft layer', () => {
it('drops exit_plan_mode outside plan mode even when a custom allowlist names it', async () => {
const ctx = await setup({ modes: { review: { section: 'reviewing', tools: ['read', EXIT_PLAN_MODE] } } })
- registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
+ registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'review' })
const assembly = await ctx.systemPrompt.assemble({ agent })
@@ -320,7 +321,7 @@ describe('the soft layer', () => {
it('treats a dropped folded definition as the default mode', async () => {
const ctx = await setup()
- registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
+ registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
const assembly = await ctx.systemPrompt.assemble({ agent })
@@ -382,3 +383,172 @@ describe('the hard layer', () => {
expect(result.isError).toBe(false)
})
})
+
+describe('exit_plan_mode', () => {
+ async function setupWithReview(answer?: { selected: string[]; custom?: string }) {
+ const ctx = await setup()
+ await ctx.plugin(UserInteractionService)
+ const asked: AskUserQuestionRequest[] = []
+ if (answer !== undefined) {
+ ctx.userInteraction.registerProvider({
+ ask: (request) => {
+ asked.push(request)
+ return Promise.resolve({ answers: [{ id: 'plan-review', ...answer }] })
+ },
+ })
+ }
+ const agent = agentWithSession()
+ agent.session.append('mode/set', { mode: PLAN_MODE })
+ return { ctx, agent, asked }
+ }
+
+ function callExit(ctx: Context, agent: Agent | undefined, plan = '# The plan\n\ndo things') {
+ return ctx.tools.execute({
+ callId: CallId(`call-exit-${++callCounter}`),
+ name: EXIT_PLAN_MODE,
+ arguments: { plan },
+ ...agent ? { agent } : {},
+ })
+ }
+
+ it('registers the tool with one required plan argument', async () => {
+ const ctx = await setup()
+ const schema = ctx.tools.schemas().find(entry => entry.name === EXIT_PLAN_MODE)
+ const parameters = schema?.parameters as { required?: string[]; properties?: Record }
+ expect(Object.keys(parameters.properties ?? {})).toEqual(['plan'])
+ expect(parameters.required).toEqual(['plan'])
+ })
+
+ it('rejects an agent-less call', async () => {
+ const ctx = await setup()
+ const result = await callExit(ctx, undefined)
+ expect(result.isError).toBe(true)
+ expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a calling agent (no session to switch)' }])
+ })
+
+ it('rejects a call outside plan mode (defense in depth behind the gate)', async () => {
+ const ctx = await setup()
+ const agent = agentWithSession()
+ const result = await callExit(ctx, agent)
+ expect(result.isError).toBe(true)
+ expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode is only available in plan mode' }])
+ })
+
+ it('degrades to the manual exit when no user-interaction seam is composed', async () => {
+ const ctx = await setup()
+ const agent = agentWithSession()
+ agent.session.append('mode/set', { mode: PLAN_MODE })
+ const result = await callExit(ctx, agent)
+ expect(result.isError).toBe(true)
+ expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction channel is available to review the plan; ask the user to switch the session mode instead' }])
+ expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
+ })
+
+ it('degrades the same way when the seam has no provider (NO_PROVIDER)', async () => {
+ const { ctx, agent } = await setupWithReview()
+ const result = await callExit(ctx, agent)
+ expect(result.isError).toBe(true)
+ expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction provider is registered' }])
+ expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
+ })
+
+ it('approve: appends mode/set default in-turn and confirms', async () => {
+ const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
+ const result = await callExit(ctx, agent)
+ expect(result.isError).toBe(false)
+ expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; the full toolset returns on your next step.' }])
+ expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
+ expect(asked).toHaveLength(1)
+ expect(asked[0]?.agent).toBe(agent)
+ expect(asked[0]?.questions[0]?.options?.map(option => option.label)).toEqual(['Approve', 'Keep planning'])
+ })
+
+ it('approve with a note carries the note into the confirmation', async () => {
+ const { ctx, agent } = await setupWithReview({ selected: ['Approve'], custom: 'ship it small' })
+ const result = await callExit(ctx, agent)
+ expect(result.isError).toBe(false)
+ expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; the full toolset returns on your next step. User note: ship it small' }])
+ })
+
+ it('keep planning returns the corrective error carrying the feedback verbatim', async () => {
+ const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'], custom: 'consider the resume path' })
+ const result = await callExit(ctx, agent)
+ expect(result.isError).toBe(true)
+ expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: consider the resume path' }])
+ expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
+ })
+
+ it('keep planning without feedback returns the generic corrective error', async () => {
+ const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'] })
+ const result = await callExit(ctx, agent)
+ expect(result.isError).toBe(true)
+ expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
+ })
+
+ it('a custom-text-only answer is feedback, never consent', async () => {
+ const { ctx, agent } = await setupWithReview({ selected: [], custom: 'add tests first' })
+ const result = await callExit(ctx, agent)
+ expect(result.isError).toBe(true)
+ expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: add tests first' }])
+ expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
+ })
+
+ it('a missing answer item reads as keep-planning', async () => {
+ const { ctx, agent } = await setupWithReview()
+ ctx.userInteraction.registerProvider({ ask: () => Promise.resolve({ answers: [] }) })
+ const result = await callExit(ctx, agent)
+ expect(result.isError).toBe(true)
+ expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
+ })
+
+ it('forwards the execution abort signal to the review question', async () => {
+ const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
+ const controller = new AbortController()
+ const result = await ctx.tools.execute({
+ callId: CallId(`call-exit-${++callCounter}`),
+ name: EXIT_PLAN_MODE,
+ arguments: { plan: '# P' },
+ agent,
+ signal: controller.signal,
+ })
+ expect(result.isError).toBe(false)
+ expect(asked[0]?.signal).toBe(controller.signal)
+ })
+
+ it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => {
+ const { ctx, agent } = await setupWithReview()
+ ctx.userInteraction.registerProvider({ ask: () => { throw new Error('review aborted') } })
+ const result = await callExit(ctx, agent)
+ expect(result.isError).toBe(true)
+ expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }])
+ expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
+ })
+
+ it('presents the call as a generic card titled by the plan first heading', async () => {
+ const ctx = await setup()
+ const def = ctx.tools.get(EXIT_PLAN_MODE)!
+ expect(def.presentCall?.({ plan: '## Fix the flake\n\nsteps' })).toEqual({
+ card: 'generic',
+ title: 'Fix the flake',
+ kind: 'other',
+ content: [{ type: 'text', text: '## Fix the flake\n\nsteps' }],
+ })
+ expect(def.presentCall?.({ plan: 'no heading here' })).toEqual({
+ card: 'generic',
+ title: 'Plan',
+ kind: 'other',
+ content: [{ type: 'text', text: 'no heading here' }],
+ })
+ })
+
+ it('presents the result as a generic review card', async () => {
+ const ctx = await setup()
+ const def = ctx.tools.get(EXIT_PLAN_MODE)!
+ const content = [{ type: 'text' as const, text: 'ok' }]
+ expect(def.presentResult?.({ plan: '# P' }, { content, isError: false })).toEqual({
+ card: 'generic',
+ title: 'Plan review',
+ content,
+ })
+ })
+})
diff --git a/packages/mode/mode/tsconfig.json b/packages/mode/mode/tsconfig.json
index 349d4a94a5..e34cfc0613 100644
--- a/packages/mode/mode/tsconfig.json
+++ b/packages/mode/mode/tsconfig.json
@@ -25,6 +25,9 @@
},
{
"path": "../../core/system-prompt"
+ },
+ {
+ "path": "../../ui/user-interaction"
}
]
}
diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md
index 209b1a81cb..933b3a9103 100644
--- a/packages/support/acp-snapshot/README.md
+++ b/packages/support/acp-snapshot/README.md
@@ -35,4 +35,4 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. Fixture roles, record/replay semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
-Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug).
+Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Elicitation round-trips (`ask_user_question` / the plan review) script the same way: `InputScript.elicitationAnswers` is a FIFO of `{ action, choice?, custom? }` form answers; exhaustion answers `cancel`, and a stray `choice` string reaches the agent verbatim as a non-consenting custom answer, so a scenario bug fails safe in the transcript.
diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts
index c0f5788fca..b33ce268a3 100644
--- a/packages/support/acp-snapshot/src/harness.ts
+++ b/packages/support/acp-snapshot/src/harness.ts
@@ -29,6 +29,8 @@ import {
PROTOCOL_VERSION,
type Agent as AcpAgent,
type Client,
+ type CreateElicitationRequest,
+ type CreateElicitationResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
@@ -85,6 +87,8 @@ export type InputStep =
| { op: 'promptExpectError'; text: string }
| { op: 'promptAndCancel'; text: string }
| { op: 'cancel' }
+ | { op: 'setMode'; modeId: string }
+ | { op: 'setModeExpectError'; modeId: string }
/** A scenario's `input.json`: an ordered list of input steps. */
export interface InputScript {
@@ -102,6 +106,16 @@ export interface InputScript {
* agent itself just sees `cancelled`, so it cannot absorb the bug).
*/
permissionAnswers?: PermissionAnswer[]
+ /**
+ * Ordered answers for the agent's `elicitation/create` round-trips (the
+ * ask_user_question / plan-review forms), consumed FIFO — the Nth request
+ * gets the Nth answer. Exhaustion (or no queue) answers `cancel`, the same
+ * fail-closed stub an elicitation-free scenario relies on. Unlike permission
+ * kinds, the scripted strings are not validated against the offered form —
+ * a stray `choice` reaches the agent verbatim, which reads it as a custom
+ * (non-consenting) answer, so a scenario bug fails safe in the transcript.
+ */
+ elicitationAnswers?: ElicitationAnswer[]
}
/** One scripted answer to a permission request: which offered option kind to select. */
@@ -110,6 +124,16 @@ export interface PermissionAnswer {
kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'
}
+/** One scripted answer to an elicitation form (accept with choice/custom content, or cancel). */
+export interface ElicitationAnswer {
+ /** Accept the form with the content below, or cancel it. */
+ action: 'accept' | 'cancel'
+ /** The selected option label (the form's `choice` field). */
+ choice?: string
+ /** Free-form text (the form's `custom` field). */
+ custom?: string
+}
+
/** One harvested session log plus the identifying facts off its header line. */
export interface HarvestedLog {
/** The recorded session id (header `id`). */
@@ -251,6 +275,8 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
// Permission answers are consumed FIFO across the whole run; exhaustion
// falls back to `cancelled` so approval-free scenarios keep the plain stub.
const permissionQueue = [...input.permissionAnswers ?? []]
+ // Elicitation answers mirror the permission queue: FIFO, cancel on exhaustion.
+ const elicitationQueue = [...input.elicitationAnswers ?? []]
// A scenario bug detected inside a client callback (a scripted permission
// kind the agent never offered). It cannot fail the run from in there: a
// callback throw only becomes a JSON-RPC error RESPONSE to the agent, and
@@ -291,6 +317,17 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
}
return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } })
},
+ unstable_createElicitation(_params: CreateElicitationRequest): Promise {
+ const answer = elicitationQueue.shift()
+ if (answer === undefined || answer.action !== 'accept') return Promise.resolve({ action: 'cancel' })
+ return Promise.resolve({
+ action: 'accept',
+ content: {
+ ...answer.choice !== undefined ? { choice: answer.choice } : {},
+ ...answer.custom !== undefined ? { custom: answer.custom } : {},
+ },
+ })
+ },
})
const client = new ClientSideConnection(makeClient, stream)
@@ -406,6 +443,24 @@ async function runStep(
await client.cancel({ sessionId })
return
}
+ case 'setMode': {
+ const sessionId = getSessionId()
+ if (sessionId === undefined) throw new Error('snapshot-harness: setMode before newSession')
+ await client.setSessionMode({ sessionId, modeId: step.modeId })
+ return
+ }
+ case 'setModeExpectError': {
+ const sessionId = getSessionId()
+ if (sessionId === undefined) throw new Error('snapshot-harness: setModeExpectError before newSession')
+ // The bridge rejects an unknown/uncomposed mode id with invalidParams;
+ // that rejection IS the expected wire behavior — swallow it so the run
+ // completes and the error frame is captured in the transcript.
+ await client.setSessionMode({ sessionId, modeId: step.modeId }).then(
+ () => { throw new Error('snapshot-harness: expected session/set_mode to be rejected but it succeeded') },
+ () => { /* expected: the bridge rejected the mode id */ },
+ )
+ return
+ }
default:
throw new Error(`snapshot-harness: unknown input op ${JSON.stringify(step)}`)
}
diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts
index bbe74030f2..a5db97b7c6 100644
--- a/packages/support/acp-snapshot/src/index.ts
+++ b/packages/support/acp-snapshot/src/index.ts
@@ -18,6 +18,7 @@
export {
runScenario,
type AgentUnderTest,
+ type ElicitationAnswer,
type HarvestedLog,
type InputScript,
type InputStep,
diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts
index 53e05d7876..df54feac61 100644
--- a/packages/support/acp-snapshot/src/suite.ts
+++ b/packages/support/acp-snapshot/src/suite.ts
@@ -229,6 +229,11 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
pinningByClass.set(cls, scenario)
}
for (const scenario of scenarios) {
+ // Only a scenario that RUNS a model turn produces request-header events
+ // for the uniformity guard to compare — a protocol-only scenario's fixture
+ // carries no header content, so it needs no anchor (and a suite of only
+ // protocol scenarios legitimately has none).
+ if (!scenario.hasModelTurn) continue
if (!pinningByClass.has(classOf(scenario))) {
throw new Error(`acp-snapshot: no scenario pins the request-header content of class "${classOf(scenario)}" (needed by ${scenario.name})`)
}
@@ -329,9 +334,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
// scenario's fixture) or composition became session-dependent by
// design (give the divergent shape its own pinning scenario and
// class).
- if (scenario.pinsHeader !== true) {
- /* v8 ignore next -- construction guarantees the pin exists; a miss would fail the one-header assertion loudly. */
- const pinningScenario = pinningByClass.get(classOf(scenario)) ?? scenario
+ const classPin = pinningByClass.get(classOf(scenario))
+ if (scenario.pinsHeader !== true && classPin !== undefined) {
+ const pinningScenario = classPin
const pinnedFixture = await readFile(join(snapshotsDir, pinningScenario.name, 'session.jsonl'), 'utf8')
const pinned = normalizedHeaders(pinnedFixture, fixtureContext(pinnedFixture))
expect(pinned.length, `the pinning fixture (${pinningScenario.name}) must carry exactly one request/header`)
@@ -401,7 +406,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
}
expect(Object.fromEntries([...pins].map(([cls, names]) => [cls, names.length]))).toEqual(
Object.fromEntries([...pinningByClass.keys()].map(cls => [cls, 1])))
- for (const scenario of scenarios) {
+ for (const scenario of scenarios.filter(s => s.hasModelTurn)) {
expect(pinningByClass.has(classOf(scenario)), `class "${classOf(scenario)}" (scenario ${scenario.name}) has a pin`).toBe(true)
}
})
diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts
index cf41412046..71b67d845d 100644
--- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts
+++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts
@@ -44,6 +44,10 @@ interface Behavior {
prompt?: 'respond' | 'error' | 'hang-until-cancel'
/** Before responding to a prompt, send a `session/request_permission` request and echo its outcome as a chunk. */
permissionProbe?: boolean
+ /** Before responding to a prompt, send an `elicitation/create` request and echo its response as a chunk. */
+ elicitationProbe?: boolean
+ /** How `session/set_mode` settles: an empty response (echoing the modeId as a chunk) or a JSON-RPC error. */
+ setMode?: 'respond' | 'error'
/** Echo the `DSH_SNAPSHOT_*` env the harness set as a chunk (spec-side env-plumbing assertions). */
echoEnv?: boolean
/** Echo the sorted cwd listing as a chunk (spec-side workspace-seeding assertions). */
@@ -79,8 +83,8 @@ let sessionId = ''
let sessionCwd = ''
/** The parked prompt request id while `hang-until-cancel` waits for the cancel notification. */
let parkedPromptId: number | string | null = null
-/** Resolvers for permission-probe responses, keyed by outbound request id. */
-const pendingPermission = new Map void>()
+/** Resolvers for outbound probe responses (permission/elicitation), keyed by request id. */
+const pendingOutbound = new Map void>()
function send(frame: Record): void {
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...frame })}\n`)
@@ -136,8 +140,8 @@ async function handlePrompt(id: number | string): Promise {
}
if (behavior.permissionProbe === true) {
const requestId = nextOutboundId++
- const outcome = await new Promise((resolve) => {
- pendingPermission.set(requestId, resolve)
+ const result = await new Promise((resolve) => {
+ pendingOutbound.set(requestId, resolve)
send({
id: requestId,
method: 'session/request_permission',
@@ -151,7 +155,24 @@ async function handlePrompt(id: number | string): Promise {
},
})
})
- chunk(`permission:${JSON.stringify(outcome)}`)
+ chunk(`permission:${JSON.stringify((result as { outcome?: unknown } | undefined)?.outcome ?? null)}`)
+ }
+ if (behavior.elicitationProbe === true) {
+ const requestId = nextOutboundId++
+ const result = await new Promise((resolve) => {
+ pendingOutbound.set(requestId, resolve)
+ send({
+ id: requestId,
+ method: 'elicitation/create',
+ params: {
+ sessionId,
+ mode: 'form',
+ message: 'Approve this plan and leave plan mode?',
+ requestedSchema: { type: 'object', title: 'Plan review', properties: { choice: { type: 'string' }, custom: { type: 'string' } }, required: [] },
+ },
+ })
+ })
+ chunk(`elicitation:${JSON.stringify(result ?? null)}`)
}
switch (behavior.prompt ?? 'respond') {
case 'respond':
@@ -171,10 +192,10 @@ function handleFrame(frame: Record): void {
const method = frame.method as string | undefined
const params = (frame.params ?? {}) as Record
// A response to one of OUR outbound requests (the permission probe).
- if (method === undefined && id !== undefined && typeof id === 'number' && pendingPermission.has(id)) {
- const resolve = pendingPermission.get(id) as (outcome: unknown) => void
- pendingPermission.delete(id)
- resolve((frame.result as { outcome?: unknown } | undefined)?.outcome ?? null)
+ if (method === undefined && id !== undefined && typeof id === 'number' && pendingOutbound.has(id)) {
+ const resolve = pendingOutbound.get(id) as (result: unknown) => void
+ pendingOutbound.delete(id)
+ resolve(frame.result)
return
}
switch (method) {
@@ -195,6 +216,14 @@ function handleFrame(frame: Record): void {
case 'session/prompt':
void handlePrompt(id as number | string)
return
+ case 'session/set_mode':
+ if ((behavior.setMode ?? 'respond') === 'error') {
+ respondError(id as number | string, 'unknown mode')
+ return
+ }
+ chunk(`setMode:${String(params.modeId)}`)
+ respond(id as number | string, {})
+ return
case 'session/cancel':
if (parkedPromptId !== null) {
const parked = parkedPromptId
diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts
index 683d2aaf80..62e5a091fa 100644
--- a/packages/support/acp-snapshot/tests/harness.spec.ts
+++ b/packages/support/acp-snapshot/tests/harness.spec.ts
@@ -231,6 +231,69 @@ describe('runScenario', () => {
expect(result.sessionLogs).toHaveLength(0)
})
+ it('drives session/set_mode and swallows the expected rejection of setModeExpectError', { timeout: 20_000 }, async () => {
+ const { fixtureFile } = await scenario({})
+ const result = await runScenario(
+ { steps: [...boot, { op: 'setMode', modeId: 'plan' }] },
+ { agent: AGENT, mode: 'replay', fixtureFile },
+ )
+ expect(result.rawStdout).toContain('setMode:plan')
+
+ const rejecting = await scenario({ setMode: 'error' })
+ const rejected = await runScenario(
+ { steps: [...boot, { op: 'setModeExpectError', modeId: 'yolo' }] },
+ { agent: AGENT, mode: 'replay', fixtureFile: rejecting.fixtureFile },
+ )
+ expect(rejected.rawStdout).toContain('unknown mode')
+ })
+
+ it('fails the run when setModeExpectError unexpectedly succeeds, and both mode ops require a session', { timeout: 20_000 }, async () => {
+ const { fixtureFile } = await scenario({})
+ await expect(runScenario(
+ { steps: [...boot, { op: 'setModeExpectError', modeId: 'plan' }] },
+ { agent: AGENT, mode: 'replay', fixtureFile },
+ )).rejects.toThrow(/expected session\/set_mode to be rejected/)
+ await expect(runScenario(
+ { steps: [{ op: 'initialize' }, { op: 'setMode', modeId: 'plan' }] },
+ { agent: AGENT, mode: 'replay', fixtureFile },
+ )).rejects.toThrow(/setMode before newSession/)
+ await expect(runScenario(
+ { steps: [{ op: 'initialize' }, { op: 'setModeExpectError', modeId: 'plan' }] },
+ { agent: AGENT, mode: 'replay', fixtureFile },
+ )).rejects.toThrow(/setModeExpectError before newSession/)
+ })
+
+ it('answers elicitations from the scripted queue, falling back to cancel on exhaustion', { timeout: 20_000 }, async () => {
+ const { fixtureFile } = await scenario({ elicitationProbe: true })
+ // Three prompts → three elicitations: an accept-with-choice, an
+ // accept-with-custom (feedback), then the exhausted-queue cancel.
+ const result = await runScenario(
+ {
+ steps: [...boot, { op: 'prompt', text: 'one' }, { op: 'prompt', text: 'two' }, { op: 'prompt', text: 'three' }],
+ elicitationAnswers: [
+ { action: 'accept', choice: 'Approve' },
+ { action: 'accept', custom: 'add tests first' },
+ ],
+ },
+ { agent: AGENT, mode: 'replay', fixtureFile },
+ )
+ const first = result.rawStdout.indexOf('elicitation:{\\"action\\":\\"accept\\",\\"content\\":{\\"choice\\":\\"Approve\\"}}')
+ const second = result.rawStdout.indexOf('elicitation:{\\"action\\":\\"accept\\",\\"content\\":{\\"custom\\":\\"add tests first\\"}}')
+ const third = result.rawStdout.indexOf('elicitation:{\\"action\\":\\"cancel\\"}')
+ expect(first).toBeGreaterThanOrEqual(0)
+ expect(second).toBeGreaterThan(first)
+ expect(third).toBeGreaterThan(second)
+ })
+
+ it('a scripted elicitation cancel answers cancel', { timeout: 20_000 }, async () => {
+ const { fixtureFile } = await scenario({ elicitationProbe: true })
+ const result = await runScenario(
+ { steps: [...boot, { op: 'prompt', text: 'one' }], elicitationAnswers: [{ action: 'cancel' }] },
+ { agent: AGENT, mode: 'replay', fixtureFile },
+ )
+ expect(result.rawStdout).toContain('elicitation:{\\"action\\":\\"cancel\\"}')
+ })
+
it('answers permission requests from the scripted queue by option kind, falling back to cancelled', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ permissionProbe: true })
// Two prompts → two permission round-trips; one scripted answer, so the
diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md
index 6162fae137..1b68c2278b 100644
--- a/packages/ui/acp/acp-feature-support.md
+++ b/packages/ui/acp/acp-feature-support.md
@@ -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), and resumable session replay. The largest **unbuilt** areas are the **permission gate** (`session/request_permission`), **MCP passthrough**, **session modes / config options / model selection**, **slash commands**, and **agent plans** — all of which both reference adapters ship — 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), and resumable session replay. The largest **unbuilt** areas are the **permission gate** (`session/request_permission`), **MCP passthrough**, **config options / model selection** (session modes ship via `dsh-mode` — see [§6](#6-session-modes--config-options--models)), **slash commands**, and **agent plans** — all of which both reference adapters ship — 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)
@@ -25,7 +25,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
| `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 not modeled (see [§6 Modes](#6-session-modes--config-options--models)). |
+| `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-mode` mounted, `session/new`/`session/load` advertise `availableModes`/`currentModeId` and `session/set_mode` records the pending intent (optimistic `current_mode_update`; the logged `mode/set` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ❌ | ✅ | ✅ | Config options not modeled. |
| 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/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. |
@@ -85,7 +85,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `tool_call_update` | S | ✅ | ✅ | ✅ | From `tool/result` via `presentResult`. |
| `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. |
+| `current_mode_update` | S | ✅ | ✅ | ✅ | Echoed optimistically on `session/set_mode` and re-notified on each logged `mode/set` that differs from the last sent (covers the `exit_plan_mode` tool flipping the session back). |
| `config_option_update` | S | ❌ | ✅ | ✅ | No config options. |
| `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. |
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
## 6. Session modes / config options / models
-❌ None modeled. Both reference adapters ship modes (Claude: a "plan" auto-mode; Codex: read-only / agent / agent-full-access mapping to its approval+sandbox policy), the newer config-option surface, and runtime model selection. The harness fixes the model per-bridge via `AcpConfig.model`. These are coupled to the unbuilt **permission gate** (a mode often selects an approval policy), so they are natural follow-ups to it.
+Session modes ✅ (the [plan-mode RFC](../../../docs/rfc/proposed/feature/2026-07-07-plan-mode.md)): the picker maps 1:1 onto `dsh-mode`'s vocabulary — `ctx.modes.list()` fills `availableModes`, `session/set_mode` calls `set()` (pending intent, flushed at the turn boundary), and `current_mode_update` tracks both the optimistic echo and every logged flip. The division is picker-to-modes / knobs-to-config-options: individual environment knobs (sandbox mode, approval policy, the model) are NOT modes and belong to `session/set_config_option` — still unbuilt here, as is runtime model selection (the harness fixes the model per-bridge via `AcpConfig.model`). The ACP draft v2 direction reportedly slates session modes for removal in favor of config options; if that lands, the picker migrates mechanically (the mode state and both enforcement layers are wire-agnostic).
## 7. Content blocks
@@ -142,7 +142,7 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl
1. **Permission gate** — `session/request_permission` + permission options. Tracked `TODO(rfc010-permission-gate)`; the reverse map is already wired and shared with `ask_user_question` routing. Foundational, and a prerequisite for modes.
2. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
-3. **Modes / config options / model selection** — coupled to the permission gate.
+3. **Config options / model selection** — session modes shipped with `dsh-mode`; the knob surface (`session/set_config_option`) is the sandbox stack's config phase.
4. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
5. **Slash commands** (`available_commands_update`).
6. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json
index 4ebc8485ce..a11ea4872d 100644
--- a/packages/ui/acp/package.json
+++ b/packages/ui/acp/package.json
@@ -29,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
+ "@deepseek-ai/dsh-mode": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
@@ -42,15 +43,16 @@
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-mode": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
+ "@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts
index 928ab7da77..2d9d48854c 100644
--- a/packages/ui/acp/src/index.ts
+++ b/packages/ui/acp/src/index.ts
@@ -60,7 +60,10 @@ import {
type PlanEntry,
type PromptRequest,
type PromptResponse,
+ type SessionModeState,
type SessionNotification,
+ type SetSessionModeRequest,
+ type SetSessionModeResponse,
type Stream,
type StopReason,
} from '@agentclientprotocol/sdk'
@@ -74,6 +77,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'
+// Type-only edge: makes `ctx.get('modes')` resolve the ModesService type when
+// @deepseek-ai/dsh-mode is composed; the runtime read stays opportunistic.
+import type {} from '@deepseek-ai/dsh-mode'
import {
UserInteractionError,
type AskUserQuestionAnswer,
@@ -282,6 +288,13 @@ interface SessionRecord {
* result terminal) or clobber the card (call terminal, result non-terminal).
*/
terminalEnabled: boolean
+ /**
+ * The last mode id this session sent to the client (advertised at
+ * session/new+load, echoed optimistically on session/set_mode, re-notified on
+ * each logged `mode/set` that differs). `undefined` when dsh-mode is not
+ * composed — no mode surface is advertised, so nothing is ever notified.
+ */
+ lastModeId: string | undefined
/**
* The in-flight `session/prompt`, or `undefined` when none is pending. A
* prompt resolves with a {@link StopReason} or rejects with an Error (a
@@ -457,6 +470,24 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- Stream the harness event taxonomy to ACP session/update --------------
+ // --- Session modes (dsh-mode, opportunistic) ------------------------------
+ // The mode PICKER is dsh-mode's ACP surface (the plan-mode RFC): advertised
+ // as `modes` on session/new + session/load, switched via session/set_mode —
+ // optimistic `current_mode_update` (the pending mode IS the user's
+ // selection; the logged `mode/set` follows at the turn boundary) — and
+ // re-notified on each logged flip that differs from the last sent (covers
+ // the exit_plan_mode tool flipping the session back). Environment knobs are
+ // NOT modes; they stay `session/set_config_option`.
+ const modesStateFor = (agent: Agent): SessionModeState | undefined => {
+ const modes = ctx.get('modes')
+ if (modes === undefined) return undefined
+ const { current, pending } = modes.get(agent)
+ return {
+ availableModes: modes.list().map(name => ({ id: name, name })),
+ currentModeId: pending ?? current,
+ }
+ }
+
// All content streaming AND the prompt settle flow through `session/event`,
// the canonical log: every assistant/chunk and tool/call/result is logged, so
// translating from the log makes live streaming and `session/load` replay
@@ -480,6 +511,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
enabled: rec.terminalEnabled,
cwd: session.header.cwd,
}, { includeUserMessages: false })
+ if (event.type === 'mode/set' && event.data.mode !== rec.lastModeId) {
+ rec.lastModeId = event.data.mode
+ notify({ sessionId: rec.sessionId, update: { sessionUpdate: 'current_mode_update', currentModeId: event.data.mode } })
+ }
const inflight = rec.inflight
if (inflight === undefined) return
if (event.type === 'turn/start') {
@@ -603,15 +638,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
agentOptions: agentOptions(config),
})
bySession.set(handle.agent, sessionId)
+ const modes = modesStateFor(handle.agent)
sessions.set(sessionId, {
sessionId,
agent: handle.agent,
dispose: () => handle.dispose(),
presenter: makePresenter(),
terminalEnabled: terminalOutputCap,
+ lastModeId: modes?.currentModeId,
inflight: undefined,
})
- return Promise.resolve({ sessionId })
+ return Promise.resolve({ sessionId, ...modes !== undefined ? { modes } : {} })
},
async loadSession(params: LoadSessionRequest): Promise {
@@ -680,12 +717,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
// 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 modes = modesStateFor(agent)
const record: SessionRecord = {
sessionId,
agent,
dispose: () => handle.dispose(),
presenter: makePresenter(),
terminalEnabled,
+ lastModeId: modes?.currentModeId,
inflight: undefined,
}
sessions.set(sessionId, record)
@@ -710,12 +749,32 @@ export function apply(ctx: Context, config: AcpConfig): void {
for (const event of agent.session.events) {
streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
}
- return {}
+ return modes !== undefined ? { modes } : {}
} finally {
loadingIds.delete(sessionId)
}
},
+ setSessionMode(params: SetSessionModeRequest): Promise {
+ assertOpen()
+ const rec = requireSession(SessionId(params.sessionId))
+ const modes = ctx.get('modes')
+ if (modes === undefined) throw invalidParams('session modes are not composed in this deployment')
+ try {
+ modes.set(rec.agent, params.modeId)
+ } catch (error) {
+ // ModesService.set throws only Error (its unknown-name validation).
+ throw invalidParams((error as Error).message)
+ }
+ // Optimistic echo: the pending mode IS the user's selection; the logged
+ // `mode/set` lands at the next turn boundary and, matching lastModeId,
+ // is not re-notified. A no-op selection (already current) echoes too —
+ // cheap, idempotent, and the picker settles regardless.
+ rec.lastModeId = params.modeId
+ notify({ sessionId: rec.sessionId, update: { sessionUpdate: 'current_mode_update', currentModeId: params.modeId } })
+ return Promise.resolve({})
+ },
+
async prompt(params: PromptRequest): Promise {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))
diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts
index a24c7aa145..ea2fdb9f76 100644
--- a/packages/ui/acp/tests/harness.ts
+++ b/packages/ui/acp/tests/harness.ts
@@ -24,6 +24,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
+import ModesService from '@deepseek-ai/dsh-mode'
import {
ClientSideConnection,
ndJsonStream,
@@ -180,6 +181,8 @@ export async function makeBridgeHarness(options: {
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
*/
withTodo?: boolean
+ /** Plug the REAL `dsh-mode` plugin so a test can drive the session-mode picker. */
+ withModes?: boolean
/**
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
* `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge
@@ -211,6 +214,9 @@ export async function makeBridgeHarness(options: {
if (options.withTodo) {
await ctx.plugin(ToolTodo)
}
+ if (options.withModes) {
+ await ctx.plugin(ModesService)
+ }
if (options.withFs) {
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })
await ctx.plugin(FsPolicy)
diff --git a/packages/ui/acp/tests/modes.spec.ts b/packages/ui/acp/tests/modes.spec.ts
new file mode 100644
index 0000000000..3d90738a41
--- /dev/null
+++ b/packages/ui/acp/tests/modes.spec.ts
@@ -0,0 +1,116 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+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'
+
+/** The `current_mode_update` notifications, in order. */
+function modeUpdates(updates: CapturedUpdate[]): string[] {
+ return updates
+ .filter(update => update.sessionUpdate === 'current_mode_update')
+ .map(update => update.currentModeId)
+}
+
+describe('acp bridge — session modes (dsh-mode)', () => {
+ let storageDir: string
+ let harness: BridgeHarness | undefined
+ let loader: BridgeHarness | undefined
+
+ beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-modes-')) })
+ afterEach(async () => {
+ if (harness) await harness.dispose()
+ if (loader) await loader.dispose()
+ harness = loader = undefined
+ await rm(storageDir, { recursive: true, force: true })
+ })
+
+ it('advertises no mode surface and rejects session/set_mode when dsh-mode is not composed', async () => {
+ harness = await makeBridgeHarness({ storageDir })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ expect(res.modes).toBeUndefined()
+ await expect(harness.client.setSessionMode({ sessionId: res.sessionId, modeId: 'plan' }))
+ .rejects.toMatchObject({ message: expect.stringContaining('session modes are not composed') as string })
+ })
+
+ it('advertises availableModes/currentModeId on session/new', async () => {
+ harness = await makeBridgeHarness({ storageDir, withModes: true })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ expect(res.modes).toEqual({
+ availableModes: [
+ { id: 'default', name: 'default' },
+ { id: 'plan', name: 'plan' },
+ ],
+ currentModeId: 'default',
+ })
+ })
+
+ it('session/set_mode records the pending intent and echoes one optimistic current_mode_update', async () => {
+ harness = await makeBridgeHarness({ storageDir, withModes: true })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
+ expect(modeUpdates(harness.updates)).toEqual(['plan'])
+ const agent = harness.ctx.agents.get(AgentId(sessionId))!
+ expect(harness.ctx.modes.get(agent)).toEqual({ current: 'default', pending: 'plan' })
+ })
+
+ it('rejects an unknown mode id with the service validation message', async () => {
+ harness = await makeBridgeHarness({ storageDir, withModes: true })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ await expect(harness.client.setSessionMode({ sessionId, modeId: 'nope' }))
+ .rejects.toMatchObject({ message: expect.stringContaining('unknown mode "nope"') as string })
+ expect(modeUpdates(harness.updates)).toEqual([])
+ })
+
+ it('does not re-notify when the boundary flush logs the mode the picker already showed', async () => {
+ harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
+ await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
+ const agent = harness.ctx.agents.get(AgentId(sessionId))!
+ expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(true)
+ expect(modeUpdates(harness.updates)).toEqual(['plan'])
+ })
+
+ it('re-notifies on a logged flip the picker has not seen (the tool-driven exit shape)', async () => {
+ harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
+ await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
+ // A writer other than the picker (exit_plan_mode's execute) appends the
+ // flip back; the bridge must re-notify the client off the logged event.
+ const agent = harness.ctx.agents.get(AgentId(sessionId))!
+ agent.session.append('mode/set', { mode: 'default' })
+ // The notification crosses the in-memory JSON-RPC transport asynchronously.
+ await new Promise(resolve => setTimeout(resolve, 20))
+ expect(modeUpdates(harness.updates)).toEqual(['plan', 'default'])
+ })
+
+ it('advertises the folded mode on session/load', async () => {
+ harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
+ await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
+ await harness.dispose()
+ harness = undefined
+
+ loader = await makeBridgeHarness({ storageDir, withModes: true, script: [] })
+ await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
+ expect(res.modes).toEqual({
+ availableModes: [
+ { id: 'default', name: 'default' },
+ { id: 'plan', name: 'plan' },
+ ],
+ currentModeId: 'plan',
+ })
+ })
+})
diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json
index 9c2358c455..39f68d52d8 100644
--- a/packages/ui/acp/tsconfig.json
+++ b/packages/ui/acp/tsconfig.json
@@ -32,6 +32,9 @@
{
"path": "../user-interaction"
},
+ {
+ "path": "../../mode/mode"
+ },
{
"path": "../../session-persistence/session-persistence"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e7da016e94..685b9ac738 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -625,6 +625,9 @@ importers:
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
+ '@deepseek-ai/dsh-user-interaction':
+ specifier: workspace:^
+ version: link:../../ui/user-interaction
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
@@ -1000,6 +1003,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
+ '@deepseek-ai/dsh-mode':
+ specifier: workspace:^
+ version: link:../../mode/mode
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json
index 49535f0efb..e02fbc80aa 100644
--- a/scripts/doc-budgets.manifest.json
+++ b/scripts/doc-budgets.manifest.json
@@ -5,7 +5,7 @@
"docs/cordis-primer.md": 550,
"docs/defensive-patterns.md": 550,
"docs/testing.md": 800,
- "examples/AGENTS.md": 653,
+ "examples/AGENTS.md": 680,
"packages/AGENTS.md": 450,
"packages/README.md": 660
}
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 92ecc820ce..b4a43e5650 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -140,7 +140,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'mode',
title: 'Session-mode policy state',
mode: 'core',
- consumers: ['stdio-agent'],
+ consumers: ['stdio-agent', 'acp'],
note: 'Folds the logged per-agent mode (mode/set), flushes user flips at turn boundaries, and enforces the mode through the assemble filter and the tools/pre-execute gate.',
},
{
@@ -444,6 +444,14 @@ const APP_EXAMPLES = [
config: 'examples/acp-agent/cordis.yml',
summary: 'The ACP demo exposes the same agent spine over JSON-RPC stdio, with no stdout logger and no pre-created agent; clients create sessions through the ACP bridge.',
},
+ {
+ id: 'plan-acp',
+ rel: 'examples/plan-acp-agent/composition.md',
+ title: 'Plan-Mode ACP Agent App Composition',
+ label: 'examples/plan-acp-agent',
+ config: 'examples/plan-acp-agent/cordis.yml',
+ summary: 'The plan-mode demo composes session modes onto the ACP server: the editor mode picker drives plan mode, and the model exits through the user-reviewed exit_plan_mode tool.',
+ },
]
type AppExample = typeof APP_EXAMPLES[number]
diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts
index 9c25bd3ded..4d564509ac 100644
--- a/scripts/gen-tool-catalog.ts
+++ b/scripts/gen-tool-catalog.ts
@@ -42,6 +42,7 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
+import ModesService from '@deepseek-ai/dsh-mode'
import WebService from '@deepseek-ai/dsh-web'
import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
@@ -137,6 +138,18 @@ const TOOL_PACKAGES: ToolPackage[] = [
note:
'Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time.',
},
+ {
+ pkg: '@deepseek-ai/dsh-mode',
+ dir: 'mode',
+ source: 'packages/mode/mode/src/index.ts',
+ requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.userInteraction (execution time, opportunistic)'],
+ writes: ['tool/call', 'mode/set back to default on an approved review', 'tool/result'],
+ async mount(ctx) {
+ await ctx.plugin(ModesService)
+ },
+ note:
+ 'exit_plan_mode presents the plan for the user\'s review over the user-interaction seam (approve / keep planning with feedback); approval flips the logged session mode back to default. The assemble filter shows it only while the folded mode is plan.',
+ },
{
pkg: '@deepseek-ai/dsh-tool-bash',
dir: 'tool-bash',