Merge latest #239 into #447

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-07-plan-mode.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	packages/mode/README.md
#	packages/mode/mode/README.md
#	packages/mode/mode/src/index.ts
#	packages/mode/mode/tests/mode.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-21 11:43:54 +08:00
78 changed files with 261 additions and 277 deletions

View File

@@ -6,4 +6,4 @@ Session modes: named, logged, per-agent collaboration states, with **plan mode**
|---|---|---|
| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the `mode:policy` guidance section, and the model-facing `exit_plan_mode` review tool | `ctx.modes` |
The mode in force is a pure function of the session log (`SessionEventMap['mode/set']`, last one wins), so resume and fork restore it with no extra machinery. The deployment supplies the plan instructions through Cordis config, while the model-facing `exit_plan_mode` schema remains registered in every mode to keep the request tool catalog stable. UIs read flips off `session/event`; the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md).
The mode in force is a pure function of the session log (`SessionEventMap['mode/set']`, last one wins), so resume and fork restore it with no extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` remains registered in every mode to keep the request tool catalog stable. UIs read flips off `session/event`; the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker, and a composed [command registry](../ui/commands) gains the plugin-registered `/mode` command. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md).

View File

@@ -18,7 +18,11 @@ A mode does not gate execution, filter tools, or change sandbox or approval sett
`list()` returns `default` followed by the configured definitions. `get(agent)` returns the folded mode, treating a removed definition as `default`, plus any pending intent. `set(agent, mode)` validates against that vocabulary and records a pending intent. The service flushes the intent on `agent/prompt-submit` before the first assembly, `agent/turn-continuation` before a normal successor step, or after a composed `agent/request-error` decision authorizes a retry. Each append is turn-enclosed and precedes the affected prompt assembly, including an automatic recovery step after asynchronous backoff. A changed user selection adds one coalesced `context/message` notice when the last logged request header described a different mode; a net-zero selection sequence adds nothing.
`AgentOptions.mode` seeds an initial mode through the same pending-intent path. Forked sessions inherit mode state through their logged 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`
@@ -62,7 +66,7 @@ Within one mode, the section is stable. Entering or leaving a non-default mode c
#### What the model sees
A user-driven change whose previous request header described another mode appends either `The user switched this session to <mode> mode.` or `The user switched this session back to the default mode.` A logged mode removed from config reads as default and appends `Mode "<mode>" is no longer defined in this deployment's configuration; the session continues in the default mode.` once per removed name. Initial selection before the first header, net-zero selections, and the tool-driven exit add no notice.
A user-driven change whose previous request header described another mode appends either `The user switched this session to <mode> mode.` or `The user switched this session back to the default mode.` A logged mode removed from config reads as default without a notice. Initial selection before the first header, net-zero selections, and the tool-driven exit add no notice.
#### Token effect
@@ -70,7 +74,7 @@ Each qualifying transition adds one short conversation message once. The dynamic
#### KV Cache effect
The notice itself is append-only conversation growth. A real mode transition also changes the earlier order-50 section, so that section remains the limiting cache boundary; a dropped-definition notice with no section change preserves the prior request prefix and only extends it.
The notice itself is append-only conversation growth. A real mode transition also changes the earlier order-50 section, so that section remains the limiting cache boundary.
### Exit tool schema and review exchange
@@ -96,6 +100,6 @@ The tool schema and generated SDK binding are byte-identical in `default`, `plan
- A mode restrains by guidance only; pair it with independent enforcement knobs when a hard boundary is required. The [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) records the rejected enforcement shapes and the effects-metadata restart trigger.
- A pending flip selected while idle is lost if the process exits before the next boundary; the UI must reapply it.
- Forked children inherit the logged mode, while spawned children start in `default` unless their creator seeds `AgentOptions.mode`.
- Forked children inherit the logged mode, while spawned children start in `default`; there is no creation-time mode option.
Design: [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md).

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.7"
},
"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

@@ -38,6 +38,9 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
// Type-only edge: resolves `ctx.commands` for the `/mode` command child below;
// the child mounts only when a commands service is composed.
import type {} from '@deepseek-ai/dsh-commands'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
@@ -50,17 +53,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
@@ -123,11 +115,6 @@ const EXIT_DESCRIPTION
+ 'The user may approve (carry out the plan from your next step) or keep '
+ 'planning — their feedback comes back in the tool result; revise and present again.'
/** Durable notice text for a folded mode the current deployment no longer defines. */
function droppedDefinitionNotice(name: string): string {
return `Mode "${name}" is no longer defined in this deployment's configuration; the session continues in the default mode.`
}
/** The plan's first markdown heading (any level), or `undefined` when it has none. */
function firstHeading(plan: string): string | undefined {
for (const line of plan.split('\n')) {
@@ -228,9 +215,6 @@ export class ModesService extends Service {
*/
private readonly pendingIntents = new WeakMap<Session, { mode: string; narrate: boolean }>()
/** Mode-plugin notice texts already present in each live session; hydrated once from the durable log. */
private readonly noticeTexts = new WeakMap<Session, Set<string>>()
constructor(ctx: Context, config: ModeConfig = { modes: {} }) {
super(ctx, 'modes')
this.resolved = resolveConfig(config)
@@ -249,7 +233,7 @@ export class ModesService extends Service {
// only when session.append rejects during 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)
}
@@ -257,7 +241,7 @@ 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)
}
@@ -279,7 +263,7 @@ export class ModesService extends Service {
// owning plugin fiber has been disposed.
if (disposed || decision.action !== 'retry') return decision
try {
this.onBoundary(agent.session, false)
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
}
@@ -287,18 +271,39 @@ export class ModesService extends Service {
}, { prepend: true })
ctx.effect(() => () => { disposed = true }, 'dsh-mode: close boundary lifetime')
ctx.on('agent/created', (agent) => {
const seed = agent.options.mode
if (seed === undefined) return
this.set(agent, seed)
})
ctx.systemPrompt.section({
name: 'mode:policy',
order: 50,
text: context => (context.agent === undefined ? '' : this.activeDefinition(context.agent.session)?.definition.section ?? ''),
})
// The `/mode` command (show or switch the session mode) for interactive
// front doors, mounted only when a commands service is composed — the
// child plugin below activates on `ctx.commands` availability, so a
// commands-less deployment composes dsh-mode unchanged.
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,
description: EXIT_DESCRIPTION,
@@ -419,17 +424,13 @@ export class ModesService extends Service {
}
/**
* One boundary pass (`turnStart` = prompt-submit, else a normal-successor or
* recovery-retry boundary): 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 for prompt submission, a normal successor, or a recovery
* retry: append a changed pending `mode/set` and one coalesced notice when the
* flushed mode differs from what the last logged request header told the
* model. Idempotent per boundary, so repeated dispatches flush once.
*/
private onBoundary(session: Session, turnStart: boolean): void {
if (turnStart) this.noticeDroppedDefinition(session)
private onBoundary(agent: Agent): void {
const session = agent.session
const pending = this.pendingIntents.get(session)
if (pending === undefined) return
const target = pending.mode
@@ -438,10 +439,8 @@ export class ModesService extends Service {
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.
// Clear the intent only after the append lands; a failed write remains
// pending so a later boundary can retry it.
this.pendingIntents.delete(session)
if (!pending.narrate) return
const told = modeAtLastHeader(session.events)
@@ -454,30 +453,6 @@ export class ModesService extends Service {
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
const text = droppedDefinitionNotice(name)
let noticed = this.noticeTexts.get(session)
if (noticed === undefined) {
noticed = new Set(session.events.flatMap(event => event.type === 'context/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'mode'
&& event.data.content.length === 1
&& event.data.content[0]?.type === 'text'
? [event.data.content[0].text]
: []))
this.noticeTexts.set(session, noticed)
}
if (noticed.has(text)) return
session.append('context/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'mode' },
}, { surfaceOp: 'append' })
noticed.add(text)
}
}
export default ModesService

View File

@@ -64,13 +64,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,7 +5,9 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { agentEvents, type Agent, type RequestErrorDecision } 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'
@@ -15,16 +17,31 @@ const PLAN_CONFIG = { modes: { plan: { section: TEST_PLAN_SECTION } } } satisfie
/**
* 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 ordinary interception seams the loop
* fires there (`agent/prompt-submit` / `agent/turn-continuation`). Recovery
* retry coverage lives in the full-loop integration suite.
* `ToolRegistry` services, with fake Agents carrying real `Session`s and a
* real scoped `agent.ctx` minted through `createScope`.
* Turn boundaries are simulated by appending the real boundary events and
* dispatching the interception seams the loop fires there. Recovery retries
* exercise the separate `agent/request-error` wrapper.
*/
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, matching resume.
if (mode !== undefined) session.append('mode/set', { mode })
// The loop announces creation after publication.
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 = PLAN_CONFIG): Promise<Context> {
@@ -167,7 +184,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 })
@@ -177,13 +194,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 })
@@ -191,7 +208,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)
@@ -199,21 +216,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)
@@ -222,7 +230,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)
@@ -230,7 +238,7 @@ describe('the boundary flush', () => {
it('keeps the pending intent parked when recovery does not retry', async () => {
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
expect(await recoveryBoundary(ctx, agent, { action: 'fail' })).toEqual({ action: 'fail' })
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
@@ -240,7 +248,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[]) => {
@@ -255,7 +263,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')
@@ -265,7 +273,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([])
@@ -273,7 +281,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')
@@ -284,7 +292,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)
@@ -294,7 +302,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 })
@@ -304,64 +312,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('does not repeat a dropped-definition notice after the mode service restarts', async () => {
const first = await setup()
const original = agentWithSession('dropped-resume')
original.session.append('mode/set', { mode: 'retired' })
await boundary(first, original, 'turn/start')
const resumed = agentWithSession('dropped-resume')
resumed.session = new Session(SessionId('dropped-resume'), original.session.events)
const second = await setup()
await boundary(second, resumed, 'turn/start')
expect(noticeTexts(resumed.session)).toEqual([
'Mode "retired" is no longer defined in this deployment\'s configuration; the session continues in the default mode.',
])
})
it('retries a dropped-definition notice when its append fails', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
const original = agent.session.append.bind(agent.session)
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'context/message') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'turn/start')
expect(warn).toHaveBeenCalledOnce()
expect(noticeTexts(agent.session)).toEqual([])
agent.session.append = original
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.',
])
})
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
@@ -386,7 +342,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[]) => {
@@ -403,13 +359,13 @@ describe('the soft layer', () => {
it('keeps the tool schemas identical across default and plan mode', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
const defaultAssembly = await ctx.systemPrompt.assemble({ agent })
const agent = await agentWithSession(ctx)
const defaultAssembly = await assembleFor(ctx, agent)
expect(defaultAssembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write'])
expect(defaultAssembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
agent.session.append('mode/set', { mode: PLAN_MODE })
const planAssembly = await ctx.systemPrompt.assemble({ agent })
const planAssembly = await assembleFor(ctx, agent)
expect(planAssembly.tools).toEqual(defaultAssembly.tools)
expect(planAssembly.sections.find(section => section.name === 'mode:policy')?.text).toBe(TEST_PLAN_SECTION)
})
@@ -425,9 +381,8 @@ describe('the soft layer', () => {
it('keeps the full toolset in plan mode and renders the configured 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).toBe(TEST_PLAN_SECTION)
})
@@ -435,16 +390,14 @@ describe('the soft layer', () => {
it('keeps exit_plan_mode visible in custom modes while rendering their guidance', async () => {
const ctx = await setup({ modes: { ...PLAN_CONFIG.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([EXIT_PLAN_MODE, '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 addition
// survives, because modes do not filter the deployment's tool registry.
it('leaves foreign assemble additions alone in any mode (no assemble-layer filtering at all)', async () => {
// Modes do not filter the deployment's registry or later assembly additions.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -455,10 +408,12 @@ describe('the soft layer', () => {
})
await ctx.plugin(ModesService, PLAN_CONFIG)
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(['exit_plan_mode', '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 () => {
@@ -475,9 +430,8 @@ describe('the soft layer', () => {
await ctx.plugin(FakeRuntime)
await ctx.plugin(ModesService, PLAN_CONFIG)
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.
@@ -499,9 +453,8 @@ describe('the soft layer', () => {
await ctx.plugin(FakeRuntime)
await ctx.plugin(ModesService, PLAN_CONFIG)
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)
// The stable registry contribution reaches both surfaces: 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'])
@@ -523,13 +476,13 @@ describe('the soft layer', () => {
await withModes.plugin(FakeRuntime)
await withModes.plugin(ModesService, PLAN_CONFIG)
registerNamedTools(withModes, ['read', 'write'])
const agent = agentWithSession()
const defaultSdk = (await withModes.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
const agent = await agentWithSession(withModes)
const defaultSdk = (await assembleFor(withModes, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(defaultSdk).toContain('read(args:')
expect(defaultSdk).toContain('write(args:')
expect(defaultSdk).toContain('exit_plan_mode(args:')
agent.session.append('mode/set', { mode: PLAN_MODE })
const planSdk = (await withModes.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
const planSdk = (await assembleFor(withModes, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(planSdk).toBe(defaultSdk)
// Loading the mode plugin deliberately adds one stable binding compared
@@ -547,20 +500,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([EXIT_PLAN_MODE, '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)
})
@@ -568,8 +520,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)
@@ -579,11 +530,38 @@ 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)
})
})
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', () => {
@@ -599,8 +577,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 }
}
@@ -631,7 +608,7 @@ describe('exit_plan_mode', () => {
it('rejects a call outside plan mode while remaining advertised', async () => {
const ctx = await setup()
const agent = agentWithSession()
const agent = await agentWithSession(ctx)
expect(ctx.tools.schemas().map(tool => tool.name)).toContain(EXIT_PLAN_MODE)
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
@@ -651,8 +628,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' }])
@@ -708,8 +684,7 @@ describe('exit_plan_mode', () => {
return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
},
})
const agent = agentWithSession('code-mode-exit')
agent.session.append('mode/set', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'code-mode-exit', { mode: PLAN_MODE })
const result = await ctx.tools.execute({
callId: CallId(`call-exit-${++callCounter}`),
@@ -879,7 +854,7 @@ describe('HMR disposal', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(ModesService, PLAN_CONFIG)
const agent = agentWithSession('disposed-in-flight-recovery')
const agent = await agentWithSession(ctx, 'disposed-in-flight-recovery')
const recoveryEntered = Promise.withResolvers<true>()
const releaseRecovery = Promise.withResolvers<true>()
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, _next) => {
@@ -903,7 +878,7 @@ describe('HMR disposal', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(ModesService, PLAN_CONFIG)
const agent = agentWithSession('disposed-recovery')
const agent = await agentWithSession(ctx, 'disposed-recovery')
ctx.modes.set(agent, PLAN_MODE)
expect(ctx.get('modes')).toBeInstanceOf(ModesService)
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()

View File

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