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"

View File

@@ -22,7 +22,7 @@ The terminal and ACP app bundles mount this service with their consuming front d
#### What the model sees
The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-mode`](../../mode/mode/README.md#per-mode-slash-commands) submits the optional message in `/plan [message]` after selecting the mode.
The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) submits the optional message in `/plan [message]` after selecting plan mode.
#### Token effect

View File

@@ -14,7 +14,7 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly and unknown commands produce a warning, with no automatic fallthrough to the model. A command producer may explicitly schedule agent work; [`dsh-mode`](../../mode/mode/README.md#per-mode-slash-commands) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly and unknown commands produce a warning, with no automatic fallthrough to the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.