feat(mode): exit_plan_mode + the ACP session-mode picker + scriptable review answers
Plan mode's stage 2 (RFC 2026-07-07-plan-mode). The exit tool: one required plan argument (the durable log artifact), execute re-checks the folded mode, then conducts the review over the user-interaction seam — one single-select question (Approve / Keep planning) with free text open — so an approval appends mode/set back to default in-turn and every other outcome (keep-planning feedback verbatim, aborted, no provider) returns the corrective isError with the mode unchanged. presentCall is a generic card titled by the plan's first heading carrying the plan markdown; over ACP the review rides the ask_user elicitation flow, in the terminal the stdio prompt queue — no approval-seam dependency. The ACP bridge maps the picker 1:1 onto ctx.modes (opportunistic, a type-only peer edge): session/new + session/load advertise availableModes/currentModeId, session/set_mode validates through set() and echoes an optimistic current_mode_update (the pending mode IS the selection; the logged mode/set lands at the boundary and, matching, is not re-sent), and a session/event listener re-notifies on each logged flip that differs from the last sent — the tool-driven exit updates the picker. The feature matrix rows move from 'not modeled' to the picker-to-modes / knobs-to-config-options division, with the ACP v2 removal direction recorded as a mechanical-migration risk. The snapshot harness gains the setMode/setModeExpectError ops and a scripted elicitationAnswers FIFO (cancel on exhaustion; a stray choice string reaches the agent verbatim as a non-consenting custom answer, so a scenario bug fails safe). The suite factory's header-pin requirement now applies only to model-turn scenarios — a protocol-only suite has no header content to anchor. examples/plan-acp-agent is the live composition; its keyless modes-advertise scenario pins the wire surface (advertisement, both set_mode round-trips, unknown-id rejection). The recorded plan-mode approve/reject arc awaits a with-key recording session; its texts are pinned at the unit tier meanwhile. examples/AGENTS.md ceiling 653 → 680: the new example's required smoke row does not fit the old budget.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
19
examples/plan-acp-agent/README.md
Normal file
19
examples/plan-acp-agent/README.md
Normal file
@@ -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`).
|
||||
46
examples/plan-acp-agent/composition.md
Normal file
46
examples/plan-acp-agent/composition.md
Normal file
@@ -0,0 +1,46 @@
|
||||
<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.
|
||||
Run `pnpm run gen-doc-graphs` to regenerate. -->
|
||||
|
||||
# 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<br/>cordis.yml"]
|
||||
plugin_plan-acp_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
|
||||
cfg --> plugin_plan-acp_llm_deepseek
|
||||
plugin_plan-acp_acp_agent["acp-agent<br/>@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<br/>JSON-RPC stdio bridge<br/>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<br/>@deepseek-ai/dsh-mode"]
|
||||
cfg --> plugin_plan-acp_mode
|
||||
plugin_plan-acp_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"]
|
||||
cfg --> plugin_plan-acp_fs_local
|
||||
plugin_plan-acp_fs_policy["fs-policy<br/>@deepseek-ai/dsh-fs-policy"]
|
||||
cfg --> plugin_plan-acp_fs_policy
|
||||
plugin_plan-acp_tool_fs["tool-fs<br/>@deepseek-ai/dsh-tool-fs"]
|
||||
cfg --> plugin_plan-acp_tool_fs
|
||||
plugin_plan-acp_tool_todo["tool-todo<br/>@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.
|
||||
27
examples/plan-acp-agent/cordis.snapshot.yml
Normal file
27
examples/plan-acp-agent/cordis.snapshot.yml
Normal file
@@ -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'
|
||||
65
examples/plan-acp-agent/cordis.yml
Normal file
65
examples/plan-acp-agent/cordis.yml
Normal file
@@ -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'
|
||||
7
examples/plan-acp-agent/package.json
Normal file
7
examples/plan-acp-agent/package.json
Normal file
@@ -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"
|
||||
}
|
||||
38
examples/plan-acp-agent/tests/acp.snapshot.ts
Normal file
38
examples/plan-acp-agent/tests/acp.snapshot.ts
Normal file
@@ -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',
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"op": "initialize"
|
||||
},
|
||||
{
|
||||
"op": "newSession"
|
||||
},
|
||||
{
|
||||
"op": "setMode",
|
||||
"modeId": "plan"
|
||||
},
|
||||
{
|
||||
"op": "setMode",
|
||||
"modeId": "default"
|
||||
},
|
||||
{
|
||||
"op": "setModeExpectError",
|
||||
"modeId": "yolo"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0}
|
||||
@@ -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"}}
|
||||
Reference in New Issue
Block a user