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:
@@ -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).
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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] }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' }])
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user