Merge refreshed schema DSL into canonical tool output
# Conflicts: # docs/config-catalog.md # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/core/tools/tests/tools.spec.ts # packages/ui/tui/src/index.ts # packages/ui/tui/tests/tui.snapshot.ts
This commit is contained in:
9
packages/plan/README.md
Normal file
9
packages/plan/README.md
Normal 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).
|
||||
88
packages/plan/plan-mode/README.md
Normal file
88
packages/plan/plan-mode/README.md
Normal 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 canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review 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.
|
||||
57
packages/plan/plan-mode/package.json
Normal file
57
packages/plan/plan-mode/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"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",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-commands": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
352
packages/plan/plan-mode/src/index.ts
Normal file
352
packages/plan/plan-mode/src/index.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* 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.' },
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
approved: { type: 'boolean', const: true, required: true },
|
||||
},
|
||||
},
|
||||
render: () => [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }],
|
||||
},
|
||||
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 { approved: true }
|
||||
},
|
||||
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
|
||||
43
packages/plan/plan-mode/src/invariant.ts
Normal file
43
packages/plan/plan-mode/src/invariant.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/** 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-plan-mode'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'plan-mode-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Validate one `plan/mode` payload before it reaches the durable log. */
|
||||
function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
|
||||
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 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)
|
||||
}
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const event = (args as [Session, SessionEvent])[1]
|
||||
validateEvent(event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the plan-mode invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
170
packages/plan/plan-mode/tests/integration.spec.ts
Normal file
170
packages/plan/plan-mode/tests/integration.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
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, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import PlanModeService, { foldPlanMode } from '@deepseek-ai/dsh-plan-mode'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
const PLAN_CONFIG = { section: 'Test plan mode instructions.' }
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Only the model is mocked; the loop, the session log, and the plugin are
|
||||
* real.
|
||||
*/
|
||||
async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
for (const name of ['read', 'write']) {
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name,
|
||||
description: `test tool ${name}`,
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve([{ type: 'text', text: `ran ${name}` }]),
|
||||
}))
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function findEvent<T extends SessionEvent['type']>(
|
||||
log: readonly SessionEvent[],
|
||||
type: T,
|
||||
position: 'first' | 'last' = 'first',
|
||||
): Extract<SessionEvent, { type: T }> {
|
||||
const found = position === 'first'
|
||||
? log.find(event => event.type === type)
|
||||
: log.findLast(event => event.type === type)
|
||||
if (!found) throw new Error(`no ${type} event in the session log`)
|
||||
return found as Extract<SessionEvent, { type: T }>
|
||||
}
|
||||
|
||||
describe('plan mode through the agent loop', () => {
|
||||
it('a pre-turn set() makes the FIRST header plan-shaped, and a non-shell call is guidance-constrained only', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-1', 'write', {}, 'Writing during plan.'),
|
||||
textResponse('Noted in the plan.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' })
|
||||
// Selected while idle (the ACP picker shape): the pending intent flushes at
|
||||
// the first prompt-submit, BEFORE the first assembly.
|
||||
ctx.planMode.set(agent, true)
|
||||
|
||||
agent.send([{ type: 'text', text: 'explore the repo' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = agent.session.events
|
||||
const planMode = findEvent(log, 'plan/mode')
|
||||
const header = findEvent(log, 'request/header')
|
||||
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')
|
||||
|
||||
// No tool gate: the write RUNS — plan restrains by the section's
|
||||
// guidance alone (enforcement lives on the independent sandbox/approval
|
||||
// axes). The mode itself stays plan throughout.
|
||||
const result = findEvent(log, 'tool/result')
|
||||
expect(result.data.isError).toBe(false)
|
||||
expect(foldPlanMode(log)).toBe(true)
|
||||
expect(log.some(event => event.type === 'context/message')).toBe(false)
|
||||
})
|
||||
|
||||
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.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'hello' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
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.planMode.set(agent, true)
|
||||
agent.send([{ type: 'text', text: 'now plan' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = agent.session.events
|
||||
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([
|
||||
{ type: 'text', text: 'The user switched this session to plan mode.' },
|
||||
])
|
||||
// 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.planMode.set(agent, true)
|
||||
releaseRecovery.resolve(true)
|
||||
await idle
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
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 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(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.' },
|
||||
])
|
||||
})
|
||||
})
|
||||
50
packages/plan/plan-mode/tests/invariant.spec.ts
Normal file
50
packages/plan/plan-mode/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
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(PlanModeInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function event(active: unknown): SessionEvent {
|
||||
return { type: 'plan/mode', seq: 0, time: 0, data: { active } } as SessionEvent
|
||||
}
|
||||
|
||||
describe('plan-mode stream invariants', () => {
|
||||
it('accepts either boolean state', async () => {
|
||||
const ctx = await setup()
|
||||
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, 'plan', undefined])('rejects invalid durable plan state %j', async (active) => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, event(active)) })
|
||||
.toThrow(/expected a boolean/)
|
||||
})
|
||||
|
||||
it('ignores unrelated dispatches and session events', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => {
|
||||
ctx.emit('tools/change')
|
||||
ctx.emit('session/event', {} as Session, {
|
||||
type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects invalid existing state on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.sessions.create().append('plan/mode', { active: 'plan' as unknown as boolean })
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).rejects.toThrow(/expected a boolean/)
|
||||
})
|
||||
})
|
||||
936
packages/plan/plan-mode/tests/plan-mode.spec.ts
Normal file
936
packages/plan/plan-mode/tests/plan-mode.spec.ts
Normal file
@@ -0,0 +1,936 @@
|
||||
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, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { agentEvents, type Agent, type RequestErrorDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import 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 = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
|
||||
|
||||
/**
|
||||
* 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
|
||||
* dispatching the interception seams the loop fires there. Recovery retries
|
||||
* exercise the separate `agent/request-error` wrapper.
|
||||
*/
|
||||
|
||||
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
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
|
||||
inject: ['tools'],
|
||||
}))
|
||||
;(agent as { ctx?: Context }).ctx = scoped
|
||||
// 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
|
||||
}
|
||||
|
||||
/** Assemble exactly as the loop does: the agent is both subject and scope. */
|
||||
function assembleFor(ctx: Context, agent: Agent) {
|
||||
return ctx.systemPrompt.assemble({ agent, scope: agent })
|
||||
}
|
||||
|
||||
async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(PlanModeService, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a boundary event and dispatch the interception seam the loop fires
|
||||
* there — `agent/prompt-submit` inside the just-opened turn,
|
||||
* `agent/turn-continuation` after the step closed. Recovery retries use the
|
||||
* separately covered `agent/request-error` wrapper; post-commit
|
||||
* `session/event` observers remain observe-only.
|
||||
*/
|
||||
async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'turn/start' | 'step/end'): Promise<void> {
|
||||
const events = agentEvents(ctx, agent)
|
||||
if (type === 'turn/start') {
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await events.waterfall('agent/prompt-submit', [{ type: 'text', text: 'boundary probe' }], { kind: 'user' }, new AbortController().signal, () => Promise.resolve({ kind: 'allow' }))
|
||||
return
|
||||
}
|
||||
agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
await events.waterfall('agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal, () => Promise.resolve({ action: 'stop' }))
|
||||
}
|
||||
|
||||
/** Dispatch the closed-step recovery seam with one terminal decision. */
|
||||
function recoveryBoundary(
|
||||
ctx: Context,
|
||||
agent: Agent & { session: Session },
|
||||
decision: RequestErrorDecision,
|
||||
): Promise<RequestErrorDecision> {
|
||||
return agentEvents(ctx, agent).waterfall(
|
||||
'agent/request-error',
|
||||
1,
|
||||
1,
|
||||
new Error('request failed'),
|
||||
{ message: 'request failed', code: 'SERVER' },
|
||||
[],
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve(decision),
|
||||
)
|
||||
}
|
||||
|
||||
/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
|
||||
function header(session: Session): void {
|
||||
session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'initial' })
|
||||
}
|
||||
|
||||
function noticeTexts(session: Session): string[] {
|
||||
return session.events
|
||||
.filter(event => event.type === 'context/message')
|
||||
.map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join(''))
|
||||
}
|
||||
|
||||
function registerNamedTools(ctx: Context, names: string[]): void {
|
||||
for (const name of names) {
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name,
|
||||
description: `test tool ${name}`,
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve([{ type: 'text', text: `ran ${name}` }]),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
function execute(ctx: Context, name: string, agent?: Agent) {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: {},
|
||||
signal: new AbortController().signal,
|
||||
...agent ? { agent } : {},
|
||||
})
|
||||
}
|
||||
|
||||
describe('resolveConfig', () => {
|
||||
it('requires string, non-empty plan instructions', () => {
|
||||
expect(() => resolveConfig({} as PlanModeConfig))
|
||||
.toThrow('needs a string `section`')
|
||||
expect(() => resolveConfig({ section: 5 } as unknown as PlanModeConfig))
|
||||
.toThrow('needs a string `section`')
|
||||
expect(() => resolveConfig({ section: ' ' }))
|
||||
.toThrow('needs a non-empty `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('foldPlanMode', () => {
|
||||
it('folds an empty log to inactive and takes the last plan/mode otherwise', () => {
|
||||
const session = new Session(SessionId('fold'))
|
||||
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('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.planMode: get/set', () => {
|
||||
it('reads the folded state', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: false })
|
||||
agent.session.append('plan/mode', { active: true })
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: true })
|
||||
})
|
||||
|
||||
it('selects inactive as the plan exit target', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
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.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 plan/mode at turn/start', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
ctx.planMode.set(agent, true)
|
||||
await boundary(ctx, agent, 'turn/start')
|
||||
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 () => {
|
||||
const ctx = await setup()
|
||||
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 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.planMode.set(agent, true)
|
||||
await next()
|
||||
return decision
|
||||
})
|
||||
agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
await agentEvents(ctx, agent).waterfall(
|
||||
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
|
||||
() => Promise.resolve({ action: 'stop' }),
|
||||
)
|
||||
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(PlanModeService, PLAN_CONFIG)
|
||||
const agent = await agentWithSession(ctx)
|
||||
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.
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
|
||||
await fiber.dispose()
|
||||
await next()
|
||||
return decision
|
||||
})
|
||||
agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
await agentEvents(ctx, agent).waterfall(
|
||||
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
|
||||
() => Promise.resolve({ action: 'stop' }),
|
||||
)
|
||||
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.planMode.set(agent, true)
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
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.planMode.set(agent, true)
|
||||
expect(await recoveryBoundary(ctx, agent, { action: 'fail' })).toEqual({ action: 'fail' })
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
|
||||
})
|
||||
|
||||
it('contains an append failure at the retry boundary without changing its decision', async () => {
|
||||
const ctx = await setup()
|
||||
const warn = vi.fn()
|
||||
ctx.logger.warn = warn as never
|
||||
const agent = await agentWithSession(ctx)
|
||||
ctx.planMode.set(agent, true)
|
||||
const original = agent.session.append.bind(agent.session)
|
||||
agent.session.append = (((type: string, ...rest: unknown[]) => {
|
||||
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.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.planMode.set(agent, true)
|
||||
ctx.planMode.set(agent, false)
|
||||
await boundary(ctx, agent, 'turn/start')
|
||||
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.planMode.set(agent, true)
|
||||
await boundary(ctx, agent, 'turn/start')
|
||||
expect(noticeTexts(agent.session)).toEqual([])
|
||||
})
|
||||
|
||||
it('narrates once when the flushed mode differs from what the last header told the model', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
header(agent.session)
|
||||
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')
|
||||
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
|
||||
})
|
||||
|
||||
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('plan/mode', { active: true })
|
||||
header(agent.session)
|
||||
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.'])
|
||||
})
|
||||
|
||||
it('stays silent when the header already reflects the flushed mode', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
agent.session.append('plan/mode', { active: true })
|
||||
header(agent.session)
|
||||
agent.session.append('plan/mode', { active: false })
|
||||
ctx.planMode.set(agent, true)
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
expect(noticeTexts(agent.session)).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
it('contains an append failure instead of blocking the prompt or the turn', async () => {
|
||||
const ctx = await setup()
|
||||
const warn = vi.fn()
|
||||
ctx.logger.warn = warn as never
|
||||
const agent = await agentWithSession(ctx)
|
||||
ctx.planMode.set(agent, true)
|
||||
const original = agent.session.append.bind(agent.session)
|
||||
// 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 === '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')
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
// 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.planMode.get(agent)).toEqual({ active: false, pending: true })
|
||||
agent.session.append = original
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
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 () => {
|
||||
const ctx = await setup()
|
||||
const warn = vi.fn()
|
||||
ctx.logger.warn = warn as never
|
||||
const agent = await agentWithSession(ctx)
|
||||
ctx.planMode.set(agent, true)
|
||||
const original = agent.session.append.bind(agent.session)
|
||||
agent.session.append = (((type: string, ...rest: unknown[]) => {
|
||||
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.planMode.get(agent)).toEqual({ active: false, pending: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('the soft layer', () => {
|
||||
it('keeps the tool schemas identical across default and plan mode', async () => {
|
||||
const ctx = await setup()
|
||||
registerNamedTools(ctx, ['read', 'write'])
|
||||
const agent = 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 === 'plan:policy')?.text).toBe('')
|
||||
|
||||
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 === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
|
||||
})
|
||||
|
||||
it('leaves an agent-less assembly untouched', async () => {
|
||||
const ctx = await setup()
|
||||
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 === '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', { 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 === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
|
||||
})
|
||||
|
||||
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)
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const final = await next()
|
||||
final.tools = [...final.tools, { name: 'added-later', description: 'added after next()', parameters: {} }]
|
||||
return final
|
||||
})
|
||||
await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
||||
registerNamedTools(ctx, ['read'])
|
||||
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')
|
||||
expect((await assembleFor(ctx, defaulted)).tools.map(tool => tool.name))
|
||||
.toEqual(['exit_plan_mode', 'read', 'added-later'])
|
||||
})
|
||||
|
||||
it('keeps run_code the only wire tool in plan mode under the registry Code Mode; the SDK gains the exit binding', async () => {
|
||||
// Minimal scriptable runtime: the SDK section resolves ctx.codeRuntime at
|
||||
// assembly time (the code-mode.spec fake's shape).
|
||||
class FakeRuntime extends CodeRuntime {
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'fake'
|
||||
run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
||||
await ctx.plugin(FakeRuntime)
|
||||
await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
||||
registerNamedTools(ctx, ['read', 'write'])
|
||||
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; 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:')
|
||||
expect(sdk).toContain('exit_plan_mode(args:')
|
||||
})
|
||||
|
||||
it('keeps native wire schemas and the SDK in step under mode both', async () => {
|
||||
class FakeRuntime extends CodeRuntime {
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'fake'
|
||||
run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry, { mode: 'both' })
|
||||
await ctx.plugin(FakeRuntime)
|
||||
await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
||||
registerNamedTools(ctx, ['read', 'write'])
|
||||
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.
|
||||
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:')
|
||||
expect(sdk).toContain('write(args:')
|
||||
expect(sdk).toContain('exit_plan_mode(args:')
|
||||
})
|
||||
|
||||
it('keeps the Code Mode SDK byte-identical across mode switches', async () => {
|
||||
class FakeRuntime extends CodeRuntime {
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'fake'
|
||||
run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
|
||||
}
|
||||
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('plan/mode', { active: true })
|
||||
const planSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
|
||||
expect(planSdk).toBe(defaultSdk)
|
||||
|
||||
// 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)
|
||||
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(bareSdk).not.toContain('exit_plan_mode(args:')
|
||||
expect(defaultSdk).not.toBe(bareSdk)
|
||||
})
|
||||
})
|
||||
|
||||
describe('no execution gating beyond the exit tool', () => {
|
||||
it('passes agent-less and default-mode executions through', async () => {
|
||||
const ctx = await setup()
|
||||
registerNamedTools(ctx, ['write'])
|
||||
const agentless = await execute(ctx, 'write')
|
||||
expect(agentless.isError).toBe(false)
|
||||
const agent = await agentWithSession(ctx)
|
||||
const defaulted = await execute(ctx, 'write', agent)
|
||||
expect(defaulted.isError).toBe(false)
|
||||
})
|
||||
|
||||
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', { active: true })
|
||||
for (const name of ['read', 'write', 'bash']) {
|
||||
const result = await execute(ctx, name, agent)
|
||||
expect(result.isError).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
await ctx.plugin(CommandService)
|
||||
// The `ctx.inject` child mounts asynchronously once `commands` resolves.
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
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]' } },
|
||||
])
|
||||
|
||||
const signal = new AbortController().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.planMode.get(messageAgent)).toEqual({ active: false, pending: true })
|
||||
expect(messageSteer).toHaveBeenCalledExactlyOnceWith([{ type: 'text', text: 'draft the migration' }])
|
||||
})
|
||||
|
||||
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(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'])
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
expect(ctx.commands.list(agent)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('exit_plan_mode', () => {
|
||||
async function setupWithReview(answer?: { selected: string[]; custom?: string }) {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const asked: AskUserQuestionRequest[] = []
|
||||
if (answer !== undefined) {
|
||||
ctx.userInteraction.registerProvider({
|
||||
ask: (request) => {
|
||||
asked.push(request)
|
||||
return Promise.resolve({ answers: [{ id: 'plan-review', ...answer }] })
|
||||
},
|
||||
})
|
||||
}
|
||||
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
|
||||
return { ctx, agent, asked }
|
||||
}
|
||||
|
||||
function callExit(ctx: Context, agent: Agent | undefined, plan = '# The plan\n\ndo things') {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-exit-${++callCounter}`),
|
||||
name: EXIT_PLAN_MODE,
|
||||
arguments: { plan },
|
||||
signal: new AbortController().signal,
|
||||
...agent ? { agent } : {},
|
||||
})
|
||||
}
|
||||
|
||||
it('registers the tool with one required plan argument', async () => {
|
||||
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'])
|
||||
})
|
||||
|
||||
it('rejects an agent-less call', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await callExit(ctx, undefined)
|
||||
expect(result.isError).toBe(true)
|
||||
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 while remaining advertised', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
expect(ctx.tools.schemas().map(tool => tool.name)).toContain(EXIT_PLAN_MODE)
|
||||
const result = await callExit(ctx, agent)
|
||||
expect(result.isError).toBe(true)
|
||||
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(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', { 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(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
it('degrades the same way when the seam has no provider (NO_PROVIDER)', async () => {
|
||||
const { ctx, agent } = await setupWithReview()
|
||||
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(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
it('approve: records the boundary-applied switch and confirms (the fold flips at the flush)', async () => {
|
||||
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
|
||||
const result = await callExit(ctx, agent)
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected approved plan result')
|
||||
expect(result.value).toEqual({ approved: true })
|
||||
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(foldPlanMode(agent.session.events)).toBe(true)
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
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')
|
||||
expect(asked[0]?.questions[0]?.options?.map(option => option.label)).toEqual(['Approve', 'Keep planning'])
|
||||
})
|
||||
|
||||
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(PlanModeService, 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 = await agentWithSession(ctx, 'code-mode-exit', { active: true })
|
||||
|
||||
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)} })` },
|
||||
signal: new AbortController().signal,
|
||||
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.planMode.get(agent)).toEqual({ active: true, pending: false })
|
||||
})
|
||||
|
||||
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)
|
||||
// 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(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 === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
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 === 'plan:policy')?.text).toBe('')
|
||||
})
|
||||
|
||||
it('the exit flush narrates nothing — the tool result is the narration', async () => {
|
||||
const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
|
||||
header(agent.session)
|
||||
await callExit(ctx, agent)
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
expect(foldPlanMode(agent.session.events)).toBe(false)
|
||||
expect(noticeTexts(agent.session)).toEqual([])
|
||||
})
|
||||
|
||||
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)
|
||||
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(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
it('keep planning without feedback returns the generic corrective error', async () => {
|
||||
const { ctx, agent } = await setupWithReview({ 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.' }])
|
||||
})
|
||||
|
||||
it('a custom-text-only answer is feedback, never consent', async () => {
|
||||
const { ctx, agent } = await setupWithReview({ selected: [], custom: 'add tests first' })
|
||||
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(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
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(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
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(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
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(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
it('a missing answer item reads as keep-planning', async () => {
|
||||
const { ctx, agent } = await setupWithReview()
|
||||
ctx.userInteraction.registerProvider({ ask: () => Promise.resolve({ answers: [] }) })
|
||||
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.' }])
|
||||
})
|
||||
|
||||
it('forwards the execution abort signal to the review question', async () => {
|
||||
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
|
||||
const controller = new AbortController()
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId(`call-exit-${++callCounter}`),
|
||||
name: EXIT_PLAN_MODE,
|
||||
arguments: { plan: '# P' },
|
||||
agent,
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(asked[0]?.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('fails the call when the plugin is disposed while the review awaits (no phantom exit)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
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', { 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
|
||||
// would claim an exit that can never flush — the call must fail instead.
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
await fiber.dispose()
|
||||
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 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 () => {
|
||||
const { ctx, agent } = await setupWithReview()
|
||||
ctx.userInteraction.registerProvider({ ask: () => { throw new Error('review aborted') } })
|
||||
const result = await callExit(ctx, agent)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }])
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
it('presents the call as a generic card titled by the plan first heading', async () => {
|
||||
const ctx = await setup()
|
||||
const def = ctx.tools.get(EXIT_PLAN_MODE)!
|
||||
expect(def.presentCall?.({ plan: '## Fix the flake\n\nsteps' })).toEqual({
|
||||
card: 'generic',
|
||||
title: 'Fix the flake',
|
||||
kind: 'other',
|
||||
content: [{ type: 'text', text: '## Fix the flake\n\nsteps' }],
|
||||
})
|
||||
expect(def.presentCall?.({ plan: 'no heading here' })).toEqual({
|
||||
card: 'generic',
|
||||
title: 'Plan',
|
||||
kind: 'other',
|
||||
content: [{ type: 'text', text: 'no heading here' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('presents the result as a generic review card', async () => {
|
||||
const ctx = await setup()
|
||||
const def = ctx.tools.get(EXIT_PLAN_MODE)!
|
||||
const content = [{ type: 'text' as const, text: 'ok' }]
|
||||
expect(def.presentResult?.({ plan: '# P' }, { content, isError: false })).toEqual({
|
||||
card: 'generic',
|
||||
title: 'Plan review',
|
||||
content,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('HMR disposal', () => {
|
||||
it('does not flush a retry boundary that resumes after plugin disposal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
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>()
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, _next) => {
|
||||
recoveryEntered.resolve(true)
|
||||
await releaseRecovery.promise
|
||||
return { action: 'retry' }
|
||||
})
|
||||
ctx.planMode.set(agent, true)
|
||||
|
||||
const recovery = recoveryBoundary(ctx, agent, { action: 'fail' })
|
||||
await recoveryEntered.promise
|
||||
await fiber.dispose()
|
||||
releaseRecovery.resolve(true)
|
||||
|
||||
expect(await recovery).toEqual({ action: 'retry' })
|
||||
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(PlanModeService, PLAN_CONFIG)
|
||||
const agent = await agentWithSession(ctx, 'disposed-recovery')
|
||||
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('plan:policy')
|
||||
|
||||
await fiber.dispose()
|
||||
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('plan:policy')
|
||||
expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
})
|
||||
})
|
||||
39
packages/plan/plan-mode/tsconfig.json
Normal file
39
packages/plan/plan-mode/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user