fix(mode): stabilize plan-mode model experience

This commit is contained in:
Tianyi Cui
2026-07-20 22:13:59 +08:00
parent 28a74d6d7e
commit 897dc82d9b
40 changed files with 620 additions and 298 deletions

View File

@@ -23,7 +23,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`spill/`](spill/README.md) | Spill capability family: the storage seam, a local impl, and the 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-mode policy family: plan mode as logged per-agent state with soft/hard enforcement | Product — stable surface |
| [`mode/`](mode/README.md) | Session-mode policy family: plan mode as logged per-agent guidance with a user-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

@@ -987,7 +987,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AskUserQuestionItem',
declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n}',
declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n detail?: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n}',
},
{
name: 'AskUserQuestionOption',

View File

@@ -40,15 +40,10 @@ function serializeAssistant(message: Message): WireMessage {
return {
role: 'assistant',
// Text-less turns send "" — NEVER null. Pure tool-call turns: the
// official samples replay message.content verbatim (which is "") and
// some gateways reject null outright. Reasoning-ONLY turns (the model
// can answer entirely in the reasoning channel, e.g. a v4-flash
// greeting): the live API rejects null-content/no-tool_calls assistant
// messages with a 400 ("content or tool_calls must be set"), and since
// the message sits durably in the session log, a null here bricks every
// later turn of that session.
content: text,
// Tool-call turns send "" rather than null: the live API answers both,
// but the official samples replay message.content verbatim (which is ""
// for pure tool-call responses) and some gateways reject null outright.
content: text.length > 0 ? text : toolCalls.length > 0 ? '' : null,
// Official passback rule (guides/thinking_mode.mdx): reasoning_content
// must return on tool-call turns; it is ignored on plain turns, so we
// drop it there to save tokens.

View File

@@ -187,22 +187,12 @@ describe('serializeRequest', () => {
})
})
describe('review fixes: assistant content shapes', () => {
it('serializes a content-less, tool-call-less assistant message as "" content, never null', () => {
// Aborted/empty assistant turns: no text, no calls → "". The earlier
// null shape was live-falsified: the API 400s a null-content assistant
// message without tool_calls ("content or tool_calls must be set").
describe('assistant empty and tool-call content shapes', () => {
it('serializes a content-less, tool-call-less assistant message as null content', () => {
// Aborted/empty assistant turns: no text, no calls → null (the wire
// accepts it; "" is reserved for tool-call turns per the samples).
const wire = serializeMessages([{ role: 'assistant', content: [] }])
expect(wire).toEqual([{ role: 'assistant', content: '' }])
})
it('serializes a reasoning-ONLY assistant message as "" content with the reasoning dropped', () => {
// The model can answer entirely in the reasoning channel (a v4-flash
// greeting did, live). The passback rule keeps reasoning_content off
// plain turns, and content must still be SET — a null here poisoned the
// session log and bricked every later turn of that session.
const wire = serializeMessages([{ role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }] }])
expect(wire).toEqual([{ role: 'assistant', content: '' }])
expect(wire).toEqual([{ role: 'assistant', content: null }])
})
it('serializes tool-call turns with empty string content, not null', () => {

View File

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

View File

@@ -1,31 +1,35 @@
# @deepseek-ai/dsh-mode
Session modes: named, logged, per-agent COLLABORATION states. **Plan mode** is the first shipped definition the agent explores and designs under a planning stance, produces a reviewable plan, and crosses back through an explicit review. Modes are one axis; enforcement knobs (the sandbox mode, the approval policy) are others — they never read or write each other, matching how Codex keeps its Plan/Default collaboration presets separate from its sandbox and approval settings.
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 mode in force (the last `mode/set`, else `default`). Because the log is the fact channel, resume, fork, and compaction restore the mode with no extra machinery, and UIs read flips off `session/event` — there is no live mirror.
`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 is the absence of policy: no section, no extra tool. An agent that never sees a `mode/set` behaves byte-identically to a deployment that never loads this plugin.
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 guidance section.** A `system-prompt/assemble` listener renders the mode's `section` text as the `mode:policy` section (order 50) while the mode is in force, and shows the `exit_plan_mode` tool IFF the folded mode is `plan` — on the wire and, under the registry's Code Mode, in the `tools:sdk` section alike. Every transition therefore surfaces as an attributable complete `request/header` event on the next step; entering or leaving plan changes both the section and the exit-tool catalog.
The 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.
**Deliberately absent: enforcement.** A mode never gates execution, filters the toolset, or touches the sandbox/approval knobs — those are independent axes the user switches separately (a deployment that wants a hard read-only floor while planning flips the sandbox-mode option beside the mode picker, in either order; neither disturbs the other). A per-mode tool allow/deny list is likewise out: which tools a mode admits is an effects question — a per-tool read-only/mutating classification the harness does not yet have — parked until tool definitions declare their effects (the plan-mode RFC's deferred item). The config vocabulary is exactly `{ section }`, and an unknown key (a `tools` list or an `access` cap included) fails loud at load.
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 the selectable vocabulary (`default` first, then the configured definitions); `get(agent)` returns the folded mode (a folded name the config no longer defines reads as `default`) plus any pending intent; `set(agent, mode)` validates against `list()` (loud on unknown; `default` is always a valid target) and records a pending intent — every session event is turn-enclosed and an idle agent has no open turn, so the service flushes the intent on the loop's interception seams (`agent/prompt-submit` inside the just-opened turn, `agent/turn-continuation` after each step closed — both outside any log emit, where a post-commit `session/event` observer could not append) and, when the flushed mode differs from what the last logged request header told the model, appends one coalesced `context/message` notice in the same frame. A net-zero flip sequence appends nothing.
`list()` returns `default` followed by the configured definitions. `get(agent)` returns the folded mode, treating a removed definition as `default`, plus any pending intent. `set(agent, mode)` validates against that vocabulary and records a pending intent. The service flushes the intent on `agent/prompt-submit` before the first assembly, `agent/turn-continuation` before a normal successor step, or after a composed `agent/request-error` decision authorizes a retry. Each append is turn-enclosed and precedes the affected prompt assembly, including an automatic recovery step after asynchronous backoff. A changed user selection adds one coalesced `context/message` notice when the last logged request header described a different mode; a net-zero selection sequence adds nothing.
`AgentOptions.mode` (declaration-merged) seeds a child's initial mode through the same pending-intent flush; explicit options beat the logged baseline on create AND resume. A fork child needs no mechanism — the parent's `mode/set` is inside the seeded prefix.
`AgentOptions.mode` seeds an initial mode through the same pending-intent path. Forked sessions inherit mode state through their logged prefix.
## `exit_plan_mode`
The model-facing exit tool. Its single required argument is the plan text — a durable, replayable log artifact riding the ordinary `tool/call` event. `execute` re-checks the folded mode, then conducts the review over the user-interaction seam (`ctx.get('userInteraction')`, opportunistic): one single-select question — Approve, or Keep planning — with the free-text channel open. Approve records the switch back to `default` as a silent boundary-applied pending intent (flushed at this step's end — the plan surface keeps holding for any remaining call of the same assistant response) and the next step reflects the exit; every other outcome (keep-planning with the user's feedback verbatim, an aborted question, no provider) returns the corrective `isError` and the mode stays `plan`. `presentCall` renders a `generic` card titled by the plan's first heading with the plan markdown as content; over ACP the review rides the same elicitation flow as `ask_user_question`, in the terminal the stdio provider's prompt queue.
[`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 [plan ACP example](../../../examples/plan-acp-agent/cordis.yml) for the maintained production-shaped instructions.
```yaml
- id: mode
name: '@deepseek-ai/dsh-mode'
@@ -33,51 +37,65 @@ The model-facing exit tool. Its single required argument is the plan text — a
modes:
plan:
section: |
You are in plan mode: ...
You are in plan mode. Explore first, make no changes, and present a decision-complete plan through exit_plan_mode.
```
Definitions are validated at load (`resolveConfig`): the built-in `plan` (the shipped guidance section) merges unless overridden, `default` is rejected as a key, and any other key — a `tools` list or an `access` cap included — fails loud. An unknown mode name fails loudly at `set()` time.
`resolveConfig` requires `modes.plan.section`, rejects `default` as a definition key, rejects blank sections and unknown definition keys, and preserves any further named modes. `set()` rejects an unknown mode name.
## Model Experience
### System prompt and mode tool
### Mode guidance section
#### What the model sees
In `default`, no `mode:policy` text appears and the registered `exit_plan_mode` tool is filtered from native schemas and the Code Mode SDK, making the request identical to a deployment without this plugin. A configured non-default mode renders its exact section at order 50; `plan` also exposes the [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-mode). A user-driven transition whose prior header described another mode appends one coalesced notice naming the new mode.
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/plan-acp-agent/cordis.yml) owns the plan instructions used by the shipped composition.
##### Plan-mode policy section
#### 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 and appends `Mode "<mode>" is no longer defined in this deployment's configuration; the session continues in the default mode.` once per removed name. Initial selection before the first header, net-zero selections, and the tool-driven exit add no notice.
#### 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; a dropped-definition notice with no section change preserves the prior request prefix and only extends it.
### 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
You are in plan mode: a planning state. Explore, analyze, and design; reading files and running read-only commands is fine, but hold off on changes — edits and other side effects belong in the plan and run after its approval, not in this mode. When a decision or a missing detail blocks the plan, ask the user through the ask_user_question tool where it is available. A finished plan is delivered by calling exit_plan_mode — that call is what puts it in front of the user for review, so prefer it over pasting the plan as a plain reply or asking the user to switch modes themselves. If exit_plan_mode is unavailable or its review fails, ask the user to switch the session out of plan mode instead of pressing on.
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
`default` adds no tokens. Plan mode adds the policy section and one tool schema on each request; each qualifying user transition adds one short conversation notice.
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
Within one mode, the section and catalog are stable. Entering or leaving plan changes the system prompt at order 50 and adds or removes the exit-tool schema, so the request takes a different cache path; bytes before the section remain a reusable prefix where the provider supports prefix caching.
### Exit review
#### What the model sees
The call carries the complete plan markdown as ordinary tool arguments. Approval returns `Plan approved — plan mode exited; carry out the plan starting with your next step.`; every non-approval returns a corrective error containing the reviewer's feedback when provided.
#### Token effect
The plan is paid once as tool arguments and remains in the conversation. Each rejection adds its feedback result, and a later revision adds another complete plan call.
#### KV Cache effect
The review call and result extend the conversation normally. An approved exit changes the next request's earlier mode section and removes the exit-tool schema, so that request follows the default-mode cache path rather than the plan-mode path.
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** — nothing gates execution while a mode holds; a user who wants a hard floor pairs the mode with the independent sandbox/approval knobs. The [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) archives the two removed enforcement shapes (the interim allowlist, the `access` sandbox cap) and their restart trigger (effects self-declaration on tool definitions).
- **A pending flip set while idle dies with the process** — the UI re-applies; the idle-record primitive is the escape hatch if this bites.
- **Subagent mode inheritance is deferred** — a fork child inherits via the seeded prefix; a spawn child starts default unless its creator seeds `AgentOptions.mode`.
- A mode restrains by guidance only; pair it with independent enforcement knobs when a hard boundary is required. The [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) records the rejected enforcement shapes and the effects-metadata restart trigger.
- A pending flip selected while idle is lost if the process exits before the next boundary; the UI must reapply it.
- Forked children inherit the logged mode, while spawned children start in `default` unless their creator seeds `AgentOptions.mode`.
Design: [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md).

View File

@@ -27,7 +27,7 @@
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -38,6 +38,6 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -8,13 +8,13 @@
* 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 RFC's deferred item). The mode IN FORCE for an
* 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 policy: no section, no extra tool. An
* agent that never sees a `mode/set` behaves byte-identically to a deployment
* that never loads this plugin, so it is safe to compose unconditionally.
* 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
@@ -35,7 +35,7 @@
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool, renderToolsSdk, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
@@ -74,14 +74,12 @@ declare module 'cordis' {
*/
export const DEFAULT_MODE = 'default'
/** The one shipped mode definition's name. */
/** The required plan definition's name. */
export const PLAN_MODE = 'plan'
/**
* The model-facing exit tool's name. The assemble filter shows the tool IFF the
* folded mode is {@link PLAN_MODE}, which keeps a default-mode assembly
* byte-identical to a deployment without this plugin even though the tool is
* always registered.
* 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'
@@ -97,33 +95,22 @@ export interface ModeDefinition {
}
/**
* Plugin config: mode definitions by name. The built-in {@link PLAN_MODE}
* definition is merged in unless overridden; {@link DEFAULT_MODE} is rejected
* as a key ({@link resolveConfig} throws at load).
* Plugin config: mode definitions by name. The deployment must define
* {@link PLAN_MODE}, including its complete model instructions;
* {@link DEFAULT_MODE} is rejected as a key ({@link resolveConfig} throws at
* load).
*/
export interface ModeConfig {
/** Mode definitions by name, overriding or extending the built-in `plan`. */
modes?: Record<string, ModeDefinition>
/** Mode definitions by name; `plan` is required and owns its full prompt text. */
modes: Record<string, ModeDefinition>
}
/** Validated mode definitions: the built-in `plan` merged with (or replaced by) the configured ones. */
/** Validated deployment-owned mode definitions, including `plan`. */
export interface ResolvedModes {
/** Definitions by mode name; never contains {@link DEFAULT_MODE}. */
definitions: ReadonlyMap<string, ModeDefinition>
}
const PLAN_SECTION
= 'You are in plan mode: a planning state. Explore, analyze, and design; reading '
+ 'files and running read-only commands is fine, but hold off on changes — edits '
+ 'and other side effects belong in the plan and run after its approval, not in '
+ 'this mode. When a decision or a missing detail blocks the plan, ask the '
+ 'user through the ask_user_question tool where it is available. A finished plan '
+ 'is delivered by calling exit_plan_mode — that call is what puts it in front of '
+ 'the user for review, so prefer it over pasting the plan as a plain reply or '
+ 'asking the user to switch modes themselves. If exit_plan_mode is unavailable or '
+ 'its review fails, ask the user to switch the session out of plan mode instead '
+ 'of pressing on.'
/** The review question's approve option label — the answer item is matched by it. */
const APPROVE_LABEL = 'Approve'
@@ -131,11 +118,16 @@ const APPROVE_LABEL = 'Approve'
const KEEP_PLANNING_LABEL = 'Keep planning'
const EXIT_DESCRIPTION
= 'Present your plan for the user\'s review and, on approval, leave plan mode. '
= '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.'
/** Durable notice text for a folded mode the current deployment no longer defines. */
function droppedDefinitionNotice(name: string): string {
return `Mode "${name}" is no longer defined in this deployment's configuration; the session continues in the default mode.`
}
/** The plan's first markdown heading (any level), or `undefined` when it has none. */
function firstHeading(plan: string): string | undefined {
for (const line of plan.split('\n')) {
@@ -146,23 +138,29 @@ function firstHeading(plan: string): string | undefined {
}
/**
* Validate the config and merge the built-in `plan` definition (explicit
* resolve step — the `dsh-bash` request/spec template). Fail-loud: a
* {@link DEFAULT_MODE} key or a malformed definition throws at load.
* 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, `plan` included unless overridden.
* @returns The validated definitions, including deployment-configured `plan`.
*/
export function resolveConfig(config: ModeConfig): ResolvedModes {
const definitions = new Map<string, ModeDefinition>()
definitions.set(PLAN_MODE, { section: PLAN_SECTION })
for (const [name, definition] of Object.entries(config.modes ?? {})) {
// 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`)
}
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).
@@ -172,6 +170,9 @@ export function resolveConfig(config: ModeConfig): ResolvedModes {
}
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 }
}
@@ -210,13 +211,13 @@ function modeAtLastHeader(events: readonly SessionEvent[]): string | undefined {
/**
* `ctx.modes`: the session-mode service. Owns the `mode/set` vocabulary, the
* pending-intent flush, the boundary narration, the `mode:policy` section,
* and the exit tool's visibility rule. UIs read mode flips off
* `session/event`; there is no live mirror.
* 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 definitions (built-in `plan` merged unless overridden). */
/** Validated deployment-owned definitions, including `plan`. */
readonly resolved: ResolvedModes
/**
@@ -227,10 +228,10 @@ export class ModesService extends Service {
*/
private readonly pendingIntents = new WeakMap<Session, { mode: string; narrate: boolean }>()
/** The unknown folded-mode name already narrated per session (once per name). */
private readonly droppedNoticed = new WeakMap<Session, string>()
/** Mode-plugin notice texts already present in each live session; hydrated once from the durable log. */
private readonly noticeTexts = new WeakMap<Session, Set<string>>()
constructor(ctx: Context, config: ModeConfig = {}) {
constructor(ctx: Context, config: ModeConfig = { modes: {} }) {
super(ctx, 'modes')
this.resolved = resolveConfig(config)
@@ -274,52 +275,21 @@ export class ModesService extends Service {
text: context => (context.agent === undefined ? '' : this.activeDefinition(context.agent.session)?.definition.section ?? ''),
})
// prepend: the filter wraps OUTSIDE every append-registered listener
// regardless of load order, so their post-next() additions are filtered
// too. It hides exactly ONE thing: the always-registered exit tool, wherever
// the folded mode is not plan — which keeps a default-mode assembly
// byte-identical to a no-dsh-mode deployment (whose registry never saw the
// tool) and keeps custom modes from advertising a binding that only errors.
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
const result = await next()
const agent = context.agent
if (agent === undefined) return result
if (this.activeDefinition(agent.session)?.name === PLAN_MODE) return result
result.tools = result.tools.filter(tool => tool.name !== EXIT_PLAN_MODE)
// Code Mode's soft surface is the SDK section, not the wire schemas —
// section text resolves in assemble's base, so the outermost wrapper
// re-renders it under the same visibility rule the wire filter applies.
rerenderSdk(result, name => name !== EXIT_PLAN_MODE)
return result
}, { prepend: true })
/**
* Re-render the `tools:sdk` section (present only under the registry's
* Code Mode) from the registry schemas the given rule admits — minus
* `run_code` itself, mirroring the registry's own exclusion. A no-op when
* the section is absent (native mode).
*/
function rerenderSdk(result: { sections: { name: string; text: string }[] }, include: (name: string) => boolean): void {
const sdkIndex = result.sections.findIndex(section => section.name === 'tools:sdk')
if (sdkIndex < 0) return
const sdkText = renderToolsSdk(ctx.tools.schemas().filter(schema =>
include(schema.name) && schema.name !== RUN_CODE_NAME))
result.sections = result.sections.map((section, index) =>
index === sdkIndex ? { ...section, text: sdkText } : section)
}
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) => {
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')
@@ -329,6 +299,7 @@ export class ModesService extends Service {
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.' },
@@ -337,8 +308,9 @@ export class ModesService extends Service {
agent,
...exec.signal ? { signal: exec.signal } : {},
})
const item = answer.answers.find(entry => entry.id === 'plan-review')
if (!item?.selected.includes(APPROVE_LABEL)) {
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 ?? ''
@@ -349,13 +321,12 @@ export class ModesService extends Service {
// 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 surface (the section, the exit tool's visibility) keeps
// holding for that whole batch. The flush at this step's end appends
// 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 })
const note = item.custom === undefined || item.custom === '' ? '' : ` User note: ${item.custom}`
return [{ type: 'text', text: `Plan approved — plan mode exited; carry out the plan starting with your next step.${note}` }]
return [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }]
},
presentCall: args => ({
card: 'generic',
@@ -464,12 +435,24 @@ export class ModesService extends Service {
private noticeDroppedDefinition(session: Session): void {
const name = foldMode(session.events)
if (name === DEFAULT_MODE || this.resolved.definitions.has(name)) return
if (this.droppedNoticed.get(session) === name) return
this.droppedNoticed.set(session, name)
const text = droppedDefinitionNotice(name)
let noticed = this.noticeTexts.get(session)
if (noticed === undefined) {
noticed = new Set(session.events.flatMap(event => event.type === 'context/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'mode'
&& event.data.content.length === 1
&& event.data.content[0]?.type === 'text'
? [event.data.content[0].text]
: []))
this.noticeTexts.set(session, noticed)
}
if (noticed.has(text)) return
session.append('context/message', {
content: [{ type: 'text', text: `Mode "${name}" is no longer defined in this deployment's configuration; the session continues in the default mode.` }],
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'mode' },
}, { surfaceOp: 'append' })
noticed.add(text)
}
}

View File

@@ -1,8 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import LlmService, { type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
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'
@@ -10,6 +9,8 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import ModesService, { PLAN_MODE, foldMode } from '@deepseek-ai/dsh-mode'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
const PLAN_CONFIG = { modes: { plan: { section: 'Test plan mode instructions.' } } }
/**
* Full-loop integration: a scripted mock model drives the REAL mode plugin
* through the agent loop — the pending-intent flush at the turn boundary, the
@@ -26,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)
await ctx.plugin(ModesService, PLAN_CONFIG)
ctx.llm.registerAdapter(['mock'], adapter)
for (const name of ['read', 'write']) {
ctx.tools.register(defineTool({
@@ -91,7 +92,7 @@ describe('plan mode through the agent loop', () => {
expect(log.some(event => event.type === 'context/message')).toBe(false)
})
it('a user flip between turns lands at the boundary: one notice and the changed plan header', async () => {
it('a user flip between turns lands at the boundary: one notice and a changed header with stable tool schemas', async () => {
const adapter = new MockAdapter([
textResponse('First turn, default mode.'),
textResponse('Second turn, plan mode.'),
@@ -102,6 +103,8 @@ describe('plan mode through the agent loop', () => {
agent.send([{ type: 'text', text: 'hello' }])
await waitForIdle(ctx, agent)
expect(foldMode(agent.session.events)).toBe('default')
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)
agent.send([{ type: 'text', text: 'now plan' }])
@@ -114,11 +117,51 @@ describe('plan mode through the agent loop', () => {
expect(findEvent(log, 'context/message').data.content).toEqual([
{ type: 'text', text: 'The user switched this session to plan mode.' },
])
// Entering plan changes both the section and tool catalog, so the next
// request logs a complete changed header.
// The changed request is logged as a complete snapshot.
const second = findEvent(log, 'request/header', 'last')
expect(second.data.reason).toBe('change')
expect(second.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
expect(second.data.header.tools).toEqual(first.data.header.tools)
expect(second.data.header.system).toContain('plan mode')
})
it('a mode flip during request recovery shapes the retry before its assembly', async () => {
const failedRequest = [{
type: 'finish',
reason: { kind: 'error', failure: { message: 'temporarily unavailable', code: 'SERVER', status: 503 } },
}] satisfies StreamChunk[]
const adapter = new MockAdapter([failedRequest, textResponse('Recovered in plan mode.')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' })
const recoveryEntered = Promise.withResolvers<true>()
const releaseRecovery = Promise.withResolvers<true>()
ctx.on('agent/request-error', async (subject, _turn, _step, _error, _failure, _history, _signal, next) => {
if (subject !== agent) return next()
recoveryEntered.resolve(true)
await releaseRecovery.promise
return { action: 'retry' }
})
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'plan after the transient failure' }])
await recoveryEntered.promise
ctx.modes.set(agent, PLAN_MODE)
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[1]?.tools).toEqual(adapter.requests[0]?.tools)
const log = agent.session.events
const modeSet = findEvent(log, 'mode/set')
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(findEvent(log, 'context/message').data.content).toEqual([
{ type: 'text', text: 'The user switched this session to plan mode.' },
])
})
})

View File

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
@@ -10,6 +10,9 @@ import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-
import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts'
import type { ModeConfig } from '../src/index.ts'
const TEST_PLAN_SECTION = 'Test plan mode instructions.'
const PLAN_CONFIG = { modes: { plan: { section: TEST_PLAN_SECTION } } } satisfies ModeConfig
/**
* Drives the REAL plugin: mounts `dsh-mode` beside real `SystemPrompt` and
* `ToolRegistry` services, with fake Agents carrying real `Session`s (the
@@ -24,7 +27,7 @@ function agentWithSession(id = 'agent-1', options: { mode?: string } = {}): Agen
return { id: SessionId(id), session, options } as unknown as Agent & { session: Session }
}
async function setup(config?: ModeConfig): Promise<Context> {
async function setup(config: ModeConfig = PLAN_CONFIG): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -82,36 +85,38 @@ function execute(ctx: Context, name: string, agent?: Agent) {
}
describe('resolveConfig', () => {
it('merges the built-in plan definition: a guidance section, nothing else', () => {
const resolved = resolveConfig({})
const plan = resolved.definitions.get(PLAN_MODE)
expect(plan).toEqual({ section: plan?.section })
expect(plan?.section).toContain('plan mode')
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('lets config override plan and add further modes', () => {
it('loads the configured plan instructions and further modes verbatim', () => {
const resolved = resolveConfig({ modes: {
plan: { section: 'custom plan' },
plan: { section: TEST_PLAN_SECTION },
review: { section: 'review' },
} })
expect(resolved.definitions.get(PLAN_MODE)).toEqual({ section: 'custom plan' })
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: { default: { section: '' } } }))
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, default: { section: 'policy' } } }))
.toThrow('"default" is reserved')
})
it('rejects a malformed definition loudly', () => {
expect(() => resolveConfig({ modes: { bad: { section: 5 } as unknown as { section: string } } }))
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, bad: { section: 5 } as unknown as { section: string } } }))
.toThrow('needs a string `section`')
expect(() => resolveConfig({ modes: { plan: { 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: { bad: { section: '', tools: ['read'] } as unknown as { section: string } } }))
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: { bad: { section: '', access: 'read-only' } as unknown as { section: string } } }))
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 }')
})
})
@@ -137,7 +142,7 @@ describe('foldMode', () => {
describe('ctx.modes: list/get/set', () => {
it('lists default first, then the configured definitions', async () => {
const ctx = await setup({ modes: { review: { section: 's' } } })
const ctx = await setup({ modes: { ...PLAN_CONFIG.modes, review: { section: 's' } } })
expect(ctx.modes.list()).toEqual([DEFAULT_MODE, PLAN_MODE, 'review'])
})
@@ -268,6 +273,46 @@ describe('the boundary flush', () => {
expect(noticeTexts(agent.session)).toHaveLength(1)
})
it('does not repeat a dropped-definition notice after the mode service restarts', async () => {
const first = await setup()
const original = agentWithSession('dropped-resume')
original.session.append('mode/set', { mode: 'retired' })
await boundary(first, original, 'turn/start')
const resumed = agentWithSession('dropped-resume')
resumed.session = new Session(SessionId('dropped-resume'), original.session.events)
const second = await setup()
await boundary(second, resumed, 'turn/start')
expect(noticeTexts(resumed.session)).toEqual([
'Mode "retired" is no longer defined in this deployment\'s configuration; the session continues in the default mode.',
])
})
it('retries a dropped-definition notice when its append fails', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
const original = agent.session.append.bind(agent.session)
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'context/message') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'turn/start')
expect(warn).toHaveBeenCalledOnce()
expect(noticeTexts(agent.session)).toEqual([])
agent.session.append = original
await boundary(ctx, agent, 'turn/start')
await boundary(ctx, agent, 'turn/start')
expect(noticeTexts(agent.session)).toEqual([
'Mode "retired" is no longer defined in this deployment\'s configuration; the session continues in the default mode.',
])
})
it('contains an append failure instead of blocking the prompt or the turn', async () => {
const ctx = await setup()
const warn = vi.fn()
@@ -311,13 +356,18 @@ describe('the boundary flush', () => {
})
describe('the soft layer', () => {
it('keeps a default-mode assembly identical to a no-dsh-mode deployment (exit tool dropped)', async () => {
it('keeps the tool schemas identical across default and plan mode', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
const defaultAssembly = await ctx.systemPrompt.assemble({ agent })
expect(defaultAssembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write'])
expect(defaultAssembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
agent.session.append('mode/set', { mode: PLAN_MODE })
const planAssembly = await ctx.systemPrompt.assemble({ agent })
expect(planAssembly.tools).toEqual(defaultAssembly.tools)
expect(planAssembly.sections.find(section => section.name === 'mode:policy')?.text).toBe(TEST_PLAN_SECTION)
})
it('leaves an agent-less assembly untouched', async () => {
@@ -328,32 +378,29 @@ describe('the soft layer', () => {
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
})
it('keeps the full toolset in plan mode, adds the exit tool, and renders the mode section', async () => {
it('keeps the full toolset in plan mode and renders the configured mode section', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', 'todo_write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name).sort()).toEqual([EXIT_PLAN_MODE, 'read', 'todo_write', 'write'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toContain('plan mode')
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe(TEST_PLAN_SECTION)
})
it('drops exit_plan_mode outside plan mode (custom modes never see it)', async () => {
const ctx = await setup({ modes: { review: { section: 'reviewing' } } })
it('keeps exit_plan_mode visible in custom modes while rendering their guidance', async () => {
const ctx = await setup({ modes: { ...PLAN_CONFIG.modes, review: { section: 'reviewing' } } })
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'review' })
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('reviewing')
})
it('leaves foreign post-next() additions alone in plan mode (no general tool filtering)', async () => {
// A foreign listener that post-processes await next(): the mode filter
// wraps outside it (prepend) but hides only the exit tool outside plan —
// a foreign addition survives, because which tools a mode admits is
// deliberately not this plugin's decision (the effects question stays
// parked; module doc).
// A foreign listener that post-processes await next(): the addition
// survives, because modes do not filter the deployment's tool registry.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -362,7 +409,7 @@ describe('the soft layer', () => {
final.tools = [...final.tools, { name: 'added-later', description: 'added after next()', parameters: {} }]
return final
})
await ctx.plugin(ModesService)
await ctx.plugin(ModesService, PLAN_CONFIG)
registerNamedTools(ctx, ['read'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
@@ -382,7 +429,7 @@ describe('the soft layer', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
await ctx.plugin(FakeRuntime)
await ctx.plugin(ModesService)
await ctx.plugin(ModesService, PLAN_CONFIG)
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
@@ -406,13 +453,13 @@ describe('the soft layer', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'both' })
await ctx.plugin(FakeRuntime)
await ctx.plugin(ModesService)
await ctx.plugin(ModesService, PLAN_CONFIG)
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
// ONE visibility rule covers both surfaces: in plan the exit tool is
// present on the wire AND in the SDK, alongside the untouched toolset.
// The stable registry contribution reaches both surfaces: the exit tool
// is present on the wire AND in the SDK alongside the untouched toolset.
expect(assembly.tools.map(tool => tool.name).sort()).toEqual(['exit_plan_mode', 'read', 'run_code', 'write'])
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toContain('read(args:')
@@ -420,7 +467,7 @@ describe('the soft layer', () => {
expect(sdk).toContain('exit_plan_mode(args:')
})
it('default-mode Code Mode SDK is byte-identical to a no-dsh-mode deployment (exit binding hidden)', async () => {
it('keeps the Code Mode SDK byte-identical across mode switches', async () => {
class FakeRuntime extends CodeRuntime {
readonly language = 'typescript'
readonly isolation = 'fake'
@@ -430,23 +477,27 @@ describe('the soft layer', () => {
await withModes.plugin(SystemPrompt)
await withModes.plugin(ToolRegistry, { mode: 'code' })
await withModes.plugin(FakeRuntime)
await withModes.plugin(ModesService)
await withModes.plugin(ModesService, PLAN_CONFIG)
registerNamedTools(withModes, ['read', 'write'])
const agent = agentWithSession()
const sdk = (await withModes.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toContain('read(args:')
expect(sdk).toContain('write(args:')
// The always-registered exit tool is callable only in plan mode, so a
// default-mode SDK advertising it would offer a binding that can only
// error — and diverge from a deployment that never loaded dsh-mode:
const defaultSdk = (await withModes.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(defaultSdk).toContain('read(args:')
expect(defaultSdk).toContain('write(args:')
expect(defaultSdk).toContain('exit_plan_mode(args:')
agent.session.append('mode/set', { mode: PLAN_MODE })
const planSdk = (await withModes.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(planSdk).toBe(defaultSdk)
// Loading the 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)
await bare.plugin(ToolRegistry, { mode: 'code' })
await bare.plugin(FakeRuntime)
registerNamedTools(bare, ['read', 'write'])
const bareSdk = (await bare.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toBe(bareSdk)
expect(sdk).not.toContain('exit_plan_mode(args:')
expect(bareSdk).not.toContain('exit_plan_mode(args:')
expect(defaultSdk).not.toBe(bareSdk)
})
it('treats a dropped folded definition as the default mode', async () => {
@@ -455,7 +506,7 @@ describe('the soft layer', () => {
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write'])
})
})
@@ -522,6 +573,7 @@ describe('exit_plan_mode', () => {
const ctx = await setup()
const schema = ctx.tools.schemas().find(entry => entry.name === EXIT_PLAN_MODE)
const parameters = schema?.parameters as { required?: string[]; properties?: Record<string, unknown> }
expect(schema?.description).toMatch(/^Use only in plan mode\./)
expect(Object.keys(parameters.properties ?? {})).toEqual(['plan'])
expect(parameters.required).toEqual(['plan'])
})
@@ -533,14 +585,26 @@ describe('exit_plan_mode', () => {
expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a calling agent (no session to switch)' }])
})
it('rejects a call outside plan mode (defense in depth behind the gate)', async () => {
it('rejects a call outside plan mode while remaining advertised', async () => {
const ctx = await setup()
const agent = agentWithSession()
expect(ctx.tools.schemas().map(tool => tool.name)).toContain(EXIT_PLAN_MODE)
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode is only available in plan mode' }])
})
it('rejects an empty or heading-less plan before asking the reviewer', async () => {
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
for (const plan of ['', 'do things']) {
const result = await callExit(ctx, agent, plan)
expect(result.isError).toBe(true)
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)
})
it('degrades to the manual exit when no user-interaction seam is composed', async () => {
const ctx = await setup()
const agent = agentWithSession()
@@ -572,10 +636,60 @@ describe('exit_plan_mode', () => {
expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
expect(asked).toHaveLength(1)
expect(asked[0]?.agent).toBe(agent)
expect(asked[0]?.questions[0]?.detail).toBe('# The plan\n\ndo things')
expect(asked[0]?.questions[0]?.options?.map(option => option.label)).toEqual(['Approve', 'Keep planning'])
})
it('an approved exit keeps the plan surface until the boundary (same-batch fold holds)', async () => {
it('carries the exact plan through a Code Mode review and logs the nested dispatch', async () => {
const plan = '# Code Mode plan\n\nUse the existing seam.'
class ExitRuntime extends CodeRuntime {
readonly language = 'typescript'
readonly isolation = 'fake'
async run(request: CodeRunRequest): Promise<CodeRunResult> {
const exit = request.bindings[0]?.functions[EXIT_PLAN_MODE]
if (exit === undefined) throw new Error('missing exit_plan_mode binding')
return { logs: [], value: await exit({ plan }) }
}
}
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
await ctx.plugin(ExitRuntime)
await ctx.plugin(ModesService, PLAN_CONFIG)
await ctx.plugin(UserInteractionService)
const asked: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
ask: (request) => {
asked.push(request)
return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
},
})
const agent = agentWithSession('code-mode-exit')
agent.session.append('mode/set', { mode: PLAN_MODE })
const result = await ctx.tools.execute({
callId: CallId(`call-exit-${++callCounter}`),
name: RUN_CODE_NAME,
arguments: { code: `return await tools.${EXIT_PLAN_MODE}({ plan: ${JSON.stringify(plan)} })` },
agent,
})
expect(result.isError).toBe(false)
expect(asked).toHaveLength(1)
expect(asked[0]?.questions[0]).toMatchObject({
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
detail: plan,
})
expect(agent.session.events.find(event => event.type === 'tool/code-dispatch')?.data).toMatchObject({
name: EXIT_PLAN_MODE,
arguments: { plan },
isError: false,
})
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE, pending: DEFAULT_MODE })
})
it('an approved exit keeps plan guidance until the boundary and never removes the tool', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
const approved = await callExit(ctx, agent)
expect(approved.isError).toBe(false)
@@ -585,8 +699,12 @@ describe('exit_plan_mode', () => {
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
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)
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
const afterExit = await ctx.systemPrompt.assemble({ agent })
expect(afterExit.tools).toEqual(assembly.tools)
expect(afterExit.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
})
it('the exit flush narrates nothing — the tool result is the narration', async () => {
@@ -598,13 +716,6 @@ describe('exit_plan_mode', () => {
expect(noticeTexts(agent.session)).toEqual([])
})
it('approve with a note carries the note into the confirmation', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'], custom: 'ship it small' })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step. User note: ship it small' }])
})
it('keep planning returns the corrective error carrying the feedback verbatim', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'], custom: 'consider the resume path' })
const result = await callExit(ctx, agent)
@@ -628,6 +739,36 @@ describe('exit_plan_mode', () => {
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
it('requires exactly the single Approve selection', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve', 'Keep planning'] })
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)
})
it('treats custom text alongside Approve as feedback, not consent', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'], custom: 'change the tests' })
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)
})
it('treats duplicate review answer items as non-consent', async () => {
const { ctx, agent } = await setupWithReview()
ctx.userInteraction.registerProvider({
ask: () => Promise.resolve({ answers: [
{ id: 'plan-review', selected: ['Approve'] },
{ id: 'plan-review', selected: ['Keep planning'] },
] }),
})
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)
})
it('a missing answer item reads as keep-planning', async () => {
const { ctx, agent } = await setupWithReview()
ctx.userInteraction.registerProvider({ ask: () => Promise.resolve({ answers: [] }) })
@@ -687,3 +828,20 @@ describe('exit_plan_mode', () => {
})
})
})
describe('HMR disposal', () => {
it('unregisters the service, 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)
expect(ctx.get('modes')).toBeInstanceOf(ModesService)
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).toContain('mode:policy')
await fiber.dispose()
expect(ctx.get('modes')).toBeUndefined()
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('mode:policy')
})
})

View File

@@ -145,11 +145,14 @@ function elicitationForQuestion(
options: AskUserQuestionOption[],
): CreateElicitationRequest {
const title = question.header ?? 'Question'
const message = question.detail === undefined
? question.question
: `${question.question}\n\n${question.detail}`
if (options.length === 0) {
return {
sessionId,
mode: 'form',
message: question.question,
message,
requestedSchema: {
type: 'object',
title,
@@ -183,7 +186,7 @@ function elicitationForQuestion(
return {
sessionId,
mode: 'form',
message: question.question,
message,
requestedSchema: {
type: 'object',
title,
@@ -504,7 +507,7 @@ 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 RFC): advertised
// 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

View File

@@ -143,15 +143,18 @@ describe('acp bridge', () => {
questions: [{
id: 'language',
question: 'Which language?',
detail: 'Choose the implementation language for this project.',
options: [{ label: 'TypeScript' }],
}],
})
expect(result).toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] })
expect(harness.elicitationRequests[0]).toMatchObject({
message: 'Which language?\n\nChoose the implementation language for this project.',
requestedSchema: {
properties: {
choice: {
title: 'Which language?',
description: 'Choose one option, or fill a custom answer below.',
oneOf: [{ const: 'TypeScript', title: 'TypeScript' }],
},

View File

@@ -227,7 +227,7 @@ export async function makeBridgeHarness(options: {
await ctx.plugin(ToolTodo)
}
if (options.withModes) {
await ctx.plugin(ModesService)
await ctx.plugin(ModesService, { modes: { plan: { section: 'Test plan mode instructions.' } } })
}
if (options.withFs) {
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })

View File

@@ -751,6 +751,10 @@ class QuestionDialog implements Component, Focusable {
lines.push(`${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.accent('│')}`)
}
for (const line of wrapTextWithAnsi(this.palette.bold(displayText(this.question.question)), innerWidth)) push(line)
if (this.question.detail !== undefined) {
push('')
for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line)
}
push('')
if (this.mode === 'custom') {
for (const line of this.input.render(innerWidth)) push(line)

View File

@@ -731,12 +731,13 @@ describe('TUI user-interaction dialogs', () => {
const single = result.ctx.userInteraction.ask({
questions: [{
id: 'mode', header: 'Mode', question: 'Choose a mode',
id: 'mode', header: 'Mode', question: 'Choose a mode', detail: 'This choice controls the next turn.',
options: [{ label: 'Safe', description: 'Use checks' }, { label: 'Fast' }],
}],
})
await tick()
expect(result.terminal.output).toContain('Choose a mode')
expect(result.terminal.output).toContain('This choice controls the next turn.')
expect(result.terminal.output).toContain('1/2')
result.terminal.send('\x1b[B')
result.terminal.send('\r')

View File

@@ -11,7 +11,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
### Key Types
- `AskUserQuestionRequest``{ questions: [{ id, question, header?, options?, multiSelect? }], agent?, signal? }`.
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label.
- `AskUserQuestionOption``{ label, description? }`.
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`.
- `UserInteractionProvider` — UI implementation with `ask(request)`.

View File

@@ -31,6 +31,8 @@ export interface AskUserQuestionItem {
id: string
/** The question to display. */
question: string
/** Optional supporting detail rendered with the question but kept out of option labels. */
detail?: string
/** Optional short heading/group label. */
header?: string
/** Optional choices the UI can render as a menu. */