feat(mode): register one slash command per mode

Replace /mode [name] with same-named no-argument commands such as /plan. Update the TUI and ACP snapshots, and validate mode names against command syntax.
This commit is contained in:
Tianyi Cui
2026-07-22 14:32:35 +08:00
parent b0008546be
commit a8a18f309a
70 changed files with 205 additions and 144 deletions

View File

@@ -6,4 +6,4 @@ Session modes: named, logged, per-agent collaboration states, with **plan mode**
|---|---|---|
| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the `mode:policy` guidance section, 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 deployment supplies plan instructions through Cordis config, while `exit_plan_mode` remains registered in every mode to keep the request tool catalog stable. UIs read flips off `session/event`; the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker, and a composed [command registry](../ui/commands) gains the plugin-registered `/mode` command. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md).
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 deployment supplies plan instructions through Cordis config, while `exit_plan_mode` remains registered in every mode to keep the request tool catalog stable. UIs read flips off `session/event`; the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker, and a composed [command registry](../ui/commands) gains one entry command per configured definition (`/plan` for the required definition). Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md).

View File

@@ -20,9 +20,11 @@ A mode does not gate execution, filter tools, or change sandbox or approval sett
There is no creation-time mode option: a UI (or a plugin) selects through `set()` before the first turn, and a fork child needs no mechanism at all — the parent's `mode/set` is inside the seeded prefix.
## The `/mode` command
## Per-mode slash commands
When a command registry (`@deepseek-ai/dsh-commands`) is composed, the plugin registers `/mode` for interactive front doors: bare `/mode` prints the current mode (plus any pending switch) and the available vocabulary; `/mode <name>` records the switch through `set()` and echoes that it applies from the next turn. Without a commands service the child never mounts and nothing else changes.
When a command registry (`@deepseek-ai/dsh-commands`) is composed, each configured definition contributes its own entry command to interactive front doors. The required definition supplies `/plan`; a further `review` definition supplies `/review`. These commands accept no arguments, record the switch through `set()`, and report that it applies from the next turn. `default` is the absence of a definition and contributes no command. Without a commands service the child never mounts and nothing else changes.
Definition names must match `/^[a-z][a-z0-9_-]*$/u`, the shared mode/command subset; config fails at load before a definition can become selectable but undispatchable.
## `exit_plan_mode`
@@ -44,7 +46,7 @@ The deployment must provide the complete plan-mode instructions in Cordis config
You are in plan mode. Explore first, make no changes, and present a decision-complete plan through exit_plan_mode.
```
`resolveConfig` requires `modes.plan.section`, rejects `default` as a definition key, rejects blank sections and unknown definition keys, and preserves any further named modes. `set()` rejects an unknown mode name.
`resolveConfig` requires `modes.plan.section`, rejects `default` as a definition key, rejects invalid command-shaped names, blank sections, and unknown definition keys, and preserves any further named modes. `set()` rejects an unknown mode name.
## Model Experience

View File

@@ -38,8 +38,8 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
// Type-only edge: resolves `ctx.commands` for the `/mode` command child below;
// the child mounts only when a commands service is composed.
// Type-only edge: resolves `ctx.commands` for the per-mode command child
// below; the child mounts only when a commands service is composed.
import type {} from '@deepseek-ai/dsh-commands'
declare module '@deepseek-ai/dsh-session' {
@@ -69,6 +69,10 @@ export const DEFAULT_MODE = 'default'
/** The required plan definition's name. */
export const PLAN_MODE = 'plan'
// Every definition contributes a same-named slash command when the optional
// command registry is composed, so mode names use that stable common subset.
const MODE_NAME = /^[a-z][a-z0-9_-]*$/u
/**
* The model-facing exit tool's name. It stays registered in every mode so the
* request tool catalog is stable; execution outside {@link PLAN_MODE} rejects.
@@ -89,8 +93,8 @@ export interface ModeDefinition {
/**
* Plugin config: mode definitions by name. The deployment must define
* {@link PLAN_MODE}, including its complete model instructions;
* {@link DEFAULT_MODE} is rejected as a key ({@link resolveConfig} throws at
* load).
* {@link DEFAULT_MODE} is rejected as a key and definition names must be valid
* slash-command names ({@link resolveConfig} throws at load).
*/
export interface ModeConfig {
/** Mode definitions by name; `plan` is required and owns its full prompt text. */
@@ -148,6 +152,9 @@ export function resolveConfig(config: ModeConfig): ResolvedModes {
if (name.trim() === '' || name.trim() !== name) {
throw new Error(`ModeConfig: mode name ${JSON.stringify(name)} must be non-empty and trimmed`)
}
if (!MODE_NAME.test(name)) {
throw new Error(`ModeConfig: mode name ${JSON.stringify(name)} must match ${String(MODE_NAME)} for its slash command`)
}
if (typeof definition.section !== 'string') {
throw new Error(`ModeConfig: mode "${name}" needs a string \`section\``)
}
@@ -286,31 +293,23 @@ export class ModesService extends Service {
text: context => (context.agent === undefined ? '' : this.activeDefinition(context.agent.session)?.definition.section ?? ''),
})
// The `/mode` command (show or switch the session mode) for interactive
// front doors, mounted only when a commands service is composed — the
// child plugin below activates on `ctx.commands` availability, so a
// commands-less deployment composes dsh-mode unchanged.
// Each configured definition contributes its own entry command to
// interactive front doors. The child activates only when `ctx.commands`
// is available, so a commands-less deployment composes dsh-mode unchanged.
ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'mode',
description: 'Show or switch the session mode',
input: { hint: '[name]' },
handler: ({ agent, rawInput }) => {
const target = rawInput.trim()
if (target === '') {
const { current, pending } = this.get(agent)
const pendingNote = pending === undefined ? '' : ` (pending: ${pending})`
return { kind: 'success', text: `mode: ${current}${pendingNote} — available: ${this.list().join(', ')}` }
}
try {
this.set(agent, target)
return { kind: 'success', text: `mode → ${target} (applies from the next turn)` }
} catch (error) {
// ModesService.set throws only Error (its unknown-name validation).
return { kind: 'error', text: (error as Error).message }
}
},
})
for (const mode of this.resolved.definitions.keys()) {
commandCtx.commands.register({
name: mode,
description: `Enter ${mode} mode`,
handler: ({ agent, rawInput }) => {
if (rawInput.trim() !== '') {
return { kind: 'error', text: `Usage: /${mode}` }
}
this.set(agent, mode)
return { kind: 'success', text: `Entering ${mode} mode (applies from the next turn).` }
},
})
}
})
ctx.tools.register(defineTool({

View File

@@ -150,6 +150,13 @@ describe('resolveConfig', () => {
.toThrow('mode name " review " must be non-empty and trimmed')
})
it('rejects mode names that cannot also name their slash commands', () => {
for (const name of ['Review', 'review mode', '1-review', 'review!']) {
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, [name]: { section: 'x' } } }))
.toThrow(`mode name ${JSON.stringify(name)} must match /^[a-z][a-z0-9_-]*$/u for its slash command`)
}
})
it('rejects a malformed definition loudly', () => {
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, bad: { section: 5 } as unknown as { section: string } } }))
.toThrow('needs a string `section`')
@@ -590,30 +597,52 @@ describe('no execution gating beyond the exit tool', () => {
})
describe('the /mode command', () => {
it('registers only when a commands service is composed, and shows or switches the mode', async () => {
describe('per-mode slash commands', () => {
it('registers one entry command per configured mode only when a commands service is composed', async () => {
const bare = await setup()
expect(bare.get('commands')).toBeUndefined()
const ctx = await setup()
const ctx = await setup({ modes: {
plan: { section: TEST_PLAN_SECTION },
review: { section: 'Review mode instructions.' },
} })
await ctx.plugin(CommandService)
// The `ctx.inject` child mounts asynchronously once `commands` resolves.
await new Promise(resolve => setImmediate(resolve))
const agent = await agentWithSession(ctx)
expect(ctx.commands.list(agent).map(command => command.name)).toEqual(['mode'])
expect(ctx.commands.list(agent)).toEqual([
{ name: 'plan', description: 'Enter plan mode' },
{ name: 'review', description: 'Enter review mode' },
])
const signal = new AbortController().signal
const show = await ctx.commands.execute(agent, '/mode', signal)
expect(show).toEqual({ kind: 'success', text: 'mode: default — available: default, plan' })
const flip = await ctx.commands.execute(agent, '/mode plan', signal)
expect(flip).toEqual({ kind: 'success', text: 'mode → plan (applies from the next turn)' })
expect(await ctx.commands.execute(agent, '/mode', signal)).toBeUndefined()
expect(await ctx.commands.execute(agent, '/plan later', signal))
.toEqual({ kind: 'error', text: 'Usage: /plan' })
const plan = await ctx.commands.execute(agent, '/plan', signal)
expect(plan).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next turn).' })
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
const pendingShow = await ctx.commands.execute(agent, '/mode', signal)
expect(pendingShow).toEqual({ kind: 'success', text: 'mode: default (pending: plan) — available: default, plan' })
const review = await ctx.commands.execute(agent, '/review', signal)
expect(review).toEqual({ kind: 'success', text: 'Entering review mode (applies from the next turn).' })
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: 'review' })
})
const unknown = await ctx.commands.execute(agent, '/mode nope', signal)
expect(unknown).toEqual({ kind: 'error', text: 'unknown mode "nope" — available modes: default, plan' })
it('removes every contributed command when the mode plugin is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(CommandService)
const fiber = await ctx.plugin(ModesService, { modes: {
plan: { section: TEST_PLAN_SECTION },
review: { section: 'Review mode instructions.' },
} })
await new Promise(resolve => setImmediate(resolve))
const agent = await agentWithSession(ctx)
expect(ctx.commands.list(agent).map(command => command.name)).toEqual(['plan', 'review'])
await fiber.dispose()
expect(ctx.commands.list(agent)).toEqual([])
})
})