refactor(mode): move exit-tool visibility onto the registry restriction layer; register /mode; drop unconsumed surfaces

The master merge brought the tool registry's per-scope restriction layer
(tools.restrict), which makes dsh-mode's prepend assemble filter a duplicate
enforcement shape: it re-implemented the registry's SDK-section rendering
(renderToolsSdk + the RUN_CODE_NAME exclusion) and hid the exit tool from
prompts only — dispatch stayed open and the execute-time re-check was the
real gate. The service now reconciles a per-agent deny restriction on
agent.ctx at agent/created and at every boundary flush, so wire schemas, the
Code Mode tools:sdk section, AND dispatch resolve exit_plan_mode through the
one registry view (a default-mode call answers UNKNOWN_TOOL, byte-identical
to a no-dsh-mode deployment). The execute-time folded-mode re-check stays as
defense in depth for a direct foreign mode/set append no boundary has
reconciled yet.

Two zero-consumer surfaces removed per the pre-release stance:
- AgentOptions.mode creation seeding (declaration merge + agent/created
  listener); a caller selects through set() before the first turn, and the
  deferred subagent inheritance returns together with its consumer.
- The dropped-definition boundary notice (droppedNoticed + narration):
  custom mode definitions have no production consumer, so nothing can be
  dropped; fold-to-default degradation is unchanged.

The stdio removal had left plan mode ACP-only while docs still claimed a
/mode command. dsh-mode now registers /mode on the plugin-owned command
registry through an optional ctx.inject(['commands']) child (type-only peer
edge on dsh-commands), so the TUI and the ACP slash-command surface both
gain it; examples/tui-agent composes dsh-mode. ACP/TUI expected outputs
refreshed keyless for the available_commands_update delta.

Docs updated in place (mode READMEs, the plan-mode Agent Note's realization
sections); catalogs and graphs regenerated.
This commit is contained in:
kingwl
2026-07-21 11:11:35 +08:00
parent 81c239bc60
commit a00d1e0496
73 changed files with 351 additions and 255 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 default mode is the absence of policy, keeping the plugin invisible until a mode is set. UIs read flips off `session/event`; the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker. 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 default mode is the absence of policy, keeping the plugin invisible until a mode is set. 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).

View File

@@ -10,15 +10,19 @@ The `default` mode is the absence of policy: no section, no extra tool. An agent
## What a mode carries
**The guidance section.** A `system-prompt/assemble` listener renders the mode's `section` text as the `mode:policy` section (order 50) while the mode is in force, and shows the `exit_plan_mode` tool IFF the folded mode is `plan` — on the wire and, under the registry's Code Mode, in the `tools:sdk` section alike. Every transition therefore surfaces as an attributable complete `request/header` event on the next step; entering or leaving plan changes both the section and the exit-tool catalog.
**The guidance section.** A `system-prompt/assemble` listener renders the mode's `section` text as the `mode:policy` section (order 50) while the mode is in force. The `exit_plan_mode` tool is visible IFF the folded mode is `plan`: outside plan the service holds a per-agent deny restriction on the tool registry's scoped layer (`agent.ctx.tools.restrict`), so wire schemas, the Code Mode `tools:sdk` section, and dispatch all resolve it through the one registry view — a default-mode call answers `unknown tool`, exactly as in a deployment without this plugin. The restriction is reconciled with the fold at agent creation and at every boundary flush. Every transition therefore surfaces as an attributable complete `request/header` event on the next step; entering or leaving plan changes both the section and the exit-tool catalog.
**Deliberately absent: enforcement.** A mode never gates execution, filters the toolset, or touches the sandbox/approval knobs — those are independent axes the user switches separately (a deployment that wants a hard read-only floor while planning flips the sandbox-mode option beside the mode picker, in either order; neither disturbs the other). A per-mode tool allow/deny list is likewise out: which tools a mode admits is an effects question — a per-tool read-only/mutating classification the harness does not yet have — parked until tool definitions declare their effects (the plan-mode RFC's deferred item). The config vocabulary is exactly `{ section }`, and an unknown key (a `tools` list or an `access` cap included) fails loud at load.
**Deliberately absent: enforcement.** A mode never gates execution of the deployment's toolset or touches the sandbox/approval knobs — those are independent axes the user switches separately (a deployment that wants a hard read-only floor while planning flips the sandbox-mode option beside the mode picker, in either order; neither disturbs the other). The one thing a mode DOES fence is its own contribution: the exit tool exists only as plan mode's crossing, so hiding it outside plan removes a binding that could only error, not a capability. A per-mode tool allow/deny list over the rest stays out: which tools a mode admits is an effects question — a per-tool read-only/mutating classification the harness does not yet have — parked until tool definitions declare their effects (the plan-mode Agent Note's deferred item). The config vocabulary is exactly `{ section }`, and an unknown key (a `tools` list or an `access` cap included) fails loud at load.
## `ctx.modes`
`list()` returns the selectable vocabulary (`default` first, then the configured definitions); `get(agent)` returns the folded mode (a folded name the config no longer defines reads as `default`) plus any pending intent; `set(agent, mode)` validates against `list()` (loud on unknown; `default` is always a valid target) and records a pending intent — every session event is turn-enclosed and an idle agent has no open turn, so the service flushes the intent on the loop's interception seams (`agent/prompt-submit` inside the just-opened turn, `agent/turn-continuation` after each step closed — both outside any log emit, where a post-commit `session/event` observer could not append) and, when the flushed mode differs from what the last logged request header told the model, appends one coalesced `context/message` notice in the same frame. A net-zero flip sequence appends nothing.
`AgentOptions.mode` (declaration-merged) seeds a child's initial mode through the same pending-intent flush; explicit options beat the logged baseline on create AND resume. A fork child needs no mechanism — the parent's `mode/set` is inside the seeded prefix.
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
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.
## `exit_plan_mode`

View File

@@ -23,16 +23,23 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-commands": {
"optional": true
}
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

View File

@@ -14,7 +14,10 @@
*
* The default mode is the absence of policy: no section, no extra tool. An
* agent that never sees a `mode/set` behaves byte-identically to a deployment
* that never loads this plugin, so it is safe to compose unconditionally.
* that never loads this plugin, so it is safe to compose unconditionally. The
* exit tool's per-agent visibility rides the tool registry's scoped
* restriction layer, so wire schemas, the Code Mode SDK section, and dispatch
* all resolve it through the one registry view.
*
* User flips go through {@link ModesService.set}: every session event is
* turn-enclosed and an idle agent has no open turn, so `set()` records a
@@ -35,9 +38,12 @@
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool, renderToolsSdk, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
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.
import type {} from '@deepseek-ai/dsh-commands'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
@@ -50,17 +56,6 @@ declare module '@deepseek-ai/dsh-session' {
}
}
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
/**
* Initial session mode for this agent. Applied as a pending intent flushed
* at the first turn boundary; an explicit option beats the logged baseline
* on create AND resume. An unknown name throws at agent creation.
*/
mode?: string
}
}
declare module 'cordis' {
interface Context {
modes: ModesService
@@ -227,8 +222,14 @@ export class ModesService extends Service {
*/
private readonly pendingIntents = new WeakMap<Session, { mode: string; narrate: boolean }>()
/** The unknown folded-mode name already narrated per session (once per name). */
private readonly droppedNoticed = new WeakMap<Session, string>()
/**
* Per-agent deny-restriction disposer over the exit tool, present exactly
* while the agent's folded mode is NOT plan. The restriction lives on
* `agent.ctx` (the registry's scoped layer), so it fences wire schemas, the
* Code Mode SDK section, AND dispatch through the one registry view, and it
* unwinds with the agent. Entries die with their agents (WeakMap).
*/
private readonly exitToolDenials = new WeakMap<Agent, () => void>()
constructor(ctx: Context, config: ModeConfig = {}) {
super(ctx, 'modes')
@@ -247,7 +248,7 @@ export class ModesService extends Service {
// rejecting mid-teardown.
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
try {
this.onBoundary(agent.session, true)
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
}
@@ -255,17 +256,19 @@ export class ModesService extends Service {
})
ctx.on('agent/turn-continuation', (agent, _turn, _decision, next) => {
try {
this.onBoundary(agent.session, false)
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
}
return next()
})
// Seed the visibility restriction at creation: a fresh or resumed agent
// outside plan must not advertise (or dispatch) the exit tool. A throw
// here is a structural bug in this plugin, not a policy outcome, so it
// stays loud (it vetoes the creation, matching fail-loud misconfiguration).
ctx.on('agent/created', (agent) => {
const seed = agent.options.mode
if (seed === undefined) return
this.set(agent, seed)
this.syncExitToolVisibility(agent)
})
ctx.systemPrompt.section({
@@ -274,39 +277,32 @@ export class ModesService extends Service {
text: context => (context.agent === undefined ? '' : this.activeDefinition(context.agent.session)?.definition.section ?? ''),
})
// prepend: the filter wraps OUTSIDE every append-registered listener
// regardless of load order, so their post-next() additions are filtered
// too. It hides exactly ONE thing: the always-registered exit tool, wherever
// the folded mode is not plan — which keeps a default-mode assembly
// byte-identical to a no-dsh-mode deployment (whose registry never saw the
// tool) and keeps custom modes from advertising a binding that only errors.
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
const result = await next()
const agent = context.agent
if (agent === undefined) return result
if (this.activeDefinition(agent.session)?.name === PLAN_MODE) return result
result.tools = result.tools.filter(tool => tool.name !== EXIT_PLAN_MODE)
// Code Mode's soft surface is the SDK section, not the wire schemas —
// section text resolves in assemble's base, so the outermost wrapper
// re-renders it under the same visibility rule the wire filter applies.
rerenderSdk(result, name => name !== EXIT_PLAN_MODE)
return result
}, { prepend: true })
/**
* Re-render the `tools:sdk` section (present only under the registry's
* Code Mode) from the registry schemas the given rule admits — minus
* `run_code` itself, mirroring the registry's own exclusion. A no-op when
* the section is absent (native mode).
*/
function rerenderSdk(result: { sections: { name: string; text: string }[] }, include: (name: string) => boolean): void {
const sdkIndex = result.sections.findIndex(section => section.name === 'tools:sdk')
if (sdkIndex < 0) return
const sdkText = renderToolsSdk(ctx.tools.schemas().filter(schema =>
include(schema.name) && schema.name !== RUN_CODE_NAME))
result.sections = result.sections.map((section, index) =>
index === sdkIndex ? { ...section, text: sdkText } : 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.
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 }
}
},
})
})
ctx.tools.register(defineTool({
name: EXIT_PLAN_MODE,
@@ -424,52 +420,66 @@ export class ModesService extends Service {
}
/**
* One boundary pass (`turnStart` = a prompt-submit flush, else a
* turn-continuation flush): narrate a folded mode the config dropped (once
* per name, turn starts only), then flush the pending intent — append the
* `mode/set` (skipped when the fold already matches: a net-zero flip
* sequence) and the one coalesced notice when the flushed mode differs from
* what the last logged request header told the model. Idempotent per
* boundary, so the per-message prompt-submit dispatches of one batch flush
* once.
* One boundary pass: flush the pending intent — append the `mode/set`
* (skipped when the fold already matches: a net-zero flip sequence) and the
* one coalesced notice when the flushed mode differs from what the last
* logged request header told the model — then reconcile the exit tool's
* visibility with the (possibly changed) fold. Idempotent per boundary, so
* the per-message prompt-submit dispatches of one batch flush once.
*/
private onBoundary(session: Session, turnStart: boolean): void {
if (turnStart) this.noticeDroppedDefinition(session)
const pending = this.pendingIntents.get(session)
if (pending === undefined) return
const target = pending.mode
if (target === foldMode(session.events)) {
private onBoundary(agent: Agent): void {
const session = agent.session
try {
const pending = this.pendingIntents.get(session)
if (pending === undefined) return
const target = pending.mode
if (target === foldMode(session.events)) {
this.pendingIntents.delete(session)
return
}
session.append('mode/set', { mode: target })
// Clear the intent only AFTER the append landed: if a backend rejects the
// write, the intent stays parked and the next boundary retries — the UI's
// optimistic picker state and the log re-converge instead of diverging
// forever on a swallowed one-shot.
this.pendingIntents.delete(session)
return
if (!pending.narrate) return
const told = modeAtLastHeader(session.events)
if (told === undefined || told === target) return
const text = target === DEFAULT_MODE
? 'The user switched this session back to the default mode.'
: `The user switched this session to ${target} mode.`
session.append('context/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'mode' },
}, { surfaceOp: 'append' })
} finally {
// Reconcile even when the flush threw or nothing was pending: a foreign
// writer (a test, a future plugin) may have appended `mode/set` directly,
// and the fold — not the intent — is what visibility must track.
this.syncExitToolVisibility(agent)
}
session.append('mode/set', { mode: target })
// Clear the intent only AFTER the append landed: if a backend rejects the
// write, the intent stays parked and the next boundary retries — the UI's
// optimistic picker state and the log re-converge instead of diverging
// forever on a swallowed one-shot.
this.pendingIntents.delete(session)
if (!pending.narrate) return
const told = modeAtLastHeader(session.events)
if (told === undefined || told === target) return
const text = target === DEFAULT_MODE
? 'The user switched this session back to the default mode.'
: `The user switched this session to ${target} mode.`
session.append('context/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'mode' },
}, { surfaceOp: 'append' })
}
/** Narrate a folded mode name the current config no longer defines — the session reads as default plus this one notice. */
private noticeDroppedDefinition(session: Session): void {
const name = foldMode(session.events)
if (name === DEFAULT_MODE || this.resolved.definitions.has(name)) return
if (this.droppedNoticed.get(session) === name) return
this.droppedNoticed.set(session, name)
session.append('context/message', {
content: [{ type: 'text', text: `Mode "${name}" is no longer defined in this deployment's configuration; the session continues in the default mode.` }],
source: { kind: 'plugin', plugin: 'mode' },
}, { surfaceOp: 'append' })
/**
* Make the exit tool's registry visibility match the agent's folded mode:
* outside plan a scoped deny restriction on `agent.ctx` hides it from wire
* schemas, the Code Mode SDK section, and dispatch (an invisible tool
* reports `UNKNOWN_TOOL`), which keeps a default-mode agent byte-identical
* to a no-dsh-mode deployment; entering plan disposes the restriction.
* Idempotent — boundaries that change nothing install or dispose nothing.
*/
private syncExitToolVisibility(agent: Agent): void {
const inPlan = this.activeDefinition(agent.session)?.name === PLAN_MODE
const denial = this.exitToolDenials.get(agent)
if (inPlan) {
if (denial === undefined) return
this.exitToolDenials.delete(agent)
denial()
return
}
if (denial !== undefined) return
this.exitToolDenials.set(agent, agent.ctx.tools.restrict({ deny: [EXIT_PLAN_MODE] }))
}
}

View File

@@ -63,13 +63,16 @@ function findEvent<T extends SessionEvent['type']>(
}
describe('plan mode through the agent loop', () => {
it('seeds plan mode from AgentOptions: the FIRST header is already plan-shaped, and a non-shell call is guidance-constrained only', async () => {
it('a pre-turn set() makes the FIRST header plan-shaped, and a non-shell call is guidance-constrained only', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'write', {}, 'Writing during plan.'),
textResponse('Noted in the plan.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock', mode: PLAN_MODE })
const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' })
// Selected while idle (the ACP picker shape): the pending intent flushes at
// the first prompt-submit, BEFORE the first assembly.
ctx.modes.set(agent, PLAN_MODE)
agent.send([{ type: 'text', text: 'explore the repo' }])
await waitForIdle(ctx, agent)

View File

@@ -5,23 +5,44 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import CommandService from '@deepseek-ai/dsh-commands'
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts'
import type { ModeConfig } from '../src/index.ts'
/**
* Drives the REAL plugin: mounts `dsh-mode` beside real `SystemPrompt` and
* `ToolRegistry` services, with fake Agents carrying real `Session`s (the
* tool-todo test shape). Turn boundaries are simulated by appending the real
* boundary events and dispatching the interception seams the loop fires there
* `ToolRegistry` services, with fake Agents carrying real `Session`s and a
* real scoped `agent.ctx` (minted through `createScope`, the tool-skill test
* shape) so the registry's scoped restriction layer is exercised for real.
* Turn boundaries are simulated by appending the real boundary events and
* dispatching the interception seams the loop fires there
* (`agent/prompt-submit` / `agent/turn-continuation`) — exactly the seams the
* flush rides in production.
* flush (and the visibility reconcile) ride in production.
*/
function agentWithSession(id = 'agent-1', options: { mode?: string } = {}): Agent & { session: Session } {
async function agentWithSession(ctx: Context, id = 'agent-1', { mode }: { mode?: string } = {}): Promise<Agent & { session: Session }> {
const session = new Session(SessionId(id))
return { id: SessionId(id), session, options } as unknown as Agent & { session: Session }
const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
let scoped!: Context
await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
inject: ['tools'],
}))
;(agent as { ctx?: Context }).ctx = scoped
// A seeded mode lands BEFORE the creation announcement — the resume shape:
// the log already folds to the mode when the reconcile first runs.
if (mode !== undefined) session.append('mode/set', { mode })
// The loop announces creation after publication; the reconcile that seeds
// the exit tool's visibility restriction rides that announcement.
ctx.emit('agent/created', agent)
return agent
}
/** Assemble exactly as the loop does: the agent is both subject and scope. */
function assembleFor(ctx: Context, agent: Agent) {
return ctx.systemPrompt.assemble({ agent, scope: agent })
}
async function setup(config?: ModeConfig): Promise<Context> {
@@ -143,7 +164,7 @@ describe('ctx.modes: list/get/set', () => {
it('reads the folded mode, mapping a dropped definition to default', async () => {
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE })
agent.session.append('mode/set', { mode: PLAN_MODE })
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
@@ -153,13 +174,13 @@ describe('ctx.modes: list/get/set', () => {
it('rejects an unknown mode name loudly, naming the vocabulary', async () => {
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
expect(() => { ctx.modes.set(agent, 'nope') }).toThrow('unknown mode "nope" — available modes: default, plan')
})
it('accepts default as a target (exit-to-default is a valid write)', async () => {
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
agent.session.append('mode/set', { mode: PLAN_MODE })
ctx.modes.set(agent, DEFAULT_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE, pending: DEFAULT_MODE })
@@ -167,7 +188,7 @@ describe('ctx.modes: list/get/set', () => {
it('drops a no-op set (target equals pending, else the current fold)', async () => {
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, DEFAULT_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE })
ctx.modes.set(agent, PLAN_MODE)
@@ -175,21 +196,12 @@ describe('ctx.modes: list/get/set', () => {
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
})
it('seeds the initial mode from AgentOptions.mode on agent/created', async () => {
const ctx = await setup()
const agent = agentWithSession('seeded', { mode: PLAN_MODE })
ctx.emit('agent/created', agent)
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
const bare = agentWithSession('unseeded')
ctx.emit('agent/created', bare)
expect(ctx.modes.get(bare)).toEqual({ current: DEFAULT_MODE })
})
})
describe('the boundary flush', () => {
it('flushes the pending intent as a mode/set at turn/start', async () => {
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
await boundary(ctx, agent, 'turn/start')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
@@ -198,7 +210,7 @@ describe('the boundary flush', () => {
it('flushes at step/end too (a mid-turn flip lands on the following step)', async () => {
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
@@ -206,7 +218,7 @@ describe('the boundary flush', () => {
it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.modes.set(agent, DEFAULT_MODE)
await boundary(ctx, agent, 'turn/start')
@@ -216,7 +228,7 @@ describe('the boundary flush', () => {
it('narrates nothing before the first request header (the section is the state statement)', async () => {
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
await boundary(ctx, agent, 'turn/start')
expect(noticeTexts(agent.session)).toEqual([])
@@ -224,7 +236,7 @@ describe('the boundary flush', () => {
it('narrates once when the flushed mode differs from what the last header told the model', async () => {
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
header(agent.session)
ctx.modes.set(agent, PLAN_MODE)
await boundary(ctx, agent, 'turn/start')
@@ -235,7 +247,7 @@ describe('the boundary flush', () => {
it('narrates a switch back to the default mode with the default wording', async () => {
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
agent.session.append('mode/set', { mode: PLAN_MODE })
header(agent.session)
ctx.modes.set(agent, DEFAULT_MODE)
@@ -245,7 +257,7 @@ describe('the boundary flush', () => {
it('stays silent when the header already reflects the flushed mode', async () => {
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
agent.session.append('mode/set', { mode: PLAN_MODE })
header(agent.session)
agent.session.append('mode/set', { mode: DEFAULT_MODE })
@@ -255,24 +267,12 @@ describe('the boundary flush', () => {
expect(noticeTexts(agent.session)).toEqual([])
})
it('narrates a folded mode the config no longer defines, once, at turn starts', async () => {
const ctx = await setup()
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
await boundary(ctx, agent, 'turn/start')
await boundary(ctx, agent, 'turn/start')
expect(noticeTexts(agent.session)).toEqual([
'Mode "retired" is no longer defined in this deployment\'s configuration; the session continues in the default mode.',
])
await boundary(ctx, agent, 'step/end')
expect(noticeTexts(agent.session)).toHaveLength(1)
})
it('contains an append failure instead of blocking the prompt or the turn', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
const original = agent.session.append.bind(agent.session)
// Only the flush's own mode/set append fails; the boundary event itself
@@ -297,7 +297,7 @@ describe('the boundary flush', () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
const original = agent.session.append.bind(agent.session)
agent.session.append = (((type: string, ...rest: unknown[]) => {
@@ -311,11 +311,11 @@ describe('the boundary flush', () => {
})
describe('the soft layer', () => {
it('keeps a default-mode assembly identical to a no-dsh-mode deployment (exit tool dropped)', async () => {
it('keeps a default-mode assembly identical to a no-dsh-mode deployment (exit tool restricted away)', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
const assembly = await ctx.systemPrompt.assemble({ agent })
const agent = await agentWithSession(ctx)
const assembly = await assembleFor(ctx, agent)
expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
})
@@ -331,9 +331,8 @@ describe('the soft layer', () => {
it('keeps the full toolset in plan mode, adds the exit tool, and renders the mode section', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', 'todo_write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const assembly = await assembleFor(ctx, agent)
expect(assembly.tools.map(tool => tool.name).sort()).toEqual([EXIT_PLAN_MODE, 'read', 'todo_write', 'write'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toContain('plan mode')
})
@@ -341,19 +340,17 @@ describe('the soft layer', () => {
it('drops exit_plan_mode outside plan mode (custom modes never see it)', async () => {
const ctx = await setup({ modes: { review: { section: 'reviewing' } } })
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'review' })
const assembly = await ctx.systemPrompt.assemble({ agent })
const agent = await agentWithSession(ctx, 'agent-1', { mode: 'review' })
const assembly = await assembleFor(ctx, agent)
expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('reviewing')
})
it('leaves foreign post-next() additions alone in plan mode (no general tool filtering)', async () => {
// A foreign listener that post-processes await next(): the mode filter
// wraps outside it (prepend) but hides only the exit tool outside plan —
// a foreign addition survives, because which tools a mode admits is
// deliberately not this plugin's decision (the effects question stays
// parked; module doc).
it('leaves foreign assemble additions alone in any mode (no assemble-layer filtering at all)', async () => {
// The visibility rule lives in the registry's scoped restriction, not in
// an assemble wrapper, so a foreign listener's post-next() addition is
// never touched — in default mode included, where the exit tool itself is
// already absent from the registry view.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -364,10 +361,12 @@ describe('the soft layer', () => {
})
await ctx.plugin(ModesService)
registerNamedTools(ctx, ['read'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'added-later'])
const planning = await agentWithSession(ctx, 'planning', { mode: PLAN_MODE })
expect((await assembleFor(ctx, planning)).tools.map(tool => tool.name))
.toEqual(['exit_plan_mode', 'read', 'added-later'])
const defaulted = await agentWithSession(ctx, 'defaulted')
expect((await assembleFor(ctx, defaulted)).tools.map(tool => tool.name))
.toEqual(['read', 'added-later'])
})
it('keeps run_code the only wire tool in plan mode under the registry Code Mode; the SDK gains the exit binding', async () => {
@@ -384,9 +383,8 @@ describe('the soft layer', () => {
await ctx.plugin(FakeRuntime)
await ctx.plugin(ModesService)
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const assembly = await assembleFor(ctx, agent)
expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
// The SDK documents the full binding set plus the exit — a mode never
// prunes capabilities; it restrains by the section's guidance alone.
@@ -408,9 +406,8 @@ describe('the soft layer', () => {
await ctx.plugin(FakeRuntime)
await ctx.plugin(ModesService)
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const assembly = await assembleFor(ctx, agent)
// ONE visibility rule covers both surfaces: in plan the exit tool is
// present on the wire AND in the SDK, alongside the untouched toolset.
expect(assembly.tools.map(tool => tool.name).sort()).toEqual(['exit_plan_mode', 'read', 'run_code', 'write'])
@@ -432,8 +429,8 @@ describe('the soft layer', () => {
await withModes.plugin(FakeRuntime)
await withModes.plugin(ModesService)
registerNamedTools(withModes, ['read', 'write'])
const agent = agentWithSession()
const sdk = (await withModes.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
const agent = await agentWithSession(withModes)
const sdk = (await assembleFor(withModes, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toContain('read(args:')
expect(sdk).toContain('write(args:')
// The always-registered exit tool is callable only in plan mode, so a
@@ -444,7 +441,7 @@ describe('the soft layer', () => {
await bare.plugin(ToolRegistry, { mode: 'code' })
await bare.plugin(FakeRuntime)
registerNamedTools(bare, ['read', 'write'])
const bareSdk = (await bare.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
const bareSdk = (await bare.systemPrompt.assemble({ agent, scope: agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toBe(bareSdk)
expect(sdk).not.toContain('exit_plan_mode(args:')
})
@@ -452,20 +449,19 @@ describe('the soft layer', () => {
it('treats a dropped folded definition as the default mode', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
const assembly = await ctx.systemPrompt.assemble({ agent })
const agent = await agentWithSession(ctx, 'agent-1', { mode: 'retired' })
const assembly = await assembleFor(ctx, agent)
expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
})
})
describe('no execution gating', () => {
describe('no execution gating beyond the exit tool', () => {
it('passes agent-less and default-mode executions through', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['write'])
const agentless = await execute(ctx, 'write')
expect(agentless.isError).toBe(false)
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
const defaulted = await execute(ctx, 'write', agent)
expect(defaulted.isError).toBe(false)
})
@@ -473,8 +469,7 @@ describe('no execution gating', () => {
it('runs every call in plan mode untouched — modes restrain by guidance, enforcement knobs are separate axes', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', 'bash'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
for (const name of ['read', 'write', 'bash']) {
const result = await execute(ctx, name, agent)
expect(result.isError).toBe(false)
@@ -484,11 +479,70 @@ describe('no execution gating', () => {
it('treats a dropped folded definition as the default mode', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
const agent = await agentWithSession(ctx, 'agent-1', { mode: 'retired' })
const result = await execute(ctx, 'write', agent)
expect(result.isError).toBe(false)
})
it('denies a default-mode exit_plan_mode dispatch through the registry view (UNKNOWN_TOOL, not a schema-only hide)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
const result = await callExitFor(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: unknown tool "exit_plan_mode"' }])
})
it('restores dispatch when the boundary flush enters plan and re-denies after the exit flush', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
await boundary(ctx, agent, 'turn/start')
// In plan the tool is visible again; without a review channel the execute
// body answers (proving dispatch reached it), rather than UNKNOWN_TOOL.
const inPlan = await callExitFor(ctx, agent)
expect(inPlan.content).toEqual([{ type: 'text', text: 'Error: no user-interaction channel is available to review the plan; ask the user to switch the session mode instead' }])
ctx.modes.set(agent, DEFAULT_MODE)
await boundary(ctx, agent, 'step/end')
const afterExit = await callExitFor(ctx, agent)
expect(afterExit.content).toEqual([{ type: 'text', text: 'Error: unknown tool "exit_plan_mode"' }])
})
})
/** Dispatch the exit tool as the loop would — through the registry, agent-scoped. */
function callExitFor(ctx: Context, agent: Agent) {
return ctx.tools.execute({
callId: CallId(`call-exit-${++callCounter}`),
name: EXIT_PLAN_MODE,
arguments: { plan: '# P' },
agent,
})
}
describe('the /mode command', () => {
it('registers only when a commands service is composed, and shows or switches the mode', async () => {
const bare = await setup()
expect(bare.get('commands')).toBeUndefined()
const ctx = await setup()
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'])
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(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 unknown = await ctx.commands.execute(agent, '/mode nope', signal)
expect(unknown).toEqual({ kind: 'error', text: 'unknown mode "nope" — available modes: default, plan' })
})
})
describe('exit_plan_mode', () => {
@@ -504,8 +558,7 @@ describe('exit_plan_mode', () => {
},
})
}
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
return { ctx, agent, asked }
}
@@ -533,9 +586,13 @@ describe('exit_plan_mode', () => {
expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a calling agent (no session to switch)' }])
})
it('rejects a call outside plan mode (defense in depth behind the gate)', async () => {
it('re-checks the folded mode at execute (defense in depth behind the registry denial)', async () => {
// A foreign writer appends the flip DIRECTLY (not through ctx.modes.set),
// so no boundary has reconciled visibility yet: the registry still admits
// the call and the execute body's own re-check is what rejects it.
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
agent.session.append('mode/set', { mode: DEFAULT_MODE })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode is only available in plan mode' }])
@@ -543,8 +600,7 @@ describe('exit_plan_mode', () => {
it('degrades to the manual exit when no user-interaction seam is composed', async () => {
const ctx = await setup()
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction channel is available to review the plan; ask the user to switch the session mode instead' }])

View File

@@ -28,6 +28,9 @@
},
{
"path": "../../ui/user-interaction"
},
{
"path": "../../ui/commands"
}
]
}