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:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user