refactor(plan): remove generic mode abstraction

This commit is contained in:
Tianyi Cui
2026-07-22 16:57:23 +08:00
parent 92da23270d
commit f4185122dc
61 changed files with 990 additions and 1161 deletions

View File

@@ -25,7 +25,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
| [`mode/`](mode/README.md) | Session modes: plan mode with a reviewed exit | Product — stable surface |
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |

View File

@@ -350,24 +350,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'modes',
summary: '`ctx.modes`: the session-mode service.',
methods: [
{
signature: 'list(): string[]',
jsDoc: '/**\n * The selectable mode vocabulary: {@link DEFAULT_MODE} first, then the\n * configured definitions — the list a mode picker advertises.\n *\n * @returns Mode names, `default` first.\n */',
},
{
signature: 'get(agent: Agent): { current: string; pending?: string }',
jsDoc: '/**\n * The agent\'s mode state: the folded mode in force (a folded name the config\n * no longer defines reads as {@link DEFAULT_MODE}) plus the pending\n * user-selected intent awaiting its boundary flush, when one exists.\n *\n * @param agent The agent to read.\n * @returns The current (effective) mode and the pending intent, if any.\n */',
},
{
signature: 'set(agent: Agent, mode: string): void',
jsDoc: '/**\n * Select the agent\'s mode. Validates the name against {@link list} (loud on\n * unknown; `default` is always a valid target), drops a no-op (target equals\n * the pending intent, else the current fold), and otherwise records a\n * pending intent flushed as a `mode/set` at the next turn boundary.\n *\n * @param agent The agent to switch.\n * @param mode The target mode name.\n */',
},
],
},
{
key: 'permission',
summary: 'Owns the deployment\'s permission presets and their write path.',
@@ -390,6 +372,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'planMode',
summary: '`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool.',
methods: [
{
signature: 'get(agent: Agent): { active: boolean; pending?: boolean }',
jsDoc: '/**\n * Read the logged plan state and any selected state awaiting a boundary.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */',
},
{
signature: 'set(agent: Agent, active: boolean): void',
jsDoc: '/**\n * Select whether plan mode should be active from the next turn boundary.\n * Repeated selection of the current or already-pending state is a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n */',
},
],
},
{
key: 'sandbox',
summary: 'Abstract process-sandbox service.',

View File

@@ -1,9 +0,0 @@
# mode/ — session-mode policy family
Session modes: named, logged, per-agent collaboration states, with **plan mode** as the first shipped definition. A single **product** package — there is no interface/implementation seam here, because a mode's variable part is a config value (the section text), not a swappable implementation.
| Package | Role | ctx key |
|---|---|---|
| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the `mode:policy` guidance section, and the model-facing `exit_plan_mode` review tool | `ctx.modes` |
The mode in force is a pure function of the session log (`SessionEventMap['mode/set']`, last one wins), so resume and fork restore it with no extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` remains registered in every mode to keep the request tool catalog stable. UIs read flips off `session/event`; the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker, and a composed [command registry](../ui/commands) gains one entry command per configured definition (`/plan [message]` for the required definition, with an optional next-step message). Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md).

View File

@@ -1,121 +0,0 @@
# @deepseek-ai/dsh-mode
Session modes are named, logged, per-agent collaboration states. **Plan mode** is the required first definition: the agent explores and designs under deployment-owned instructions, presents a reviewable plan, and crosses back through an explicit review. Modes are independent from enforcement knobs such as sandbox mode and approval policy.
## The mode state is a session event
`mode/set` (`{ mode: string }`) is a log-only, non-surface `SessionEventMap` member with whole-value-replace semantics; the pure `foldMode(events)` returns the last logged mode or `default`. Resume, fork, and compaction therefore restore the mode from the log, and UIs observe flips through `session/event`.
The `default` mode means no mode guidance. Loading this plugin still contributes one stable `exit_plan_mode` tool schema in every mode; that fixed schema is the cost that avoids changing the tool catalog at a mode boundary.
## What a mode carries
The plugin registers one `mode:policy` prompt section at order 50. It renders the active definition's deployment-configured `section` text and renders empty in `default`, for an agent-less assembly, or when a logged definition no longer exists.
A mode does not gate execution, filter tools, or change sandbox or approval settings. A deployment that wants a hard read-only floor while planning combines plan mode with the independent sandbox and approval controls. The config vocabulary is exactly `{ section }`; unknown keys fail at load.
## `ctx.modes`
`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.
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.
## Per-mode slash commands
When a command registry (`@deepseek-ai/dsh-commands`) is composed, each configured definition contributes its own entry command to interactive front doors. The required definition supplies `/plan [message]`; a further `review` definition supplies `/review [message]`. Each command records its named switch through `set()`; when the optional message is non-empty, the handler trims it and passes it to `agent.steer()` so a running agent receives it in its next step and an idle agent starts a new turn. `default` is the absence of a definition and contributes no command. Without a commands service the child never mounts and nothing else changes.
Definition names must match `/^[a-z][a-z0-9_-]*$/u`, the shared mode/command subset; config fails at load before a definition can become selectable but undispatchable.
## `exit_plan_mode`
[`exit_plan_mode`](../../../docs/tool-catalog.md#deepseek-aidsh-mode) is registered in every mode so native tool schemas and Code Mode's generated SDK remain byte-identical across a mode switch. Its description says it is plan-only, and execution rechecks the folded mode and rejects outside `plan`.
In plan mode, the required `plan` argument makes the review artifact durable: native dispatch records it in `tool/call`; Code Mode records the outer `run_code` source before execution and the extracted arguments in `tool/code-dispatch` when the nested dispatch settles. The review request also carries the exact plan as supporting detail, so ACP and TUI show what is being approved even when Code Mode has no nested native call card. The tool asks the user to Approve or Keep planning through `ctx.userInteraction`, with optional free-text rejection feedback. Approval schedules a silent switch to `default` at the step boundary; every non-approval outcome returns a corrective `isError` and keeps plan mode. Native presentation additionally renders the markdown as a generic plan card.
## Config
The deployment must provide the complete plan-mode instructions in Cordis config; the package has no embedded plan prompt. See the [ACP example](../../../examples/acp-agent/cordis.yml) for the maintained production-shaped instructions.
```yaml
- id: mode
name: '@deepseek-ai/dsh-mode'
config:
modes:
plan:
section: |
You are in plan mode. Explore first, make no changes, and present a decision-complete plan through exit_plan_mode.
```
`resolveConfig` requires `modes.plan.section`, rejects `default` as a definition key, rejects invalid command-shaped names, blank sections, and unknown definition keys, and preserves any further named modes. `set()` rejects an unknown mode name.
## Model Experience
### Mode guidance section
#### What the model sees
In `default`, no `mode:policy` text appears. In a configured mode, that definition's exact `section` text appears after persona and before tool guidance. The package does not own a stable prompt literal; the [example Cordis config](../../../examples/acp-agent/cordis.yml) owns the plan instructions used by the shipped composition.
#### Token effect
`default` adds no section tokens. Plan mode adds the configured section on each request; the text is static until config or mode changes.
#### KV Cache effect
Within one mode, the section is stable. Entering or leaving a non-default mode changes the system prompt at order 50, so bytes from that section onward need a new cache path; the stable prefix before it can still be reused where the provider supports prefix caching. No tool-schema or Code Mode SDK churn accompanies the transition.
### Mode transition notices
#### 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 without a notice. Initial selection before the first header, net-zero selections, and the tool-driven exit add no notice.
#### Token effect
Each qualifying transition adds one short conversation message once. The dynamic mode name is the only data-dependent part.
#### 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.
### Per-mode command message
#### What the model sees
The command name and result remain in the direct command plane. A non-empty optional suffix is trimmed and submitted as one ordinary user text block through `agent.steer()` after the mode selection, so the resulting step sees the selected mode.
#### Token effect
The command itself adds no tokens. An optional message has the same history and token cost as submitting that text separately.
#### KV Cache effect
The optional message is append-only conversation growth. Entering the mode still changes the order-50 system-prompt section for the affected step.
### Exit tool schema and review exchange
#### What the model sees
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-mode) is present in every mode. Outside plan mode, a call returns `Error: exit_plan_mode is only available in plan mode`. In plan mode, an empty or heading-less argument returns `Error: exit_plan_mode requires a non-empty markdown plan starting with a # heading` before review. A valid call carries the complete plan both as the tool argument and as review detail; exactly one `Approve` selection returns `Plan approved — plan mode exited; carry out the plan starting with your next step.`, while every other answer returns `Error: The user chose to keep planning; revise the plan and present it again.` or `Error: The user chose to keep planning; their feedback: <feedback>`. An unavailable review channel returns its fail-closed error and keeps the mode unchanged.
##### Stable literal
```markdown
Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. 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.
```
#### Token effect
The stable cost depends on ToolRegistry mode: `native` adds the tool schema, `code` adds the generated SDK binding inside the `run_code` surface instead of a native schema, and `both` adds both representations. The plan markdown is paid once as tool-call arguments and remains in context. Each rejection adds its feedback result, and the next revision adds another complete plan tool call.
#### KV Cache effect
The tool schema and generated SDK binding are byte-identical in `default`, `plan`, and custom modes, so a mode change adds no tool-catalog diff. The earlier order-50 section change still moves the cache path as described above; schema stability avoids a second source of request-shape churn and keeps subsequent requests within the new mode on one catalog shape. Loading or unloading the plugin itself changes that catalog. Review arguments and results extend the conversation normally.
## Known Limitations and Deferred Work
- 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`; there is no creation-time mode option.
Design: [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md).

View File

@@ -1,474 +0,0 @@
/**
* Session modes: named, logged, per-agent COLLABORATION states, with **plan
* mode** as the first shipped definition. A mode is a guidance section the
* model sees while it is in force plus, for plan, the user-reviewed
* `exit_plan_mode` crossing — deliberately nothing more. Modes are one axis
* and enforcement knobs (the sandbox mode, the approval policy) are others;
* they never read or write each other, exactly as Codex separates its
* Plan/Default collaboration presets from its sandbox and approval settings.
* There is likewise NO per-mode tool allow/deny list: which tools a mode
* admits is an effects question, parked until tool definitions can declare
* their effects (the plan-mode Agent Note's deferred item). The mode IN FORCE for an
* agent is session state, folded from its log (`mode/set`, last one wins), so
* resume and fork restore it for free.
*
* The default mode is the absence of mode guidance. The `exit_plan_mode` tool
* remains registered in every mode so request tool schemas never change at a
* mode boundary; its execute path rejects calls outside plan mode.
*
* 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
* pending intent and the service flushes it on the loop's interception seams:
* `agent/prompt-submit` before the first assembly, `agent/turn-continuation`
* before a normal successor step, and the post-composed
* `agent/request-error` retry decision before a recovery step. These seams are
* outside tool execution and log publication (post-commit `session/event`
* observers cannot append). A flush that changes what the last logged request
* header told the model appends one coalesced `context/message` notice in the
* same frame.
*
* Agent Note: .agents/notes/implemented/feature/2026-07-07-plan-mode.md
*
* @module @deepseek-ai/dsh-mode
*/
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
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 per-mode command child
// below; the child mounts only when a commands service is composed.
import type {} from '@deepseek-ai/dsh-commands'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* The session mode in force from this point on: log-only, non-surface,
* whole-value replace — the last `mode/set` in the log wins (see
* {@link foldMode}). A log with none folds to {@link DEFAULT_MODE}.
*/
'mode/set': { mode: string }
}
}
declare module 'cordis' {
interface Context {
modes: ModesService
}
}
/**
* The mode a log with no `mode/set` folds to: the absence of policy. Reserved —
* {@link resolveConfig} rejects it as a definition key, and {@link ModesService.set}
* always accepts it as a target (a picker's exit-to-default is a valid write).
*/
export const DEFAULT_MODE = 'default'
/** The required plan definition's name. */
export const PLAN_MODE = 'plan'
// Every definition contributes a same-named slash command when the optional
// command registry is composed, so mode names use that stable common subset.
const MODE_NAME = /^[a-z][a-z0-9_-]*$/u
/**
* The model-facing exit tool's name. It stays registered in every mode so the
* request tool catalog is stable; execution outside {@link PLAN_MODE} rejects.
*/
export const EXIT_PLAN_MODE = 'exit_plan_mode'
/**
* One mode's deployment-configured policy: the guidance section the model
* sees. Deliberately nothing else — enforcement knobs (sandbox mode, approval
* policy) are separate axes a mode never touches, and a tool allow/deny list
* is an effects question parked until tool definitions declare their effects.
*/
export interface ModeDefinition {
/** Guidance text rendered as the `mode:policy` prompt section while the mode is in force. */
section: string
}
/**
* Plugin config: mode definitions by name. The deployment must define
* {@link PLAN_MODE}, including its complete model instructions;
* {@link DEFAULT_MODE} is rejected as a key and definition names must be valid
* slash-command names ({@link resolveConfig} throws at load).
*/
export interface ModeConfig {
/** Mode definitions by name; `plan` is required and owns its full prompt text. */
modes: Record<string, ModeDefinition>
}
/** Validated deployment-owned mode definitions, including `plan`. */
export interface ResolvedModes {
/** Definitions by mode name; never contains {@link DEFAULT_MODE}. */
definitions: ReadonlyMap<string, ModeDefinition>
}
/** The review question's approve option label — the answer item is matched by it. */
const APPROVE_LABEL = 'Approve'
/** The review question's keep-planning option label. */
const KEEP_PLANNING_LABEL = 'Keep planning'
const EXIT_DESCRIPTION
= 'Use only in plan mode. Present your plan for the user\'s review and, on approval, leave plan mode. '
+ 'Send the COMPLETE plan as markdown, starting with a # heading that names it. '
+ '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.'
/** 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')) {
const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)
if (match) return match[1]
}
return undefined
}
/**
* Validate the deployment-owned mode definitions (explicit resolve step — the
* `dsh-bash` request/spec template). Fail-loud: a missing {@link PLAN_MODE}, a
* {@link DEFAULT_MODE} key, or a malformed definition throws at load.
*
* @param config Raw plugin config.
* @returns The validated definitions, including deployment-configured `plan`.
*/
export function resolveConfig(config: ModeConfig): ResolvedModes {
const definitions = new Map<string, ModeDefinition>()
// Cordis can invoke the constructor with omitted runtime config even though
// the public TypeScript contract requires `modes`; keep that invalid shape
// inside validation so it gets the actionable missing-plan error below.
const modes = (config as Partial<ModeConfig>).modes ?? {}
for (const [name, definition] of Object.entries(modes)) {
if (name === DEFAULT_MODE) {
throw new Error(`ModeConfig: "${DEFAULT_MODE}" is reserved (the absence of policy) and cannot be defined`)
}
// The same shape the package invariant enforces on `mode/set`: accepting
// an empty or untrimmed KEY here would advertise a name whose selection
// the invariant then rejects, desynchronizing the picker forever.
if (name.trim() === '' || name.trim() !== name) {
throw new Error(`ModeConfig: mode name ${JSON.stringify(name)} must be non-empty and trimmed`)
}
if (!MODE_NAME.test(name)) {
throw new Error(`ModeConfig: mode name ${JSON.stringify(name)} must match ${String(MODE_NAME)} for its slash command`)
}
if (typeof definition.section !== 'string') {
throw new Error(`ModeConfig: mode "${name}" needs a string \`section\``)
}
if (definition.section.trim() === '') {
throw new Error(`ModeConfig: mode "${name}" needs a non-empty \`section\``)
}
// Unknown keys fail loud rather than silently shaping nothing — the
// definition vocabulary is exactly { section }: a tool allow/deny list
// and enforcement knobs are deliberately NOT part of it (module doc).
const unknown = Object.keys(definition).filter(key => key !== 'section')
if (unknown.length > 0) {
throw new Error(`ModeConfig: mode "${name}" has unknown key(s) ${unknown.join(', ')} — a definition is { section }`)
}
definitions.set(name, { section: definition.section })
}
if (!definitions.has(PLAN_MODE)) {
throw new Error(`ModeConfig: mode "${PLAN_MODE}" is required; put its model instructions in modes.${PLAN_MODE}.section`)
}
return { definitions }
}
/**
* The mode in force after the first `end` events: the last `mode/set` wins,
* a prefix with none folds to {@link DEFAULT_MODE}. Pure — exported for
* reconstructors and tests.
*
* @param events The session log (or any prefix of it).
* @param end Fold `events[0, end)`; defaults to the whole log.
* @returns The folded mode name.
*/
export function foldMode(events: readonly SessionEvent[], end = events.length): string {
let mode = DEFAULT_MODE
let index = 0
for (const event of events) {
if (index >= end) break
index++
if (event.type === 'mode/set') mode = event.data.mode
}
return mode
}
/** The mode the last logged request header shipped under, or `undefined` before the first header. */
function modeAtLastHeader(events: readonly SessionEvent[]): string | undefined {
let lastHeader = -1
let index = 0
for (const event of events) {
if (event.type === 'request/header') lastHeader = index
index++
}
if (lastHeader < 0) return undefined
return foldMode(events, lastHeader + 1)
}
/**
* `ctx.modes`: the session-mode service. Owns the `mode/set` vocabulary, the
* pending-intent flush, the boundary narration, the `mode:policy` section,
* and the stable exit tool. UIs read mode flips off `session/event`; there is
* no live mirror.
*/
export class ModesService extends Service {
static inject = ['tools', 'systemPrompt']
/** Validated deployment-owned definitions, including `plan`. */
readonly resolved: ResolvedModes
/**
* The latest selected mode per session, awaiting its turn-boundary flush.
* `narrate` is true for user selections (the flush appends the coalesced
* notice when the header disagrees) and false for the exit tool's own
* switch, which narrates through its tool result instead.
*/
private readonly pendingIntents = new WeakMap<Session, { mode: string; narrate: boolean }>()
constructor(ctx: Context, config: ModeConfig = { modes: {} }) {
super(ctx, 'modes')
this.resolved = resolveConfig(config)
let disposed = false
// Boundary flushes ride the loop's interception seams, NOT the
// `session/event` feed: post-commit session observers are observe-only
// (an append from one would re-enter the publishing append and be
// contained away). Prompt-submit runs before the first assembly;
// turn-continuation runs after an ordinary step and before its successor.
// Request retries bypass turn-continuation, so the prepended request-error
// wrapper delegates through recovery (including async backoff), then
// flushes a retry decision before that waterfall returns to the loop. A
// flushed mode therefore lands before the prompt that should reflect it.
// Contained: policy must never block a prompt or turn; onBoundary can throw
// only when session.append rejects during teardown.
// Flush AFTER next() on every seam (the request-error wrapper below does
// the same): downstream listeners may await, and a `session/set_mode`
// arriving during that window must still shape the request this boundary
// precedes — a pre-next() flush would apply it one request late.
const flushAfter = async <T>(agent: Agent, next: () => Promise<T>): Promise<T> => {
const decision = await next()
if (!disposed) {
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
}
}
return decision
}
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/turn-continuation', (agent, _turn, _decision, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/request-error', async (
agent,
_turn,
_step,
_error,
_failure,
_priorFailures,
_signal,
next,
) => {
const decision = await next()
// A waterfall can capture this wrapper before Cordis unregisters it.
// Do not let that stale continuation mutate the session after its
// owning plugin fiber has been disposed.
if (disposed || decision.action !== 'retry') return decision
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
}
return decision
}, { prepend: true })
ctx.effect(() => () => { disposed = true }, 'dsh-mode: close boundary lifetime')
ctx.systemPrompt.section({
name: 'mode:policy',
order: 50,
text: context => (context.agent === undefined ? '' : this.activeDefinition(context.agent.session)?.definition.section ?? ''),
})
// Each configured definition contributes its own entry command to
// interactive front doors. The child activates only when `ctx.commands`
// is available, so a commands-less deployment composes dsh-mode unchanged.
ctx.inject(['commands'], (commandCtx) => {
for (const mode of this.resolved.definitions.keys()) {
commandCtx.commands.register({
name: mode,
description: `Enter ${mode} mode`,
input: { hint: '[message]' },
handler: ({ agent, rawInput }) => {
const message = rawInput.trim()
this.set(agent, mode)
if (message !== '') agent.steer([{ type: 'text', text: message }])
return { kind: 'success', text: `Entering ${mode} mode (applies from the next step).` }
},
})
}
})
ctx.tools.register(defineTool({
name: EXIT_PLAN_MODE,
description: EXIT_DESCRIPTION,
parameters: {
plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },
},
execute: async (args, exec) => {
const agent = exec.agent
if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
if (this.activeDefinition(agent.session)?.name !== PLAN_MODE) {
throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
}
if (!/^#\s+\S/.test(args.plan.trim())) {
throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`)
}
const interaction = ctx.get('userInteraction')
if (interaction === undefined) {
throw new Error('no user-interaction channel is available to review the plan; ask the user to switch the session mode instead')
}
const answer = await interaction.ask({
questions: [{
id: 'plan-review',
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
detail: args.plan,
options: [
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
],
}],
agent,
signal: exec.signal,
})
// The review may outlive this plugin fiber (HMR unload while the user
// decides): the boundary listeners that would flush the switch are
// already gone, so a success result here would claim an exit that can
// never land. Fail the call instead; a remounted service still holds
// plan mode and the model re-presents.
if (disposed) {
throw new Error('the mode service was reloaded while the plan was under review; present the plan again')
}
const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review')
const item = reviewItems.length === 1 ? reviewItems[0] : undefined
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
// A custom-text-only answer is feedback, not consent — approval is
// exactly the approve option (an unknown selection never exits).
const feedback = item?.custom ?? ''
throw new Error(feedback === ''
? 'The user chose to keep planning; revise the plan and present it again.'
: `The user chose to keep planning; their feedback: ${feedback}`)
}
// A boundary-applied switch, NOT a direct append: the loop may still
// execute further tool calls from the SAME assistant response after
// this one, and they were requested under the plan-shaped header — so
// the plan guidance keeps holding for that whole batch. The flush at
// this step's end appends
// the mode/set (still in-turn), so the next step's assembly reflects
// the exit; narrate: false — this result IS the narration.
this.pendingIntents.set(agent.session, { mode: DEFAULT_MODE, narrate: false })
return [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }]
},
presentCall: args => ({
card: 'generic',
title: firstHeading(args.plan) ?? 'Plan',
kind: 'other',
content: [{ type: 'text', text: args.plan }],
}),
presentResult: (_args, result) => ({
card: 'generic',
title: 'Plan review',
content: result.content,
}),
}))
}
/**
* The selectable mode vocabulary: {@link DEFAULT_MODE} first, then the
* configured definitions — the list a mode picker advertises.
*
* @returns Mode names, `default` first.
*/
list(): string[] {
return [DEFAULT_MODE, ...this.resolved.definitions.keys()]
}
/**
* The agent's mode state: the folded mode in force (a folded name the config
* no longer defines reads as {@link DEFAULT_MODE}) plus the pending
* user-selected intent awaiting its boundary flush, when one exists.
*
* @param agent The agent to read.
* @returns The current (effective) mode and the pending intent, if any.
*/
get(agent: Agent): { current: string; pending?: string } {
const current = this.activeDefinition(agent.session)?.name ?? DEFAULT_MODE
const pending = this.pendingIntents.get(agent.session)
return pending === undefined ? { current } : { current, pending: pending.mode }
}
/**
* Select the agent's mode. Validates the name against {@link list} (loud on
* unknown; `default` is always a valid target), drops a no-op (target equals
* the pending intent, else the current fold), and otherwise records a
* pending intent flushed as a `mode/set` at the next turn boundary.
*
* @param agent The agent to switch.
* @param mode The target mode name.
*/
set(agent: Agent, mode: string): void {
if (mode !== DEFAULT_MODE && !this.resolved.definitions.has(mode)) {
throw new Error(`unknown mode "${mode}" — available modes: ${this.list().join(', ')}`)
}
const session = agent.session
const target = this.pendingIntents.get(session)?.mode ?? this.get(agent).current
if (mode === target) return
this.pendingIntents.set(session, { mode, narrate: true })
}
/** The folded mode's definition, or `undefined` for the default mode and for a folded name the config no longer defines. */
private activeDefinition(session: Session): { name: string; definition: ModeDefinition } | undefined {
const name = foldMode(session.events)
if (name === DEFAULT_MODE) return undefined
const definition = this.resolved.definitions.get(name)
if (definition === undefined) return undefined
return { name, definition }
}
/**
* 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(agent: Agent): void {
const session = agent.session
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 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)
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' })
}
}
export default ModesService

9
packages/plan/README.md Normal file
View File

@@ -0,0 +1,9 @@
# plan/ — plan collaboration state
Plan mode is one logged, per-agent collaboration state. It is a single **product** package, not a generic mode registry or a capability-seam trio.
| Package | Role | ctx key |
|---|---|---|
| `plan-mode/` | `plan/mode` vocabulary + fold, boundary-applied state, the `plan:policy` guidance section, `/plan [message]`, and the model-facing `exit_plan_mode` review tool | `ctx.planMode` |
The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. ACP maps this capability onto its generic `default` / `plan` picker; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).

View File

@@ -0,0 +1,88 @@
# @deepseek-ai/dsh-plan-mode
Logged, per-agent plan collaboration state with deployment-owned guidance, a direct `/plan [message]` entry command, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy remain independent enforcement axes.
## Durable state
`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`.
`ctx.planMode.set(agent, active)` records a pending selection and flushes it inside the next turn boundary. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's optimistic selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one `context/message` notice when the last logged request header described the other state.
## Model and human surfaces
While active, `plan:policy` renders the configured `section`. The plugin always registers `exit_plan_mode`, keeping tool schemas stable across the transition; its execute path accepts only active plan mode and leaves it only after an exact user approval through `ctx.userInteraction`.
When `ctx.commands` is composed, the package registers `/plan [message]`. The command selects plan mode first. A non-empty argument is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance; bare `/plan` only changes state.
ACP is an adapter, not the owner of this vocabulary: it advertises the fixed wire ids `default` and `plan`, maps `session/set_mode` to the boolean service, and translates committed `plan/mode` events back to `current_mode_update`.
## Configuration
```yaml
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
You are in plan mode. Explore and design before presenting the complete
plan through exit_plan_mode.
```
`section` is required and non-empty. Unknown keys fail at load. The package does not accept arbitrary named modes, tool filters, sandbox settings, or approval policy.
Design: [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).
## Model Experience
### Plan policy system prompt
#### What the model sees
While plan mode is active, the model sees the deployment's exact `section` text at prompt order 50; inactive mode contributes no text.
##### Configuration example
```markdown
You are in plan mode. Explore and design before presenting the complete plan through exit_plan_mode.
```
#### Token effect
Inactive mode adds no tokens; active mode adds the configured section to every request.
#### KV Cache effect
The section is stable within plan mode, but entering or leaving changes the system prompt from order 50 onward.
### Optional command message
#### What the model sees
`/plan` and its terminal result stay outside model history; a non-empty suffix becomes one trimmed user text block through `agent.steer()` after plan mode is selected.
#### Token effect
The suffix costs the same history tokens as submitting that text separately; a bare command adds none.
#### KV Cache effect
The user block is append-only conversation growth, while entering plan mode also changes the earlier policy section.
### Exit tool schema and review exchange
#### What the model sees
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the exit result and rejection returns feedback.
#### Token effect
The stable schema is paid according to ToolRegistry mode, and each plan argument and review result remains in conversation history.
#### KV Cache effect
Mode transitions do not change the tool catalog; plan arguments and review results extend the conversation normally.
## Known Limitations and Deferred Work
- Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls.
- A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it.
- Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-mode",
"description": "Session modes for the DeepSeek Harness: plan mode as a logged per-agent collaboration state with a guidance section and a user-reviewed exit",
"name": "@deepseek-ai/dsh-plan-mode",
"description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -0,0 +1,342 @@
/**
* Plan mode is logged per-agent collaboration state: while active, a
* deployment-owned guidance section shapes each model request, and
* `exit_plan_mode` presents the completed plan for user review. It is
* independent of sandbox mode and approval policy; those enforcement axes do
* not read or write plan state.
*
* The state in force is folded from the session log (`plan/mode`, last one
* wins), so resume and fork restore it without a live mirror. User selections
* are held as pending intent until a turn boundary because every session event
* is turn-enclosed. The service flushes before the affected request assembly
* on prompt submission, ordinary continuation, and request-recovery retry.
*
* The exit tool remains registered while plan mode is inactive so crossing a
* boundary changes only the prompt section, not the request tool catalog.
*
* Agent Notes:
* - .agents/notes/implemented/feature/2026-07-07-plan-mode.md
* - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
*
* @module @deepseek-ai/dsh-plan-mode
*/
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
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 optional command child.
import type {} from '@deepseek-ai/dsh-commands'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Whether plan mode is in force from this point on: log-only, non-surface,
* whole-value replace. The last `plan/mode` wins; a log with none folds to
* inactive through {@link foldPlanMode}.
*/
'plan/mode': { active: boolean }
}
}
declare module 'cordis' {
interface Context {
planMode: PlanModeService
}
}
/**
* The model-facing exit tool's name. It stays registered while plan mode is
* inactive so the request tool catalog is stable across transitions.
*/
export const EXIT_PLAN_MODE = 'exit_plan_mode'
/** Deployment-owned plan guidance. */
export interface PlanModeConfig {
/** Guidance rendered as the `plan:policy` prompt section while plan mode is active. */
section: string
}
/** The review question's approve option label. */
const APPROVE_LABEL = 'Approve'
/** The review question's keep-planning option label. */
const KEEP_PLANNING_LABEL = 'Keep planning'
const EXIT_DESCRIPTION
= 'Use only in plan mode. Present your plan for the user\'s review and, on approval, leave plan mode. '
+ 'Send the COMPLETE plan as markdown, starting with a # heading that names it. '
+ '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.'
/** 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')) {
const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)
if (match) return match[1]
}
return undefined
}
/**
* Validate deployment-owned plan guidance. Missing, blank, non-string, or
* unknown fields fail at plugin load rather than silently shaping nothing.
*
* @param config Raw plugin config.
* @returns A detached validated config.
*/
export function resolveConfig(config: PlanModeConfig): PlanModeConfig {
const section = (config as Partial<PlanModeConfig>).section
if (typeof section !== 'string') {
throw new Error('PlanModeConfig needs a string `section`')
}
if (section.trim() === '') {
throw new Error('PlanModeConfig needs a non-empty `section`')
}
const unknown = Object.keys(config).filter(key => key !== 'section')
if (unknown.length > 0) {
throw new Error(`PlanModeConfig has unknown key(s) ${unknown.join(', ')} — config is { section }`)
}
return { section }
}
/**
* Whether plan mode is active after the first `end` events. The last
* `plan/mode` wins; a prefix with none is inactive.
*
* @param events The session log or any prefix of it.
* @param end Fold `events[0, end)`; defaults to the whole log.
* @returns Whether plan mode is active.
*/
export function foldPlanMode(events: readonly SessionEvent[], end = events.length): boolean {
let active = false
let index = 0
for (const event of events) {
if (index >= end) break
index++
if (event.type === 'plan/mode') active = event.data.active
}
return active
}
/** Plan state at the last logged request header, or `undefined` before the first header. */
function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefined {
let lastHeader = -1
let index = 0
for (const event of events) {
if (event.type === 'request/header') lastHeader = index
index++
}
if (lastHeader < 0) return undefined
return foldPlanMode(events, lastHeader + 1)
}
/**
* `ctx.planMode`: owns logged plan state, boundary application and narration,
* the `plan:policy` section, the `/plan` command, and the stable exit tool.
* UIs observe committed flips through `session/event`; there is no live mirror.
*/
export class PlanModeService extends Service {
static inject = ['tools', 'systemPrompt']
/** Validated deployment-owned guidance. */
private readonly section: string
/**
* Latest selection per session awaiting a turn-boundary flush. `narrate` is
* true for user selections and false for the exit tool, whose result already
* narrates the transition.
*/
private readonly pendingIntents = new WeakMap<Session, { active: boolean; narrate: boolean }>()
constructor(ctx: Context, config: PlanModeConfig = { section: '' }) {
super(ctx, 'planMode')
this.section = resolveConfig(config).section
let disposed = false
// Boundary flushes use loop interception seams, not post-commit
// `session/event` observation. Flush after next(): a selection arriving
// while a downstream async listener awaits must still shape the request
// this boundary precedes. Failures are contained so policy cannot block a
// prompt or turn; a failed append remains pending for a later boundary.
const flushAfter = async <T>(agent: Agent, next: () => Promise<T>): Promise<T> => {
const decision = await next()
if (!disposed) {
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
}
}
return decision
}
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/turn-continuation', (agent, _turn, _decision, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/request-error', async (
agent,
_turn,
_step,
_error,
_failure,
_priorFailures,
_signal,
next,
) => {
const decision = await next()
// A waterfall can retain this wrapper after Cordis unregisters it.
if (disposed || decision.action !== 'retry') return decision
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
}
return decision
}, { prepend: true })
ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime')
ctx.systemPrompt.section({
name: 'plan:policy',
order: 50,
text: context => context.agent !== undefined && foldPlanMode(context.agent.session.events)
? this.section
: '',
})
// The command child activates only when a command registry is composed.
ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'plan',
description: 'Enter plan mode',
input: { hint: '[message]' },
handler: ({ agent, rawInput }) => {
const message = rawInput.trim()
this.set(agent, true)
if (message !== '') agent.steer([{ type: 'text', text: message }])
return { kind: 'success', text: 'Entering plan mode (applies from the next step).' }
},
})
})
ctx.tools.register(defineTool({
name: EXIT_PLAN_MODE,
description: EXIT_DESCRIPTION,
parameters: {
plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },
},
execute: async (args, exec) => {
const agent = exec.agent
if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
if (!foldPlanMode(agent.session.events)) {
throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
}
if (!/^#\s+\S/.test(args.plan.trim())) {
throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`)
}
const interaction = ctx.get('userInteraction')
if (interaction === undefined) {
throw new Error('no user-interaction channel is available to review the plan; ask the user to switch the session mode instead')
}
const answer = await interaction.ask({
questions: [{
id: 'plan-review',
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
detail: args.plan,
options: [
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
],
}],
agent,
signal: exec.signal,
})
// A review may outlive this plugin fiber. Without boundary listeners,
// an approved result could never land, so fail and keep planning.
if (disposed) {
throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again')
}
const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review')
const item = reviewItems.length === 1 ? reviewItems[0] : undefined
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
const feedback = item?.custom ?? ''
throw new Error(feedback === ''
? 'The user chose to keep planning; revise the plan and present it again.'
: `The user chose to keep planning; their feedback: ${feedback}`)
}
// Keep plan guidance for the rest of this assistant tool batch. The
// silent intent flushes after the step, before the next assembly.
this.pendingIntents.set(agent.session, { active: false, narrate: false })
return [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }]
},
presentCall: args => ({
card: 'generic',
title: firstHeading(args.plan) ?? 'Plan',
kind: 'other',
content: [{ type: 'text', text: args.plan }],
}),
presentResult: (_args, result) => ({
card: 'generic',
title: 'Plan review',
content: result.content,
}),
}))
}
/**
* Read the logged plan state and any selected state awaiting a boundary.
*
* @param agent The agent to read.
* @returns Current logged state plus a pending selection, when present.
*/
get(agent: Agent): { active: boolean; pending?: boolean } {
const active = foldPlanMode(agent.session.events)
const pending = this.pendingIntents.get(agent.session)
return pending === undefined ? { active } : { active, pending: pending.active }
}
/**
* Select whether plan mode should be active from the next turn boundary.
* Repeated selection of the current or already-pending state is a no-op.
*
* @param agent The agent to switch.
* @param active Whether plan mode should be active.
*/
set(agent: Agent, active: boolean): void {
const session = agent.session
const target = this.pendingIntents.get(session)?.active ?? foldPlanMode(session.events)
if (active === target) return
this.pendingIntents.set(session, { active, narrate: true })
}
/** Flush one pending selection before the next request assembly. */
private onBoundary(agent: Agent): void {
const session = agent.session
const pending = this.pendingIntents.get(session)
if (pending === undefined) return
const target = pending.active
if (target === foldPlanMode(session.events)) {
this.pendingIntents.delete(session)
return
}
session.append('plan/mode', { active: target })
// Delete only after append succeeds so a later boundary can retry a failed
// durable write.
this.pendingIntents.delete(session)
if (!pending.narrate) return
const told = planModeAtLastHeader(session.events)
if (told === undefined || told === target) return
const text = target
? 'The user switched this session to plan mode.'
: 'The user switched this session back to the default mode.'
session.append('context/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'plan-mode' },
}, { surfaceOp: 'append' })
}
}
export default PlanModeService

View File

@@ -1,32 +1,27 @@
/** Package-owned durable mode-stream invariants. @module @deepseek-ai/dsh-mode/invariant */
/** Package-owned durable plan-mode invariants. @module @deepseek-ai/dsh-plan-mode/invariant */
import type { Context } from 'cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-mode'
const PACKAGE_NAME = '@deepseek-ai/dsh-plan-mode'
/** Cordis companion plugin name. */
export const name = 'mode-invariant'
export const name = 'plan-mode-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* Validate one `mode/set` payload before it reaches the durable log: the mode
* is a non-empty bare name (config-declared vocabulary, not an opaque id), so
* an empty or non-string value can only be a writer bug folding it would
* silently select the default mode while the log claims otherwise.
*/
/** Validate one `plan/mode` payload before it reaches the durable log. */
function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
if (event.type !== 'mode/set') return
const mode = (event.data as { mode?: unknown }).mode
if (typeof mode !== 'string' || mode.trim() === '' || mode.trim() !== mode) {
fail(`mode/set carries invalid mode ${JSON.stringify(mode)}; expected a non-empty trimmed name`)
if (event.type !== 'plan/mode') return
const active = (event.data as { active?: unknown }).active
if (typeof active !== 'boolean') {
fail(`plan/mode carries invalid active state ${JSON.stringify(active)}; expected a boolean`)
}
}
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
/** Install validation for loaded and newly appended mode selections. */
/** Install validation for loaded and newly appended plan-mode state. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
for (const session of ctx.sessions.list()) {
for (const event of session.events) validateEvent(event, fail)
@@ -40,7 +35,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
/* jscpd:ignore-end */
/**
* Register the mode invariant companion.
* Register the plan-mode invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/

View File

@@ -6,13 +6,13 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import ModesService, { PLAN_MODE, foldMode } from '@deepseek-ai/dsh-mode'
import PlanModeService, { foldPlanMode } from '@deepseek-ai/dsh-plan-mode'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
const PLAN_CONFIG = { modes: { plan: { section: 'Test plan mode instructions.' } } }
const PLAN_CONFIG = { section: 'Test plan mode instructions.' }
/**
* Full-loop integration: a scripted mock model drives the REAL mode plugin
* Full-loop integration: a scripted mock model drives the REAL plan-mode plugin
* through the agent loop the pending-intent flush at the turn boundary, the
* assembly the soft layer shapes (the exit tool + mode section), and the
* `request/header` snapshots every transition leaves.
@@ -27,7 +27,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(ModesService, PLAN_CONFIG)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
ctx.llm.registerAdapter(['mock'], adapter)
for (const name of ['read', 'write']) {
ctx.tools.register(defineTool({
@@ -73,15 +73,15 @@ describe('plan mode through the agent loop', () => {
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)
ctx.planMode.set(agent, true)
agent.send([{ type: 'text', text: 'explore the repo' }])
await waitForIdle(ctx, agent)
const log = agent.session.events
const modeSet = findEvent(log, 'mode/set')
const planMode = findEvent(log, 'plan/mode')
const header = findEvent(log, 'request/header')
expect(modeSet.seq).toBeLessThan(header.seq)
expect(planMode.seq).toBeLessThan(header.seq)
expect(header.data.reason).toBe('initial')
expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
expect(header.data.header.system).toContain('plan mode')
@@ -91,7 +91,7 @@ describe('plan mode through the agent loop', () => {
// axes). The mode itself stays plan throughout.
const result = findEvent(log, 'tool/result')
expect(result.data.isError).toBe(false)
expect(foldMode(log)).toBe(PLAN_MODE)
expect(foldPlanMode(log)).toBe(true)
expect(log.some(event => event.type === 'context/message')).toBe(false)
})
@@ -105,16 +105,16 @@ describe('plan mode through the agent loop', () => {
agent.send([{ type: 'text', text: 'hello' }])
await waitForIdle(ctx, agent)
expect(foldMode(agent.session.events)).toBe('default')
expect(foldPlanMode(agent.session.events)).toBe(false)
const first = findEvent(agent.session.events, 'request/header')
expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
agent.send([{ type: 'text', text: 'now plan' }])
await waitForIdle(ctx, agent)
const log = agent.session.events
expect(foldMode(log)).toBe(PLAN_MODE)
expect(foldPlanMode(log)).toBe(true)
const notices = log.filter(event => event.type === 'context/message')
expect(notices).toHaveLength(1)
expect(findEvent(log, 'context/message').data.content).toEqual([
@@ -148,21 +148,21 @@ describe('plan mode through the agent loop', () => {
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'plan after the transient failure' }])
await recoveryEntered.promise
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
releaseRecovery.resolve(true)
await idle
expect(adapter.requests).toHaveLength(2)
expect(adapter.requests[0]?.system).not.toContain(PLAN_CONFIG.modes.plan.section)
expect(adapter.requests[1]?.system).toContain(PLAN_CONFIG.modes.plan.section)
expect(adapter.requests[0]?.system).not.toContain(PLAN_CONFIG.section)
expect(adapter.requests[1]?.system).toContain(PLAN_CONFIG.section)
expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools)
const log = agent.session.events
const modeSet = findEvent(log, 'mode/set')
const planMode = findEvent(log, 'plan/mode')
const firstEnd = log.find(event => event.type === 'step/end' && event.data.step === 1)
const retryStart = log.find(event => event.type === 'step/start' && event.data.step === 2)
expect(firstEnd?.seq).toBeLessThan(modeSet.seq)
expect(modeSet.seq).toBeLessThan(retryStart?.seq ?? 0)
expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.modes.plan.section)
expect(firstEnd?.seq).toBeLessThan(planMode.seq)
expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0)
expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section)
expect(findEvent(log, 'context/message').data.content).toEqual([
{ type: 'text', text: 'The user switched this session to plan mode.' },
])

View File

@@ -1,35 +1,32 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
import * as ModeInvariant from '@deepseek-ai/dsh-mode/invariant'
import * as PlanModeInvariant from '@deepseek-ai/dsh-plan-mode/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(ModeInvariant)
await ctx.plugin(PlanModeInvariant)
return ctx
}
function event(mode: unknown): SessionEvent {
return { type: 'mode/set', seq: 0, time: 0, data: { mode } } as SessionEvent
function event(active: unknown): SessionEvent {
return { type: 'plan/mode', seq: 0, time: 0, data: { active } } as SessionEvent
}
describe('mode stream invariants', () => {
it('accepts a plain non-empty trimmed mode name', async () => {
describe('plan-mode stream invariants', () => {
it('accepts either boolean state', async () => {
const ctx = await setup()
expect(() => { ctx.emit('session/event', {} as Session, event('plan')) }).not.toThrow()
expect(() => { ctx.emit('session/event', {} as Session, event('default')) }).not.toThrow()
expect(() => { ctx.emit('session/event', {} as Session, event(true)) }).not.toThrow()
expect(() => { ctx.emit('session/event', {} as Session, event(false)) }).not.toThrow()
})
it.each([
[42, /invalid mode 42/],
['', /invalid mode ""/],
[' plan ', /invalid mode " plan "/],
])('rejects an invalid durable mode selection', async (mode, message) => {
it.each([42, 'plan', undefined])('rejects invalid durable plan state %j', async (active) => {
const ctx = await setup()
expect(() => { ctx.emit('session/event', {} as Session, event(mode)) }).toThrow(message)
expect(() => { ctx.emit('session/event', {} as Session, event(active)) })
.toThrow(/expected a boolean/)
})
it('ignores unrelated dispatches and session events', async () => {
@@ -42,12 +39,12 @@ describe('mode stream invariants', () => {
}).not.toThrow()
})
it('rejects an invalid existing selection on late registration', async () => {
it('rejects invalid existing state on late registration', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.sessions.create().append('mode/set', { mode: '' })
ctx.sessions.create().append('plan/mode', { active: 'plan' as unknown as boolean })
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(ModeInvariant).then(() => undefined)).rejects.toThrow(/invalid mode ""/)
await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).rejects.toThrow(/expected a boolean/)
})
})

View File

@@ -9,14 +9,14 @@ 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'
import PlanModeService, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts'
import type { PlanModeConfig } from '../src/index.ts'
const TEST_PLAN_SECTION = 'Test plan mode instructions.'
const PLAN_CONFIG = { modes: { plan: { section: TEST_PLAN_SECTION } } } satisfies ModeConfig
const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
/**
* Drives the REAL plugin: mounts `dsh-mode` beside real `SystemPrompt` and
* Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and
* `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
@@ -24,7 +24,7 @@ const PLAN_CONFIG = { modes: { plan: { section: TEST_PLAN_SECTION } } } satisfie
* exercise the separate `agent/request-error` wrapper.
*/
async function agentWithSession(ctx: Context, id = 'agent-1', { mode }: { mode?: string } = {}): Promise<Agent & { session: Session }> {
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
const session = new Session(SessionId(id))
const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
let scoped!: Context
@@ -32,8 +32,8 @@ async function agentWithSession(ctx: Context, id = 'agent-1', { mode }: { mode?:
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 })
// Seeded plan state lands before the creation announcement, matching resume.
if (active !== undefined) session.append('plan/mode', { active })
// The loop announces creation after publication.
ctx.emit('agent/created', agent)
return agent
@@ -44,11 +44,11 @@ function assembleFor(ctx: Context, agent: Agent) {
return ctx.systemPrompt.assemble({ agent, scope: agent })
}
async function setup(config: ModeConfig = PLAN_CONFIG): Promise<Context> {
async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ModesService, config)
await ctx.plugin(PlanModeService, config)
return ctx
}
@@ -122,125 +122,83 @@ function execute(ctx: Context, name: string, agent?: Agent) {
}
describe('resolveConfig', () => {
it('requires the deployment to configure the plan instructions', () => {
expect(() => resolveConfig({ modes: {} }))
.toThrow('mode "plan" is required; put its model instructions in modes.plan.section')
expect(() => resolveConfig({} as ModeConfig))
.toThrow('mode "plan" is required; put its model instructions in modes.plan.section')
})
it('loads the configured plan instructions and further modes verbatim', () => {
const resolved = resolveConfig({ modes: {
plan: { section: TEST_PLAN_SECTION },
review: { section: 'review' },
} })
expect(resolved.definitions.get(PLAN_MODE)).toEqual({ section: TEST_PLAN_SECTION })
expect(resolved.definitions.get('review')).toEqual({ section: 'review' })
})
it('rejects the reserved default key loudly', () => {
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, default: { section: 'policy' } } }))
.toThrow('"default" is reserved')
})
it('rejects an empty or untrimmed mode name loudly (the invariant would reject its selection)', () => {
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, '': { section: 'x' } } }))
.toThrow('mode name "" must be non-empty and trimmed')
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, ' review ': { section: 'x' } } }))
.toThrow('mode name " review " must be non-empty and trimmed')
})
it('rejects mode names that cannot also name their slash commands', () => {
for (const name of ['Review', 'review mode', '1-review', 'review!']) {
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, [name]: { section: 'x' } } }))
.toThrow(`mode name ${JSON.stringify(name)} must match /^[a-z][a-z0-9_-]*$/u for its slash command`)
}
})
it('rejects a malformed definition loudly', () => {
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, bad: { section: 5 } as unknown as { section: string } } }))
it('requires string, non-empty plan instructions', () => {
expect(() => resolveConfig({} as PlanModeConfig))
.toThrow('needs a string `section`')
expect(() => resolveConfig({ modes: { plan: { section: ' ' } } }))
expect(() => resolveConfig({ section: 5 } as unknown as PlanModeConfig))
.toThrow('needs a string `section`')
expect(() => resolveConfig({ section: ' ' }))
.toThrow('needs a non-empty `section`')
// Unknown keys fail loud — a tool allow/deny list and enforcement knobs
// are deliberately not part of the vocabulary, and a config still
// carrying one must not be silently accepted as if it shaped anything.
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, bad: { section: 'bad', tools: ['read'] } as unknown as { section: string } } }))
.toThrow('unknown key(s) tools — a definition is { section }')
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, bad: { section: 'bad', access: 'read-only' } as unknown as { section: string } } }))
.toThrow('unknown key(s) access — a definition is { section }')
})
it('returns a detached plan config', () => {
const config = { section: TEST_PLAN_SECTION }
const resolved = resolveConfig(config)
expect(resolved).toEqual(config)
expect(resolved).not.toBe(config)
})
it('rejects fields outside the plan policy config', () => {
expect(() => resolveConfig({ section: TEST_PLAN_SECTION, tools: ['read'] } as unknown as PlanModeConfig))
.toThrow('unknown key(s) tools — config is { section }')
})
})
describe('foldMode', () => {
it('folds an empty log to the default mode and takes the last mode/set otherwise', () => {
describe('foldPlanMode', () => {
it('folds an empty log to inactive and takes the last plan/mode otherwise', () => {
const session = new Session(SessionId('fold'))
expect(foldMode(session.events)).toBe(DEFAULT_MODE)
session.append('mode/set', { mode: 'plan' })
session.append('mode/set', { mode: 'default' })
session.append('mode/set', { mode: 'plan' })
expect(foldMode(session.events)).toBe('plan')
expect(foldPlanMode(session.events)).toBe(false)
session.append('plan/mode', { active: true })
session.append('plan/mode', { active: false })
session.append('plan/mode', { active: true })
expect(foldPlanMode(session.events)).toBe(true)
})
it('folds a prefix when `end` is given', () => {
const session = new Session(SessionId('fold-prefix'))
session.append('mode/set', { mode: 'plan' })
session.append('mode/set', { mode: 'default' })
expect(foldMode(session.events, 1)).toBe('plan')
expect(foldMode(session.events, 0)).toBe(DEFAULT_MODE)
session.append('plan/mode', { active: true })
session.append('plan/mode', { active: false })
expect(foldPlanMode(session.events, 1)).toBe(true)
expect(foldPlanMode(session.events, 0)).toBe(false)
})
})
describe('ctx.modes: list/get/set', () => {
it('lists default first, then the configured definitions', async () => {
const ctx = await setup({ modes: { ...PLAN_CONFIG.modes, review: { section: 's' } } })
expect(ctx.modes.list()).toEqual([DEFAULT_MODE, PLAN_MODE, 'review'])
})
it('reads the folded mode, mapping a dropped definition to default', async () => {
describe('ctx.planMode: get/set', () => {
it('reads the folded state', async () => {
const ctx = await setup()
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 })
agent.session.append('mode/set', { mode: 'retired' })
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE })
expect(ctx.planMode.get(agent)).toEqual({ active: false })
agent.session.append('plan/mode', { active: true })
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('rejects an unknown mode name loudly, naming the vocabulary', async () => {
it('selects inactive as the plan exit target', async () => {
const ctx = await setup()
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 = 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 })
agent.session.append('plan/mode', { active: true })
ctx.planMode.set(agent, false)
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
})
it('drops a no-op set (target equals pending, else the current fold)', async () => {
const ctx = await setup()
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)
ctx.modes.set(agent, PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
ctx.planMode.set(agent, false)
expect(ctx.planMode.get(agent)).toEqual({ active: false })
ctx.planMode.set(agent, true)
ctx.planMode.set(agent, true)
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
})
describe('the boundary flush', () => {
it('flushes the pending intent as a mode/set at turn/start', async () => {
it('flushes the pending intent as a plan/mode at turn/start', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'turn/start')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('flushes a set() that arrives while a downstream listener is still awaiting (post-next ordering)', async () => {
@@ -248,11 +206,11 @@ describe('the boundary flush', () => {
const agent = await agentWithSession(ctx)
// A downstream async listener (the shipped hooks listeners' shape): the
// selection lands DURING its await — after this boundary began, before it
// returns. The prepended flush runs after next(), so the mode/set still
// returns. The prepended flush runs after next(), so the plan/mode still
// precedes the request this boundary gates.
ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
await new Promise(resolve => setTimeout(resolve, 5))
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
await next()
return decision
})
@@ -261,17 +219,17 @@ describe('the boundary flush', () => {
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
() => Promise.resolve({ action: 'stop' }),
)
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('skips the flush after the plugin fiber is disposed (a captured wrapper must not write into a dead service)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(ModesService, PLAN_CONFIG)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
// A downstream listener captured before disposal keeps the waterfall
// continuation alive across the unload; the resumed wrapper must not
// append through the disposed service.
@@ -285,23 +243,23 @@ describe('the boundary flush', () => {
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
() => Promise.resolve({ action: 'stop' }),
)
expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(false)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
it('flushes at step/end too (a mid-turn flip lands on the following step)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('keeps the pending intent parked when recovery does not retry', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
expect(await recoveryBoundary(ctx, agent, { action: 'fail' })).toEqual({ action: 'fail' })
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('contains an append failure at the retry boundary without changing its decision', async () => {
@@ -309,32 +267,32 @@ describe('the boundary flush', () => {
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
const original = agent.session.append.bind(agent.session)
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'mode/set') throw new Error('backend gone')
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
expect(warn).toHaveBeenCalledOnce()
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.modes.set(agent, DEFAULT_MODE)
ctx.planMode.set(agent, true)
ctx.planMode.set(agent, false)
await boundary(ctx, agent, 'turn/start')
expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(false)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
expect(noticeTexts(agent.session)).toEqual([])
})
it('narrates nothing before the first request header (the section is the state statement)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'turn/start')
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -343,7 +301,7 @@ describe('the boundary flush', () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
header(agent.session)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'turn/start')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
await boundary(ctx, agent, 'step/end')
@@ -353,9 +311,9 @@ describe('the boundary flush', () => {
it('narrates a switch back to the default mode with the default wording', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
agent.session.append('mode/set', { mode: PLAN_MODE })
agent.session.append('plan/mode', { active: true })
header(agent.session)
ctx.modes.set(agent, DEFAULT_MODE)
ctx.planMode.set(agent, false)
await boundary(ctx, agent, 'step/end')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.'])
})
@@ -363,12 +321,12 @@ describe('the boundary flush', () => {
it('stays silent when the header already reflects the flushed mode', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
agent.session.append('mode/set', { mode: PLAN_MODE })
agent.session.append('plan/mode', { active: true })
header(agent.session)
agent.session.append('mode/set', { mode: DEFAULT_MODE })
ctx.modes.set(agent, PLAN_MODE)
agent.session.append('plan/mode', { active: false })
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -378,12 +336,12 @@ describe('the boundary flush', () => {
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
const original = agent.session.append.bind(agent.session)
// Only the flush's own mode/set append fails; the boundary event itself
// Only the flush's own plan/mode append fails; the boundary event itself
// lands (the loop appended it before the seam fires).
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'mode/set') throw new Error('backend gone')
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'step/end')
@@ -391,11 +349,11 @@ describe('the boundary flush', () => {
// The failed flush re-parks the intent (cleared only after a landed
// append), so the next healthy boundary converges the log with the
// picker's optimistic state instead of dropping the switch forever.
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
agent.session.append = original
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent).pending).toBeUndefined()
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent).pending).toBeUndefined()
})
it('contains an append failure on the prompt-submit seam the same way', async () => {
@@ -403,15 +361,15 @@ describe('the boundary flush', () => {
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
const original = agent.session.append.bind(agent.session)
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'mode/set') throw new Error('backend gone')
if (type === 'plan/mode') 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(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
})
@@ -422,12 +380,12 @@ describe('the soft layer', () => {
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('')
expect(defaultAssembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
agent.session.append('mode/set', { mode: PLAN_MODE })
agent.session.append('plan/mode', { active: true })
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)
expect(planAssembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
})
it('leaves an agent-less assembly untouched', async () => {
@@ -435,29 +393,20 @@ describe('the soft layer', () => {
registerNamedTools(ctx, ['read'])
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
})
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 = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
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)
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
})
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 = 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 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.
it('leaves foreign assemble additions alone (no assemble-layer filtering)', async () => {
// Plan guidance does not filter the registry or later assembly additions.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -466,9 +415,9 @@ describe('the soft layer', () => {
final.tools = [...final.tools, { name: 'added-later', description: 'added after next()', parameters: {} }]
return final
})
await ctx.plugin(ModesService, PLAN_CONFIG)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
registerNamedTools(ctx, ['read'])
const planning = await agentWithSession(ctx, 'planning', { mode: PLAN_MODE })
const planning = await agentWithSession(ctx, 'planning', { active: true })
expect((await assembleFor(ctx, planning)).tools.map(tool => tool.name))
.toEqual(['exit_plan_mode', 'read', 'added-later'])
const defaulted = await agentWithSession(ctx, 'defaulted')
@@ -488,13 +437,13 @@ describe('the soft layer', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
await ctx.plugin(FakeRuntime)
await ctx.plugin(ModesService, PLAN_CONFIG)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
registerNamedTools(ctx, ['read', 'write'])
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
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.
// The SDK documents the full binding set plus the exit; plan mode never
// prunes capabilities and restrains through guidance alone.
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toContain('read(args:')
expect(sdk).toContain('write(args:')
@@ -511,9 +460,9 @@ describe('the soft layer', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'both' })
await ctx.plugin(FakeRuntime)
await ctx.plugin(ModesService, PLAN_CONFIG)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
registerNamedTools(ctx, ['read', 'write'])
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
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.
@@ -530,22 +479,22 @@ describe('the soft layer', () => {
readonly isolation = 'fake'
run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
}
const withModes = new Context()
await withModes.plugin(SystemPrompt)
await withModes.plugin(ToolRegistry, { mode: 'code' })
await withModes.plugin(FakeRuntime)
await withModes.plugin(ModesService, PLAN_CONFIG)
registerNamedTools(withModes, ['read', 'write'])
const agent = await agentWithSession(withModes)
const defaultSdk = (await assembleFor(withModes, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
const withPlanMode = new Context()
await withPlanMode.plugin(SystemPrompt)
await withPlanMode.plugin(ToolRegistry, { mode: 'code' })
await withPlanMode.plugin(FakeRuntime)
await withPlanMode.plugin(PlanModeService, PLAN_CONFIG)
registerNamedTools(withPlanMode, ['read', 'write'])
const agent = await agentWithSession(withPlanMode)
const defaultSdk = (await assembleFor(withPlanMode, 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 assembleFor(withModes, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
agent.session.append('plan/mode', { active: true })
const planSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(planSdk).toBe(defaultSdk)
// Loading the mode plugin deliberately adds one stable binding compared
// Loading the plan-mode plugin deliberately adds one stable binding compared
// with a deployment that does not compose plan mode at all.
const bare = new Context()
await bare.plugin(SystemPrompt)
@@ -556,14 +505,6 @@ describe('the soft layer', () => {
expect(bareSdk).not.toContain('exit_plan_mode(args:')
expect(defaultSdk).not.toBe(bareSdk)
})
it('treats a dropped folded definition as the default mode', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write'])
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 beyond the exit tool', () => {
@@ -577,70 +518,59 @@ describe('no execution gating beyond the exit tool', () => {
expect(defaulted.isError).toBe(false)
})
it('runs every call in plan mode untouched — modes restrain by guidance, enforcement knobs are separate axes', async () => {
it('runs every call in plan mode untouched — guidance and enforcement are separate axes', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', 'bash'])
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
for (const name of ['read', 'write', 'bash']) {
const result = await execute(ctx, name, agent)
expect(result.isError).toBe(false)
}
})
it('treats a dropped folded definition as the default mode', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['write'])
const agent = await agentWithSession(ctx, 'agent-1', { mode: 'retired' })
const result = await execute(ctx, 'write', agent)
expect(result.isError).toBe(false)
})
})
describe('per-mode slash commands', () => {
it('registers one entry command per configured mode only when a commands service is composed', async () => {
describe('/plan', () => {
it('registers only when a commands service is composed and optionally submits the next-step message', async () => {
const bare = await setup()
expect(bare.get('commands')).toBeUndefined()
const ctx = await setup({ modes: {
plan: { section: TEST_PLAN_SECTION },
review: { section: 'Review mode instructions.' },
} })
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)
const steer = vi.fn()
;(agent as unknown as { steer: typeof steer }).steer = steer
expect(ctx.commands.list(agent)).toEqual([
const plainAgent = await agentWithSession(ctx, 'plain-plan-command')
const plainSteer = vi.fn()
;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer
expect(ctx.commands.list(plainAgent)).toEqual([
{ name: 'plan', description: 'Enter plan mode', input: { hint: '[message]' } },
{ name: 'review', description: 'Enter review mode', input: { hint: '[message]' } },
])
const signal = new AbortController().signal
expect(await ctx.commands.execute(agent, '/mode', signal)).toBeUndefined()
const plan = await ctx.commands.execute(agent, '/plan draft the migration ', signal)
expect(await ctx.commands.execute(plainAgent, '/mode', signal)).toBeUndefined()
expect(await ctx.commands.execute(plainAgent, '/review', signal)).toBeUndefined()
const plain = await ctx.commands.execute(plainAgent, '/plan', signal)
expect(plain).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step).' })
expect(ctx.planMode.get(plainAgent)).toEqual({ active: false, pending: true })
expect(plainSteer).not.toHaveBeenCalled()
const messageAgent = await agentWithSession(ctx, 'message-plan-command')
const messageSteer = vi.fn()
;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer
const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', signal)
expect(plan).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step).' })
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
expect(steer).toHaveBeenCalledExactlyOnceWith([{ type: 'text', text: 'draft the migration' }])
const review = await ctx.commands.execute(agent, '/review', signal)
expect(review).toEqual({ kind: 'success', text: 'Entering review mode (applies from the next step).' })
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: 'review' })
expect(steer).toHaveBeenCalledOnce()
expect(ctx.planMode.get(messageAgent)).toEqual({ active: false, pending: true })
expect(messageSteer).toHaveBeenCalledExactlyOnceWith([{ type: 'text', text: 'draft the migration' }])
})
it('removes every contributed command when the mode plugin is disposed', async () => {
it('removes the contributed command when the plan-mode plugin is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(CommandService)
const fiber = await ctx.plugin(ModesService, { modes: {
plan: { section: TEST_PLAN_SECTION },
review: { section: 'Review mode instructions.' },
} })
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
await new Promise(resolve => setImmediate(resolve))
const agent = await agentWithSession(ctx)
expect(ctx.commands.list(agent).map(command => command.name)).toEqual(['plan', 'review'])
expect(ctx.commands.list(agent).map(command => command.name)).toEqual(['plan'])
await fiber.dispose()
@@ -661,7 +591,7 @@ describe('exit_plan_mode', () => {
},
})
}
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
return { ctx, agent, asked }
}
@@ -708,16 +638,16 @@ describe('exit_plan_mode', () => {
expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a non-empty markdown plan starting with a # heading' }])
}
expect(asked).toHaveLength(0)
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('degrades to the manual exit when no user-interaction seam is composed', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
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' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('degrades the same way when the seam has no provider (NO_PROVIDER)', async () => {
@@ -725,7 +655,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction provider is registered' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('approve: records the boundary-applied switch and confirms (the fold flips at the flush)', async () => {
@@ -735,10 +665,10 @@ describe('exit_plan_mode', () => {
expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }])
// Boundary-applied, not a direct append: the fold stays plan until the
// step's end, so the plan policy covers any remaining call of the SAME batch.
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE, pending: DEFAULT_MODE })
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
expect(foldPlanMode(agent.session.events)).toBe(false)
expect(asked).toHaveLength(1)
expect(asked[0]?.agent).toBe(agent)
expect(asked[0]?.questions[0]?.detail).toBe('# The plan\n\ndo things')
@@ -760,7 +690,7 @@ describe('exit_plan_mode', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
await ctx.plugin(ExitRuntime)
await ctx.plugin(ModesService, PLAN_CONFIG)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
await ctx.plugin(UserInteractionService)
const asked: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
@@ -769,7 +699,7 @@ describe('exit_plan_mode', () => {
return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
},
})
const agent = await agentWithSession(ctx, 'code-mode-exit', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'code-mode-exit', { active: true })
const result = await ctx.tools.execute({
callId: CallId(`call-exit-${++callCounter}`),
@@ -791,7 +721,7 @@ describe('exit_plan_mode', () => {
arguments: { plan },
isError: false,
})
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE, pending: DEFAULT_MODE })
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
})
it('an approved exit keeps plan guidance until the boundary and never removes the tool', async () => {
@@ -801,15 +731,15 @@ describe('exit_plan_mode', () => {
// Calls of the SAME assistant response (no boundary between) were
// requested under the plan-shaped header — the fold stays plan for that
// whole batch; the boundary flush is what flips the next step.
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true)
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe(TEST_PLAN_SECTION)
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
expect(foldPlanMode(agent.session.events)).toBe(false)
const afterExit = await ctx.systemPrompt.assemble({ agent })
expect(afterExit.tools).toEqual(assembly.tools)
expect(afterExit.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
expect(afterExit.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
})
it('the exit flush narrates nothing — the tool result is the narration', async () => {
@@ -817,7 +747,7 @@ describe('exit_plan_mode', () => {
header(agent.session)
await callExit(ctx, agent)
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
expect(foldPlanMode(agent.session.events)).toBe(false)
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -826,7 +756,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: consider the resume path' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('keep planning without feedback returns the generic corrective error', async () => {
@@ -841,7 +771,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: add tests first' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('requires exactly the single Approve selection', async () => {
@@ -849,7 +779,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('treats custom text alongside Approve as feedback, not consent', async () => {
@@ -857,7 +787,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: change the tests' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('treats duplicate review answer items as non-consent', async () => {
@@ -871,7 +801,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('a missing answer item reads as keep-planning', async () => {
@@ -900,13 +830,13 @@ describe('exit_plan_mode', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(ModesService, PLAN_CONFIG)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
await ctx.plugin(UserInteractionService)
let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void
ctx.userInteraction.registerProvider({
ask: () => new Promise((resolve) => { answer = resolve }),
})
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
const pending = callExit(ctx, agent)
// Let execute reach the review await, then unload the plugin (HMR) and
// only afterwards approve. The boundary listeners are gone, so a success
@@ -916,8 +846,8 @@ describe('exit_plan_mode', () => {
answer({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
const result = await pending
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: the mode service was reloaded while the plan was under review; present the plan again' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(result.content).toEqual([{ type: 'text', text: 'Error: the plan-mode service was reloaded while the plan was under review; present the plan again' }])
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => {
@@ -926,7 +856,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('presents the call as a generic card titled by the plan first heading', async () => {
@@ -963,7 +893,7 @@ describe('HMR disposal', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(ModesService, PLAN_CONFIG)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx, 'disposed-in-flight-recovery')
const recoveryEntered = Promise.withResolvers<true>()
const releaseRecovery = Promise.withResolvers<true>()
@@ -972,7 +902,7 @@ describe('HMR disposal', () => {
await releaseRecovery.promise
return { action: 'retry' }
})
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
const recovery = recoveryBoundary(ctx, agent, { action: 'fail' })
await recoveryEntered.promise
@@ -980,25 +910,25 @@ describe('HMR disposal', () => {
releaseRecovery.resolve(true)
expect(await recovery).toEqual({ action: 'retry' })
expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(false)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
it('unregisters the service, listeners, prompt section, and stable exit tool with the plugin fiber', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(ModesService, PLAN_CONFIG)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx, 'disposed-recovery')
ctx.modes.set(agent, PLAN_MODE)
expect(ctx.get('modes')).toBeInstanceOf(ModesService)
ctx.planMode.set(agent, true)
expect(ctx.get('planMode')).toBeInstanceOf(PlanModeService)
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).toContain('mode:policy')
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).toContain('plan:policy')
await fiber.dispose()
expect(ctx.get('modes')).toBeUndefined()
expect(ctx.get('planMode')).toBeUndefined()
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('mode:policy')
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy')
expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(false)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
})

View File

@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
## At a glance
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection and permission presets, and **session modes** (the picker, via `@deepseek-ai/dsh-mode`). The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection and permission presets, and **session modes** (the picker, via `@deepseek-ai/dsh-plan-mode`). The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
## 1. Agent methods (client → agent)
@@ -25,7 +25,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. |
| `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. |
| `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-mode` mounted, `session/new`/`session/load` advertise `availableModes`/`currentModeId` and `session/set_mode` records the pending intent (optimistic `current_mode_update`; the logged `mode/set` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). |
| `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-plan-mode` mounted, `session/new`/`session/load` advertise the fixed `default` / `plan` projection and `session/set_mode` records the boolean pending intent (optimistic `current_mode_update`; logged `plan/mode` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |
| model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. |
| `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. |
@@ -85,7 +85,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. |
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated``{ sessionUpdate: 'plan', entries }`). |
| `available_commands_update` | S | ✅ | ✅ | ✅ | Full effective snapshot after create/load and registry changes; names, descriptions, and unstructured-input hints come from `ctx.commands`. |
| `current_mode_update` | S | ✅ | ✅ | ✅ | Echoed optimistically on `session/set_mode` and re-notified on each logged `mode/set` that differs from the last sent (covers the `exit_plan_mode` tool flipping the session back). |
| `current_mode_update` | S | ✅ | ✅ | ✅ | Echoed optimistically on `session/set_mode` and re-notified when a logged `plan/mode` maps to a different wire id (covers the `exit_plan_mode` tool flipping the session back). |
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox Agent Note § Per-session mode switching](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). |
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
| `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. |
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
## 6. Session modes / config options / models
Session modes ✅ (the [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md)): the picker maps 1:1 onto `dsh-mode`'s vocabulary — `ctx.modes.list()` fills `availableModes`, `session/set_mode` calls `set()` (pending intent, flushed at the turn boundary), and `current_mode_update` tracks both the optimistic echo and every logged flip. Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. The division is picker-to-modes / knobs-to-config-options: individual environment knobs and the provider/model selector are not modes. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Session modes ✅ (the [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md)): ACP owns the fixed `default` / `plan` wire vocabulary and projects it onto `ctx.planMode`'s boolean `{ active, pending? }` state; `session/set_mode` calls `set()` and `current_mode_update` tracks the optimistic selection plus each distinct committed `plan/mode` flip. Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. The division is picker-to-collaboration-state / knobs-to-config-options: individual environment knobs and the provider/model selector are not modes. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## 7. Content blocks

View File

@@ -38,7 +38,7 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-mode": "^0.0.1",
"@deepseek-ai/dsh-plan-mode": "^0.0.1",
"@deepseek-ai/dsh-permission": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -62,7 +62,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-mode": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -67,9 +67,9 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence'
// Type-only edge: makes `ctx.get('modes')` resolve the ModesService type when
// @deepseek-ai/dsh-mode is composed; the runtime read stays opportunistic.
import type {} from '@deepseek-ai/dsh-mode'
// Type-only edge: resolves `ctx.get('planMode')` when dsh-plan-mode is composed;
// the runtime read stays opportunistic.
import type {} from '@deepseek-ai/dsh-plan-mode'
// Side-effect type import: declaration-merges prompt assembly onto Context and
// the scoped waterfall used to keep persona variables aligned with requests.
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -100,6 +100,18 @@ function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
}
const DEFAULT_SESSION_MODE_ID = 'default'
const PLAN_SESSION_MODE_ID = 'plan'
const AVAILABLE_SESSION_MODES = [
{ id: DEFAULT_SESSION_MODE_ID, name: DEFAULT_SESSION_MODE_ID },
{ id: PLAN_SESSION_MODE_ID, name: PLAN_SESSION_MODE_ID },
]
/** Map plan state onto ACP's named collaboration-mode protocol. */
function sessionModeId(active: boolean): string {
return active ? PLAN_SESSION_MODE_ID : DEFAULT_SESSION_MODE_ID
}
/** Render arbitrary thrown values without trusting their string coercion. */
function renderThrown(value: unknown): string {
try {
@@ -298,8 +310,8 @@ interface SessionRecord {
/**
* The last mode id this session sent to the client (advertised at
* session/new+load, echoed optimistically on session/set_mode, re-notified on
* each logged `mode/set` that differs). `undefined` when dsh-mode is not
* composed no mode surface is advertised, so nothing is ever notified.
* each logged `plan/mode` that differs). `undefined` when dsh-plan-mode is
* not composed, so no mode surface is advertised or notified.
*/
lastModeId: string | undefined
/** Session-local provider/model selection and the current step snapshot. */
@@ -558,21 +570,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- Stream the harness event taxonomy to ACP session/update --------------
// --- Session modes (dsh-mode, opportunistic) ------------------------------
// The mode PICKER is dsh-mode's ACP surface (the plan-mode Agent Note): advertised
// as `modes` on session/new + session/load, switched via session/set_mode —
// optimistic `current_mode_update` (the pending mode IS the user's
// selection; the logged `mode/set` follows at the turn boundary) — and
// re-notified on each logged flip that differs from the last sent (covers
// the exit_plan_mode tool flipping the session back). Environment knobs are
// NOT modes; they stay `session/set_config_option`.
// --- Session modes (dsh-plan-mode, opportunistic) -------------------------
// ACP's generic mode picker projects the one plan capability as the fixed
// `default` / `plan` vocabulary. A selection is echoed optimistically; the
// logged `plan/mode` follows at the boundary and tool-driven exits are
// re-notified from that event. Environment knobs remain config options.
const modesStateFor = (agent: Agent): SessionModeState | undefined => {
const modes = ctx.get('modes')
if (modes === undefined) return undefined
const { current, pending } = modes.get(agent)
const planMode = ctx.get('planMode')
if (planMode === undefined) return undefined
const { active, pending } = planMode.get(agent)
return {
availableModes: modes.list().map(name => ({ id: name, name })),
currentModeId: pending ?? current,
availableModes: AVAILABLE_SESSION_MODES,
currentModeId: sessionModeId(pending ?? active),
}
}
@@ -601,16 +610,19 @@ export function apply(ctx: Context, config: AcpConfig): void {
cwd: session.header.cwd,
}, { includeUserMessages: false })
} finally {
// Re-notify from the EVENT's value, not from modes.get(): the service
// Re-notify from the EVENT's value, not from planMode.get(): the service
// holds one coalesced pending slot (every flush reads the latest
// selection, so a flush can never be stale against the picker), and for
// any other writer — the exit tool, a test, a foreign plugin — the logged
// value IS the truth the picker should track, in log order. Inside the
// containment `finally` like the prompt settlement: a throwing presenter
// must not desync the picker.
if (event.type === 'mode/set' && event.data.mode !== rec.lastModeId) {
rec.lastModeId = event.data.mode
notify({ sessionId: rec.agent.session.id, update: { sessionUpdate: 'current_mode_update', currentModeId: event.data.mode } })
if (event.type === 'plan/mode') {
const modeId = sessionModeId(event.data.active)
if (modeId !== rec.lastModeId) {
rec.lastModeId = modeId
notify({ sessionId: rec.agent.session.id, update: { sessionUpdate: 'current_mode_update', currentModeId: modeId } })
}
}
const inflight = rec.inflight
if (inflight !== undefined && event.type === 'turn/start') {
@@ -910,16 +922,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))
const modes = ctx.get('modes')
if (modes === undefined) throw invalidParams('session modes are not composed in this deployment')
try {
modes.set(rec.agent, params.modeId)
} catch (error) {
// ModesService.set throws only Error (its unknown-name validation).
throw invalidParams((error as Error).message)
const planMode = ctx.get('planMode')
if (planMode === undefined) throw invalidParams('session modes are not composed in this deployment')
if (params.modeId !== DEFAULT_SESSION_MODE_ID && params.modeId !== PLAN_SESSION_MODE_ID) {
throw invalidParams(`unknown session mode ${JSON.stringify(params.modeId)} — available modes: default, plan`)
}
planMode.set(rec.agent, params.modeId === PLAN_SESSION_MODE_ID)
// Optimistic echo: the pending mode IS the user's selection; the logged
// `mode/set` lands at the next turn boundary and, matching lastModeId,
// `plan/mode` lands at the next turn boundary and, matching lastModeId,
// is not re-notified. A no-op selection (already current) echoes too —
// cheap, idempotent, and the picker settles regardless.
rec.lastModeId = params.modeId

View File

@@ -17,7 +17,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import ModesService from '@deepseek-ai/dsh-mode'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
import {
ClientSideConnection,
ndJsonStream,
@@ -192,7 +192,7 @@ export async function makeBridgeHarness(options: {
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
*/
withTodo?: boolean
/** Plug the REAL `dsh-mode` plugin so a test can drive the session-mode picker. */
/** Plug the REAL `dsh-plan-mode` plugin so a test can drive the session-mode picker. */
withModes?: boolean
/**
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
@@ -229,7 +229,7 @@ export async function makeBridgeHarness(options: {
await ctx.plugin(ToolTodo)
}
if (options.withModes) {
await ctx.plugin(ModesService, { modes: { plan: { section: 'Test plan mode instructions.' } } })
await ctx.plugin(PlanModeService, { section: 'Test plan mode instructions.' })
}
if (options.withFs) {
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })

View File

@@ -13,7 +13,7 @@ function modeUpdates(updates: CapturedUpdate[]): string[] {
.map(update => update.currentModeId)
}
describe('acp bridge — session modes (dsh-mode)', () => {
describe('acp bridge — plan mode projection', () => {
let storageDir: string
let harness: BridgeHarness | undefined
let loader: BridgeHarness | undefined
@@ -26,7 +26,7 @@ describe('acp bridge — session modes (dsh-mode)', () => {
await rm(storageDir, { recursive: true, force: true })
})
it('advertises no mode surface and rejects session/set_mode when dsh-mode is not composed', async () => {
it('advertises no mode surface and rejects session/set_mode when plan mode is not composed', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -55,15 +55,15 @@ describe('acp bridge — session modes (dsh-mode)', () => {
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
expect(modeUpdates(harness.updates)).toEqual(['plan'])
const agent = harness.ctx.agents.get(SessionId(sessionId))!
expect(harness.ctx.modes.get(agent)).toEqual({ current: 'default', pending: 'plan' })
expect(harness.ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('rejects an unknown mode id with the service validation message', async () => {
it('rejects an unknown ACP mode id at the adapter boundary', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.setSessionMode({ sessionId, modeId: 'nope' }))
.rejects.toMatchObject({ message: expect.stringContaining('unknown mode "nope"') as string })
.rejects.toMatchObject({ message: expect.stringContaining('unknown session mode "nope"') as string })
expect(modeUpdates(harness.updates)).toEqual([])
})
@@ -74,7 +74,7 @@ describe('acp bridge — session modes (dsh-mode)', () => {
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(true)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(true)
expect(modeUpdates(harness.updates)).toEqual(['plan'])
})
@@ -87,7 +87,7 @@ describe('acp bridge — session modes (dsh-mode)', () => {
// A writer other than the picker (exit_plan_mode's execute) appends the
// flip back; the bridge must re-notify the client off the logged event.
const agent = harness.ctx.agents.get(SessionId(sessionId))!
agent.session.append('mode/set', { mode: 'default' })
agent.session.append('plan/mode', { active: false })
// The notification crosses the in-memory JSON-RPC transport asynchronously.
await new Promise(resolve => setTimeout(resolve, 20))
expect(modeUpdates(harness.updates)).toEqual(['plan', 'default'])

View File

@@ -42,7 +42,7 @@
"path": "../user-interaction"
},
{
"path": "../../mode/mode"
"path": "../../plan/plan-mode"
},
{
"path": "../../session-persistence/session-persistence"

View File

@@ -22,7 +22,7 @@ The terminal and ACP app bundles mount this service with their consuming front d
#### What the model sees
The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-mode`](../../mode/mode/README.md#per-mode-slash-commands) submits the optional message in `/plan [message]` after selecting the mode.
The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) submits the optional message in `/plan [message]` after selecting plan mode.
#### Token effect

View File

@@ -14,7 +14,7 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly and unknown commands produce a warning, with no automatic fallthrough to the model. A command producer may explicitly schedule agent work; [`dsh-mode`](../../mode/mode/README.md#per-mode-slash-commands) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly and unknown commands produce a warning, with no automatic fallthrough to the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.