refactor(plan): remove generic mode abstraction

This commit is contained in:
Tianyi Cui
2026-07-22 16:57:23 +08:00
parent 92da23270d
commit f4185122dc
61 changed files with 990 additions and 1161 deletions

View File

@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
## At a glance
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection and permission presets, and **session modes** (the picker, via `@deepseek-ai/dsh-mode`). The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection and permission presets, and **session modes** (the picker, via `@deepseek-ai/dsh-plan-mode`). The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
## 1. Agent methods (client → agent)
@@ -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 | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. |
| `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. |
| `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_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-plan-mode` mounted, `session/new`/`session/load` advertise the fixed `default` / `plan` projection and `session/set_mode` records the boolean pending intent (optimistic `current_mode_update`; logged `plan/mode` 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 | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |
| model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. |
| `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. |
@@ -85,7 +85,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. |
| `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 | ✅ | ✅ | ✅ | Full effective snapshot after create/load and registry changes; names, descriptions, and unstructured-input hints come from `ctx.commands`. |
| `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). |
| `current_mode_update` | S | ✅ | ✅ | ✅ | Echoed optimistically on `session/set_mode` and re-notified when a logged `plan/mode` maps to a different wire id (covers the `exit_plan_mode` tool flipping the session back). |
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox Agent Note § Per-session mode switching](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). |
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
| `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. |
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
## 6. Session modes / config options / models
Session modes ✅ (the [plan-mode Agent Note](../../../.agents/notes/implemented/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. Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. The division is picker-to-modes / knobs-to-config-options: individual environment knobs and the provider/model selector are not modes. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Session modes ✅ (the [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md)): ACP owns the fixed `default` / `plan` wire vocabulary and projects it onto `ctx.planMode`'s boolean `{ active, pending? }` state; `session/set_mode` calls `set()` and `current_mode_update` tracks the optimistic selection plus each distinct committed `plan/mode` flip. Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. The division is picker-to-collaboration-state / knobs-to-config-options: individual environment knobs and the provider/model selector are not modes. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## 7. Content blocks

View File

@@ -38,7 +38,7 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-mode": "^0.0.1",
"@deepseek-ai/dsh-plan-mode": "^0.0.1",
"@deepseek-ai/dsh-permission": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -62,7 +62,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-mode": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -67,9 +67,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'
// Type-only edge: resolves `ctx.get('planMode')` when dsh-plan-mode is composed;
// the runtime read stays opportunistic.
import type {} from '@deepseek-ai/dsh-plan-mode'
// Side-effect type import: declaration-merges prompt assembly onto Context and
// the scoped waterfall used to keep persona variables aligned with requests.
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -100,6 +100,18 @@ function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
}
const DEFAULT_SESSION_MODE_ID = 'default'
const PLAN_SESSION_MODE_ID = 'plan'
const AVAILABLE_SESSION_MODES = [
{ id: DEFAULT_SESSION_MODE_ID, name: DEFAULT_SESSION_MODE_ID },
{ id: PLAN_SESSION_MODE_ID, name: PLAN_SESSION_MODE_ID },
]
/** Map plan state onto ACP's named collaboration-mode protocol. */
function sessionModeId(active: boolean): string {
return active ? PLAN_SESSION_MODE_ID : DEFAULT_SESSION_MODE_ID
}
/** Render arbitrary thrown values without trusting their string coercion. */
function renderThrown(value: unknown): string {
try {
@@ -298,8 +310,8 @@ interface SessionRecord {
/**
* 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.
* each logged `plan/mode` that differs). `undefined` when dsh-plan-mode is
* not composed, so no mode surface is advertised or notified.
*/
lastModeId: string | undefined
/** Session-local provider/model selection and the current step snapshot. */
@@ -558,21 +570,18 @@ 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 Agent Note): 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`.
// --- Session modes (dsh-plan-mode, opportunistic) -------------------------
// ACP's generic mode picker projects the one plan capability as the fixed
// `default` / `plan` vocabulary. A selection is echoed optimistically; the
// logged `plan/mode` follows at the boundary and tool-driven exits are
// re-notified from that event. Environment knobs remain config options.
const modesStateFor = (agent: Agent): SessionModeState | undefined => {
const modes = ctx.get('modes')
if (modes === undefined) return undefined
const { current, pending } = modes.get(agent)
const planMode = ctx.get('planMode')
if (planMode === undefined) return undefined
const { active, pending } = planMode.get(agent)
return {
availableModes: modes.list().map(name => ({ id: name, name })),
currentModeId: pending ?? current,
availableModes: AVAILABLE_SESSION_MODES,
currentModeId: sessionModeId(pending ?? active),
}
}
@@ -601,16 +610,19 @@ export function apply(ctx: Context, config: AcpConfig): void {
cwd: session.header.cwd,
}, { includeUserMessages: false })
} finally {
// Re-notify from the EVENT's value, not from modes.get(): the service
// Re-notify from the EVENT's value, not from planMode.get(): the service
// holds one coalesced pending slot (every flush reads the latest
// selection, so a flush can never be stale against the picker), and for
// any other writer — the exit tool, a test, a foreign plugin — the logged
// value IS the truth the picker should track, in log order. Inside the
// containment `finally` like the prompt settlement: a throwing presenter
// must not desync the picker.
if (event.type === 'mode/set' && event.data.mode !== rec.lastModeId) {
rec.lastModeId = event.data.mode
notify({ sessionId: rec.agent.session.id, update: { sessionUpdate: 'current_mode_update', currentModeId: event.data.mode } })
if (event.type === 'plan/mode') {
const modeId = sessionModeId(event.data.active)
if (modeId !== rec.lastModeId) {
rec.lastModeId = modeId
notify({ sessionId: rec.agent.session.id, update: { sessionUpdate: 'current_mode_update', currentModeId: modeId } })
}
}
const inflight = rec.inflight
if (inflight !== undefined && event.type === 'turn/start') {
@@ -910,16 +922,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
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)
const planMode = ctx.get('planMode')
if (planMode === undefined) throw invalidParams('session modes are not composed in this deployment')
if (params.modeId !== DEFAULT_SESSION_MODE_ID && params.modeId !== PLAN_SESSION_MODE_ID) {
throw invalidParams(`unknown session mode ${JSON.stringify(params.modeId)} — available modes: default, plan`)
}
planMode.set(rec.agent, params.modeId === PLAN_SESSION_MODE_ID)
// Optimistic echo: the pending mode IS the user's selection; the logged
// `mode/set` lands at the next turn boundary and, matching lastModeId,
// `plan/mode` 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

View File

@@ -17,7 +17,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 PlanModeService from '@deepseek-ai/dsh-plan-mode'
import {
ClientSideConnection,
ndJsonStream,
@@ -192,7 +192,7 @@ 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. */
/** Plug the REAL `dsh-plan-mode` plugin so a test can drive the session-mode picker. */
withModes?: boolean
/**
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
@@ -229,7 +229,7 @@ export async function makeBridgeHarness(options: {
await ctx.plugin(ToolTodo)
}
if (options.withModes) {
await ctx.plugin(ModesService, { modes: { plan: { section: 'Test plan mode instructions.' } } })
await ctx.plugin(PlanModeService, { section: 'Test plan mode instructions.' })
}
if (options.withFs) {
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })

View File

@@ -13,7 +13,7 @@ function modeUpdates(updates: CapturedUpdate[]): string[] {
.map(update => update.currentModeId)
}
describe('acp bridge — session modes (dsh-mode)', () => {
describe('acp bridge — plan mode projection', () => {
let storageDir: string
let harness: BridgeHarness | undefined
let loader: BridgeHarness | undefined
@@ -26,7 +26,7 @@ describe('acp bridge — session modes (dsh-mode)', () => {
await rm(storageDir, { recursive: true, force: true })
})
it('advertises no mode surface and rejects session/set_mode when dsh-mode is not composed', async () => {
it('advertises no mode surface and rejects session/set_mode when plan 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: [] })
@@ -55,15 +55,15 @@ describe('acp bridge — session modes (dsh-mode)', () => {
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
expect(modeUpdates(harness.updates)).toEqual(['plan'])
const agent = harness.ctx.agents.get(SessionId(sessionId))!
expect(harness.ctx.modes.get(agent)).toEqual({ current: 'default', pending: 'plan' })
expect(harness.ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('rejects an unknown mode id with the service validation message', async () => {
it('rejects an unknown ACP mode id at the adapter boundary', 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 })
.rejects.toMatchObject({ message: expect.stringContaining('unknown session mode "nope"') as string })
expect(modeUpdates(harness.updates)).toEqual([])
})
@@ -74,7 +74,7 @@ describe('acp bridge — session modes (dsh-mode)', () => {
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(true)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(true)
expect(modeUpdates(harness.updates)).toEqual(['plan'])
})
@@ -87,7 +87,7 @@ describe('acp bridge — session modes (dsh-mode)', () => {
// 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(SessionId(sessionId))!
agent.session.append('mode/set', { mode: 'default' })
agent.session.append('plan/mode', { active: false })
// The notification crosses the in-memory JSON-RPC transport asynchronously.
await new Promise(resolve => setTimeout(resolve, 20))
expect(modeUpdates(harness.updates)).toEqual(['plan', 'default'])

View File

@@ -42,7 +42,7 @@
"path": "../user-interaction"
},
{
"path": "../../mode/mode"
"path": "../../plan/plan-mode"
},
{
"path": "../../session-persistence/session-persistence"