Merge remote-tracking branch 'origin/master' into worktree/web-bind-address
This commit is contained in:
@@ -11,10 +11,10 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
|
||||
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, the model-facing file tools, and the bash-backed discovery tools | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface |
|
||||
| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface |
|
||||
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
|
||||
@@ -22,9 +22,10 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
|
||||
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface |
|
||||
| [`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 |
|
||||
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
|
||||
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
|
||||
|
||||
@@ -372,6 +372,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'planMode',
|
||||
summary: '`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'get(agent: Agent): { active: boolean; pending?: boolean }',
|
||||
jsDoc: '/**\n * Read the logged plan state and any selected state awaiting a boundary.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'set(agent: Agent, active: boolean): void',
|
||||
jsDoc: '/**\n * Select whether plan mode should be active from the next turn boundary.\n * Repeated selection of the current or already-pending state is a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sandbox',
|
||||
summary: 'Abstract process-sandbox service.',
|
||||
@@ -1098,7 +1112,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',
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
@@ -40,10 +40,15 @@ function serializeAssistant(message: Message): WireMessage {
|
||||
|
||||
return {
|
||||
role: 'assistant',
|
||||
// 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,
|
||||
// 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,
|
||||
// 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.
|
||||
|
||||
@@ -187,12 +187,22 @@ describe('serializeRequest', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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).
|
||||
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").
|
||||
const wire = serializeMessages([{ role: 'assistant', content: [] }])
|
||||
expect(wire).toEqual([{ role: 'assistant', content: null }])
|
||||
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: '' }])
|
||||
})
|
||||
|
||||
it('serializes tool-call turns with empty string content, not null', () => {
|
||||
|
||||
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 exit result and rejection returns feedback.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The stable schema is paid according to ToolRegistry mode, and each plan argument and review result remains in conversation history.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Mode transitions do not change the tool catalog; plan arguments and review results extend the conversation normally.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls.
|
||||
- A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it.
|
||||
- Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option.
|
||||
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"
|
||||
}
|
||||
}
|
||||
342
packages/plan/plan-mode/src/index.ts
Normal file
342
packages/plan/plan-mode/src/index.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Plan mode is logged per-agent collaboration state: while active, a
|
||||
* deployment-owned guidance section shapes each model request, and
|
||||
* `exit_plan_mode` presents the completed plan for user review. It is
|
||||
* independent of sandbox mode and approval policy; those enforcement axes do
|
||||
* not read or write plan state.
|
||||
*
|
||||
* The state in force is folded from the session log (`plan/mode`, last one
|
||||
* wins), so resume and fork restore it without a live mirror. User selections
|
||||
* are held as pending intent until a turn boundary because every session event
|
||||
* is turn-enclosed. The service flushes before the affected request assembly
|
||||
* on prompt submission, ordinary continuation, and request-recovery retry.
|
||||
*
|
||||
* The exit tool remains registered while plan mode is inactive so crossing a
|
||||
* boundary changes only the prompt section, not the request tool catalog.
|
||||
*
|
||||
* Agent Notes:
|
||||
* - .agents/notes/implemented/feature/2026-07-07-plan-mode.md
|
||||
* - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
|
||||
*
|
||||
* @module @deepseek-ai/dsh-plan-mode
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-user-interaction'
|
||||
// Type-only edge: resolves `ctx.commands` for the optional command child.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* Whether plan mode is in force from this point on: log-only, non-surface,
|
||||
* whole-value replace. The last `plan/mode` wins; a log with none folds to
|
||||
* inactive through {@link foldPlanMode}.
|
||||
*/
|
||||
'plan/mode': { active: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
planMode: PlanModeService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing exit tool's name. It stays registered while plan mode is
|
||||
* inactive so the request tool catalog is stable across transitions.
|
||||
*/
|
||||
export const EXIT_PLAN_MODE = 'exit_plan_mode'
|
||||
|
||||
/** Deployment-owned plan guidance. */
|
||||
export interface PlanModeConfig {
|
||||
/** Guidance rendered as the `plan:policy` prompt section while plan mode is active. */
|
||||
section: string
|
||||
}
|
||||
|
||||
/** The review question's approve option label. */
|
||||
const APPROVE_LABEL = 'Approve'
|
||||
|
||||
/** The review question's keep-planning option label. */
|
||||
const KEEP_PLANNING_LABEL = 'Keep planning'
|
||||
|
||||
const EXIT_DESCRIPTION
|
||||
= 'Use only in plan mode. Present your plan for the user\'s review and, on approval, leave plan mode. '
|
||||
+ 'Send the COMPLETE plan as markdown, starting with a # heading that names it. '
|
||||
+ 'The user may approve (carry out the plan from your next step) or keep '
|
||||
+ 'planning — their feedback comes back in the tool result; revise and present again.'
|
||||
|
||||
/** The plan's first markdown heading (any level), or `undefined` when it has none. */
|
||||
function firstHeading(plan: string): string | undefined {
|
||||
for (const line of plan.split('\n')) {
|
||||
const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)
|
||||
if (match) return match[1]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate deployment-owned plan guidance. Missing, blank, non-string, or
|
||||
* unknown fields fail at plugin load rather than silently shaping nothing.
|
||||
*
|
||||
* @param config Raw plugin config.
|
||||
* @returns A detached validated config.
|
||||
*/
|
||||
export function resolveConfig(config: PlanModeConfig): PlanModeConfig {
|
||||
const section = (config as Partial<PlanModeConfig>).section
|
||||
if (typeof section !== 'string') {
|
||||
throw new Error('PlanModeConfig needs a string `section`')
|
||||
}
|
||||
if (section.trim() === '') {
|
||||
throw new Error('PlanModeConfig needs a non-empty `section`')
|
||||
}
|
||||
const unknown = Object.keys(config).filter(key => key !== 'section')
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`PlanModeConfig has unknown key(s) ${unknown.join(', ')} — config is { section }`)
|
||||
}
|
||||
return { section }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether plan mode is active after the first `end` events. The last
|
||||
* `plan/mode` wins; a prefix with none is inactive.
|
||||
*
|
||||
* @param events The session log or any prefix of it.
|
||||
* @param end Fold `events[0, end)`; defaults to the whole log.
|
||||
* @returns Whether plan mode is active.
|
||||
*/
|
||||
export function foldPlanMode(events: readonly SessionEvent[], end = events.length): boolean {
|
||||
let active = false
|
||||
let index = 0
|
||||
for (const event of events) {
|
||||
if (index >= end) break
|
||||
index++
|
||||
if (event.type === 'plan/mode') active = event.data.active
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
/** Plan state at the last logged request header, or `undefined` before the first header. */
|
||||
function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefined {
|
||||
let lastHeader = -1
|
||||
let index = 0
|
||||
for (const event of events) {
|
||||
if (event.type === 'request/header') lastHeader = index
|
||||
index++
|
||||
}
|
||||
if (lastHeader < 0) return undefined
|
||||
return foldPlanMode(events, lastHeader + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.planMode`: owns logged plan state, boundary application and narration,
|
||||
* the `plan:policy` section, the `/plan` command, and the stable exit tool.
|
||||
* UIs observe committed flips through `session/event`; there is no live mirror.
|
||||
*/
|
||||
export class PlanModeService extends Service {
|
||||
static inject = ['tools', 'systemPrompt']
|
||||
|
||||
/** Validated deployment-owned guidance. */
|
||||
private readonly section: string
|
||||
|
||||
/**
|
||||
* Latest selection per session awaiting a turn-boundary flush. `narrate` is
|
||||
* true for user selections and false for the exit tool, whose result already
|
||||
* narrates the transition.
|
||||
*/
|
||||
private readonly pendingIntents = new WeakMap<Session, { active: boolean; narrate: boolean }>()
|
||||
|
||||
constructor(ctx: Context, config: PlanModeConfig = { section: '' }) {
|
||||
super(ctx, 'planMode')
|
||||
this.section = resolveConfig(config).section
|
||||
let disposed = false
|
||||
|
||||
// Boundary flushes use loop interception seams, not post-commit
|
||||
// `session/event` observation. Flush after next(): a selection arriving
|
||||
// while a downstream async listener awaits must still shape the request
|
||||
// this boundary precedes. Failures are contained so policy cannot block a
|
||||
// prompt or turn; a failed append remains pending for a later boundary.
|
||||
const flushAfter = async <T>(agent: Agent, next: () => Promise<T>): Promise<T> => {
|
||||
const decision = await next()
|
||||
if (!disposed) {
|
||||
try {
|
||||
this.onBoundary(agent)
|
||||
} catch (error) {
|
||||
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
|
||||
}
|
||||
}
|
||||
return decision
|
||||
}
|
||||
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) =>
|
||||
flushAfter(agent, next), { prepend: true })
|
||||
ctx.on('agent/turn-continuation', (agent, _turn, _decision, _signal, next) =>
|
||||
flushAfter(agent, next), { prepend: true })
|
||||
ctx.on('agent/request-error', async (
|
||||
agent,
|
||||
_turn,
|
||||
_step,
|
||||
_error,
|
||||
_failure,
|
||||
_priorFailures,
|
||||
_signal,
|
||||
next,
|
||||
) => {
|
||||
const decision = await next()
|
||||
// A waterfall can retain this wrapper after Cordis unregisters it.
|
||||
if (disposed || decision.action !== 'retry') return decision
|
||||
try {
|
||||
this.onBoundary(agent)
|
||||
} catch (error) {
|
||||
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
|
||||
}
|
||||
return decision
|
||||
}, { prepend: true })
|
||||
ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime')
|
||||
|
||||
ctx.systemPrompt.section({
|
||||
name: 'plan:policy',
|
||||
order: 50,
|
||||
text: context => context.agent !== undefined && foldPlanMode(context.agent.session.events)
|
||||
? this.section
|
||||
: '',
|
||||
})
|
||||
|
||||
// The command child activates only when a command registry is composed.
|
||||
ctx.inject(['commands'], (commandCtx) => {
|
||||
commandCtx.commands.register({
|
||||
name: 'plan',
|
||||
description: 'Enter plan mode',
|
||||
input: { hint: '[message]' },
|
||||
handler: ({ agent, rawInput }) => {
|
||||
const message = rawInput.trim()
|
||||
this.set(agent, true)
|
||||
if (message !== '') agent.steer([{ type: 'text', text: message }])
|
||||
return { kind: 'success', text: 'Entering plan mode (applies from the next step).' }
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: EXIT_PLAN_MODE,
|
||||
description: EXIT_DESCRIPTION,
|
||||
parameters: {
|
||||
plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },
|
||||
},
|
||||
execute: async (args, exec) => {
|
||||
const agent = exec.agent
|
||||
if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
|
||||
if (!foldPlanMode(agent.session.events)) {
|
||||
throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
|
||||
}
|
||||
if (!/^#\s+\S/.test(args.plan.trim())) {
|
||||
throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`)
|
||||
}
|
||||
const interaction = ctx.get('userInteraction')
|
||||
if (interaction === undefined) {
|
||||
throw new Error('no user-interaction channel is available to review the plan; ask the user to switch the session mode instead')
|
||||
}
|
||||
const answer = await interaction.ask({
|
||||
questions: [{
|
||||
id: 'plan-review',
|
||||
header: 'Plan review',
|
||||
question: 'Approve this plan and leave plan mode?',
|
||||
detail: args.plan,
|
||||
options: [
|
||||
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
|
||||
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
|
||||
],
|
||||
}],
|
||||
agent,
|
||||
signal: exec.signal,
|
||||
})
|
||||
// A review may outlive this plugin fiber. Without boundary listeners,
|
||||
// an approved result could never land, so fail and keep planning.
|
||||
if (disposed) {
|
||||
throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again')
|
||||
}
|
||||
const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review')
|
||||
const item = reviewItems.length === 1 ? reviewItems[0] : undefined
|
||||
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
|
||||
const feedback = item?.custom ?? ''
|
||||
throw new Error(feedback === ''
|
||||
? 'The user chose to keep planning; revise the plan and present it again.'
|
||||
: `The user chose to keep planning; their feedback: ${feedback}`)
|
||||
}
|
||||
// Keep plan guidance for the rest of this assistant tool batch. The
|
||||
// silent intent flushes after the step, before the next assembly.
|
||||
this.pendingIntents.set(agent.session, { active: false, narrate: false })
|
||||
return [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }]
|
||||
},
|
||||
presentCall: args => ({
|
||||
card: 'generic',
|
||||
title: firstHeading(args.plan) ?? 'Plan',
|
||||
kind: 'other',
|
||||
content: [{ type: 'text', text: args.plan }],
|
||||
}),
|
||||
presentResult: (_args, result) => ({
|
||||
card: 'generic',
|
||||
title: 'Plan review',
|
||||
content: result.content,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the logged plan state and any selected state awaiting a boundary.
|
||||
*
|
||||
* @param agent The agent to read.
|
||||
* @returns Current logged state plus a pending selection, when present.
|
||||
*/
|
||||
get(agent: Agent): { active: boolean; pending?: boolean } {
|
||||
const active = foldPlanMode(agent.session.events)
|
||||
const pending = this.pendingIntents.get(agent.session)
|
||||
return pending === undefined ? { active } : { active, pending: pending.active }
|
||||
}
|
||||
|
||||
/**
|
||||
* Select whether plan mode should be active from the next turn boundary.
|
||||
* Repeated selection of the current or already-pending state is a no-op.
|
||||
*
|
||||
* @param agent The agent to switch.
|
||||
* @param active Whether plan mode should be active.
|
||||
*/
|
||||
set(agent: Agent, active: boolean): void {
|
||||
const session = agent.session
|
||||
const target = this.pendingIntents.get(session)?.active ?? foldPlanMode(session.events)
|
||||
if (active === target) return
|
||||
this.pendingIntents.set(session, { active, narrate: true })
|
||||
}
|
||||
|
||||
/** Flush one pending selection before the next request assembly. */
|
||||
private onBoundary(agent: Agent): void {
|
||||
const session = agent.session
|
||||
const pending = this.pendingIntents.get(session)
|
||||
if (pending === undefined) return
|
||||
const target = pending.active
|
||||
if (target === foldPlanMode(session.events)) {
|
||||
this.pendingIntents.delete(session)
|
||||
return
|
||||
}
|
||||
session.append('plan/mode', { active: target })
|
||||
// Delete only after append succeeds so a later boundary can retry a failed
|
||||
// durable write.
|
||||
this.pendingIntents.delete(session)
|
||||
if (!pending.narrate) return
|
||||
const told = planModeAtLastHeader(session.events)
|
||||
if (told === undefined || told === target) return
|
||||
const text = target
|
||||
? 'The user switched this session to plan mode.'
|
||||
: 'The user switched this session back to the default mode.'
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: 'plan-mode' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
|
||||
export default PlanModeService
|
||||
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, { defineTool } 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(defineTool({
|
||||
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/)
|
||||
})
|
||||
})
|
||||
934
packages/plan/plan-mode/tests/plan-mode.spec.ts
Normal file
934
packages/plan/plan-mode/tests/plan-mode.spec.ts
Normal file
@@ -0,0 +1,934 @@
|
||||
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, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { agentEvents, type Agent, type RequestErrorDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import 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(defineTool({
|
||||
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)
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -125,6 +125,11 @@ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId):
|
||||
if (legacy !== undefined) {
|
||||
throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${legacy.seq}`)
|
||||
}
|
||||
const legacyModeType: string = 'mode/set'
|
||||
const legacyMode = events.find(event => event.type === legacyModeType)
|
||||
if (legacyMode !== undefined) {
|
||||
throw new Error(`session "${id}" contains unsupported legacy mode/set event at seq ${legacyMode.seq}`)
|
||||
}
|
||||
const fallback = events.find(event => event.type === 'request/header'
|
||||
&& (event.data as { reason?: string }).reason === 'fallback')
|
||||
if (fallback !== undefined) {
|
||||
|
||||
@@ -22,6 +22,16 @@ function legacyHeaderDelta(seq = 0): SessionEvent {
|
||||
} as unknown as SessionEvent
|
||||
}
|
||||
|
||||
/** An unsupported named-mode fixture emulating an untyped producer. */
|
||||
function legacyModeSet(seq = 0): SessionEvent {
|
||||
return {
|
||||
type: 'mode/set',
|
||||
seq,
|
||||
time: 1,
|
||||
data: { mode: 'plan' },
|
||||
} as unknown as SessionEvent
|
||||
}
|
||||
|
||||
/** An obsolete full-header reason fixture from the removed delta codec. */
|
||||
function legacyFallbackHeader(seq = 0): SessionEvent {
|
||||
return {
|
||||
@@ -510,6 +520,19 @@ describe('SessionPersistence service registration', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a stored legacy named-mode event during load', async () => {
|
||||
const id = SessionId('legacy-mode-load')
|
||||
const m = meta(id, '/legacy')
|
||||
const store: MemoryStore = new Map([[id, { meta: m, events: [legacyModeSet()] }]])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(MemoryPersistence, { store })
|
||||
|
||||
await expect(ctx.sessionPersistence.load(id))
|
||||
.rejects.toThrow('unsupported legacy mode/set event at seq 0')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('retires all coordinator bookkeeping for disposed sessions', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
@@ -24,6 +24,8 @@ import { basename, dirname, join, delimiter } from 'node:path'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
PROTOCOL_VERSION,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
@@ -59,6 +61,8 @@ export type InputStep =
|
||||
waitForToolCallUpdate?: string
|
||||
}
|
||||
| { op: 'cancel' }
|
||||
| { op: 'setMode'; modeId: string }
|
||||
| { op: 'setModeExpectError'; modeId: string }
|
||||
| { op: 'setConfigOption'; configId: string; value: string }
|
||||
| { op: 'setConfigOptionExpectError'; configId: string; value: string }
|
||||
|
||||
@@ -78,6 +82,16 @@ export interface InputScript {
|
||||
* agent itself just sees `cancelled`, so it cannot absorb the bug).
|
||||
*/
|
||||
permissionAnswers?: PermissionAnswer[]
|
||||
/**
|
||||
* Ordered answers for the agent's `elicitation/create` round-trips (the
|
||||
* ask_user_question / plan-review forms), consumed FIFO — the Nth request
|
||||
* gets the Nth answer. Exhaustion (or no queue) answers `cancel`, the same
|
||||
* fail-closed stub an elicitation-free scenario relies on. Unlike permission
|
||||
* kinds, the scripted strings are not validated against the offered form —
|
||||
* a stray `choice` reaches the agent verbatim, which reads it as a custom
|
||||
* (non-consenting) answer, so a scenario bug fails safe in the transcript.
|
||||
*/
|
||||
elicitationAnswers?: ElicitationAnswer[]
|
||||
}
|
||||
|
||||
/** One scripted answer to a permission request: which offered option kind to select. */
|
||||
@@ -86,6 +100,16 @@ export interface PermissionAnswer {
|
||||
kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'
|
||||
}
|
||||
|
||||
/** One scripted answer to an elicitation form (accept with choice/custom content, or cancel). */
|
||||
export interface ElicitationAnswer {
|
||||
/** Accept the form with the content below, or cancel it. */
|
||||
action: 'accept' | 'cancel'
|
||||
/** The selected option label (the form's `choice` field). */
|
||||
choice?: string
|
||||
/** Free-form text (the form's `custom` field). */
|
||||
custom?: string
|
||||
}
|
||||
|
||||
/** One harvested session log plus the identifying facts off its header line. */
|
||||
export interface HarvestedLog {
|
||||
/** The recorded session id (header `id`). */
|
||||
@@ -215,6 +239,8 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
|
||||
// Permission answers are consumed FIFO across the whole run; exhaustion
|
||||
// falls back to `cancelled` so approval-free scenarios keep the plain stub.
|
||||
const permissionQueue = [...input.permissionAnswers ?? []]
|
||||
// Elicitation answers mirror the permission queue: FIFO, cancel on exhaustion.
|
||||
const elicitationQueue = [...input.elicitationAnswers ?? []]
|
||||
// A scenario bug detected inside a client callback (a scripted permission
|
||||
// kind the agent never offered). It cannot fail the run from in there: a
|
||||
// callback throw only becomes a JSON-RPC error RESPONSE to the agent, and
|
||||
@@ -244,6 +270,17 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
|
||||
}
|
||||
return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } })
|
||||
},
|
||||
createElicitation(_params: CreateElicitationRequest): Promise<CreateElicitationResponse> {
|
||||
const answer = elicitationQueue.shift()
|
||||
if (answer === undefined || answer.action !== 'accept') return Promise.resolve({ action: 'cancel' })
|
||||
return Promise.resolve({
|
||||
action: 'accept',
|
||||
content: {
|
||||
...answer.choice !== undefined ? { choice: answer.choice } : {},
|
||||
...answer.custom !== undefined ? { custom: answer.custom } : {},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
const active = launched
|
||||
await active.spawned
|
||||
@@ -399,6 +436,24 @@ async function runStep(
|
||||
await client.cancel({ sessionId })
|
||||
return
|
||||
}
|
||||
case 'setMode': {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId === undefined) throw new Error('snapshot-harness: setMode before newSession')
|
||||
await client.setSessionMode({ sessionId, modeId: step.modeId })
|
||||
return
|
||||
}
|
||||
case 'setModeExpectError': {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId === undefined) throw new Error('snapshot-harness: setModeExpectError before newSession')
|
||||
// The bridge rejects an unknown/uncomposed mode id with invalidParams;
|
||||
// that rejection IS the expected wire behavior — swallow it so the run
|
||||
// completes and the error frame is captured in the transcript.
|
||||
await client.setSessionMode({ sessionId, modeId: step.modeId }).then(
|
||||
() => { throw new Error('snapshot-harness: expected session/set_mode to be rejected but it succeeded') },
|
||||
() => { /* expected: the bridge rejected the mode id */ },
|
||||
)
|
||||
return
|
||||
}
|
||||
case 'setConfigOption': {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId === undefined) throw new Error('snapshot-harness: setConfigOption before newSession')
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
export {
|
||||
runScenario,
|
||||
type ElicitationAnswer,
|
||||
type HarvestedLog,
|
||||
type InputScript,
|
||||
type InputStep,
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
ndJsonStream,
|
||||
type Agent as AcpAgent,
|
||||
type Client,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
@@ -47,6 +49,8 @@ export interface AcpTestLaunchOptions {
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** Permission handler; omitted requests fail closed as `cancelled`. */
|
||||
requestPermission?: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse>
|
||||
/** Elicitation handler; omitted requests fail closed as `cancel`. */
|
||||
createElicitation?: (params: CreateElicitationRequest) => Promise<CreateElicitationResponse>
|
||||
}
|
||||
|
||||
/** A running ACP test process and its captured client-side surfaces. */
|
||||
@@ -152,6 +156,8 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe
|
||||
}
|
||||
const requestPermission = options.requestPermission
|
||||
?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' as const } }))
|
||||
const createElicitation = options.createElicitation
|
||||
?? (() => Promise.resolve({ action: 'cancel' as const }))
|
||||
const makeClient = (_agent: AcpAgent): Client => ({
|
||||
sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
return trackClientCallback(() => {
|
||||
@@ -175,6 +181,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe
|
||||
})
|
||||
},
|
||||
requestPermission: params => trackClientCallback(() => requestPermission(params)),
|
||||
unstable_createElicitation: params => trackClientCallback(() => createElicitation(params)),
|
||||
})
|
||||
const client = new ClientSideConnection(makeClient, stream)
|
||||
// `exit` only reports the parent process's status. Descendants may retain
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
/**
|
||||
* Scripted ACP agent for snapshot-kit tests. A fixture-adjacent `behavior.json` controls the
|
||||
* subprocess reached through the real harness path; the bin reports observations over ACP and
|
||||
* writes scripted logs before exiting on stdin EOF.
|
||||
* Scripted fake ACP agent bin for `dsh-acp-snapshot`'s unit specs. Speaks
|
||||
* newline-delimited JSON-RPC on stdio like the real `dsh-acp-agent` bin, but
|
||||
* every behavior — how prompts settle, whether session/new rejects, which
|
||||
* session logs get persisted, what filesystem noise to leave — comes from a
|
||||
* `behavior.json` sitting NEXT to the `$DSH_SNAPSHOT_FILE` fixture, so a spec
|
||||
* scripts a whole subprocess run from data. The specs launch it through the
|
||||
* REAL `runScenario` spawn path (tsx loader, temp cwd, env plumbing), so the
|
||||
* harness plumbing is exercised for real; only the agent behind the protocol
|
||||
* is scripted.
|
||||
*
|
||||
* The specs (not the golden tier) own this bin: it asserts nothing, echoes
|
||||
* observable facts into `session/update` text chunks (env probe, permission
|
||||
* outcome, seeded-workspace listing) for the spec to read off `rawStdout`, and
|
||||
* exits 0 on stdin EOF after writing the scripted logs — mirroring the real
|
||||
* bin's dispose-flush-exit shape.
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
@@ -39,6 +51,10 @@ interface Behavior {
|
||||
cancelToolCallUpdate?: boolean
|
||||
/** Before responding to a prompt, send a `session/request_permission` request and echo its outcome as a chunk. */
|
||||
permissionProbe?: boolean
|
||||
/** Before responding to a prompt, send an `elicitation/create` request and echo its response as a chunk. */
|
||||
elicitationProbe?: boolean
|
||||
/** How `session/set_mode` settles: an empty response (echoing the modeId as a chunk) or a JSON-RPC error. */
|
||||
setMode?: 'respond' | 'error'
|
||||
/** Echo the `DSH_SNAPSHOT_*` env the harness set as a chunk (spec-side env-plumbing assertions). */
|
||||
echoEnv?: boolean
|
||||
/** Echo the sorted cwd listing as a chunk (spec-side workspace-seeding assertions). */
|
||||
@@ -84,8 +100,8 @@ let sessionId = ''
|
||||
let sessionCwd = ''
|
||||
/** The parked prompt request id while `hang-until-cancel` waits for the cancel notification. */
|
||||
let parkedPromptId: number | string | null = null
|
||||
/** Resolvers for permission-probe responses, keyed by outbound request id. */
|
||||
const pendingPermission = new Map<number, (outcome: unknown) => void>()
|
||||
/** Resolvers for outbound probe responses (permission/elicitation), keyed by request id. */
|
||||
const pendingOutbound = new Map<number, (result: unknown) => void>()
|
||||
/** Per-run `session/set_config_option` state: config id → current value (first vocabulary entry until set). */
|
||||
const currentConfig: Record<string, string> = {}
|
||||
|
||||
@@ -160,8 +176,8 @@ async function handlePrompt(id: number | string): Promise<void> {
|
||||
}
|
||||
if (behavior.permissionProbe === true) {
|
||||
const requestId = nextOutboundId++
|
||||
const outcome = await new Promise<unknown>((resolve) => {
|
||||
pendingPermission.set(requestId, resolve)
|
||||
const result = await new Promise<unknown>((resolve) => {
|
||||
pendingOutbound.set(requestId, resolve)
|
||||
send({
|
||||
id: requestId,
|
||||
method: 'session/request_permission',
|
||||
@@ -175,7 +191,24 @@ async function handlePrompt(id: number | string): Promise<void> {
|
||||
},
|
||||
})
|
||||
})
|
||||
chunk(`permission:${JSON.stringify(outcome)}`)
|
||||
chunk(`permission:${JSON.stringify((result as { outcome?: unknown } | undefined)?.outcome ?? null)}`)
|
||||
}
|
||||
if (behavior.elicitationProbe === true) {
|
||||
const requestId = nextOutboundId++
|
||||
const result = await new Promise<unknown>((resolve) => {
|
||||
pendingOutbound.set(requestId, resolve)
|
||||
send({
|
||||
id: requestId,
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
sessionId,
|
||||
mode: 'form',
|
||||
message: 'Approve this plan and leave plan mode?',
|
||||
requestedSchema: { type: 'object', title: 'Plan review', properties: { choice: { type: 'string' }, custom: { type: 'string' } }, required: [] },
|
||||
},
|
||||
})
|
||||
})
|
||||
chunk(`elicitation:${JSON.stringify(result ?? null)}`)
|
||||
}
|
||||
switch (behavior.prompt ?? 'respond') {
|
||||
case 'respond':
|
||||
@@ -195,10 +228,10 @@ function handleFrame(frame: Record<string, unknown>): void {
|
||||
const method = frame.method as string | undefined
|
||||
const params = (frame.params ?? {}) as Record<string, unknown>
|
||||
// A response to one of OUR outbound requests (the permission probe).
|
||||
if (method === undefined && id !== undefined && typeof id === 'number' && pendingPermission.has(id)) {
|
||||
const resolve = pendingPermission.get(id) as (outcome: unknown) => void
|
||||
pendingPermission.delete(id)
|
||||
resolve((frame.result as { outcome?: unknown } | undefined)?.outcome ?? null)
|
||||
if (method === undefined && id !== undefined && typeof id === 'number' && pendingOutbound.has(id)) {
|
||||
const resolve = pendingOutbound.get(id) as (result: unknown) => void
|
||||
pendingOutbound.delete(id)
|
||||
resolve(frame.result)
|
||||
return
|
||||
}
|
||||
switch (method) {
|
||||
@@ -219,6 +252,14 @@ function handleFrame(frame: Record<string, unknown>): void {
|
||||
case 'session/prompt':
|
||||
void handlePrompt(id as number | string)
|
||||
return
|
||||
case 'session/set_mode':
|
||||
if ((behavior.setMode ?? 'respond') === 'error') {
|
||||
respondError(id as number | string, 'unknown mode')
|
||||
return
|
||||
}
|
||||
chunk(`setMode:${String(params.modeId)}`)
|
||||
respond(id as number | string, {})
|
||||
return
|
||||
case 'session/set_config_option': {
|
||||
const vocabulary = behavior.configOptions
|
||||
const configId = params.configId as string
|
||||
|
||||
2
packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/session.1.jsonl
vendored
Normal file
2
packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/session.1.jsonl
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
{"type":"session","id":"abababab-cdcd-4efe-8ada-badabadabada","createdAt":800,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW","parentSession":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88"}
|
||||
{"type":"request/header","seq":0,"time":2,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
@@ -95,8 +95,8 @@ describe('runScenario', () => {
|
||||
expect(clientClosed).toBe(true)
|
||||
})
|
||||
|
||||
it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => {
|
||||
const { dir, fixtureFile } = await scenario({ permissionProbe: true, echoEnv: true, stderrNote: 'launcher stderr' })
|
||||
it('centralizes ACP boot, captures, updates, fail-closed interactions, and shutdown', { timeout: 20_000 }, async () => {
|
||||
const { dir, fixtureFile } = await scenario({ permissionProbe: true, elicitationProbe: true, echoEnv: true, stderrNote: 'launcher stderr' })
|
||||
const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-launcher-sessions-'))
|
||||
tempDirs.push(sessionsRoot)
|
||||
const launched = launchAcpTestAgent({
|
||||
@@ -120,6 +120,7 @@ describe('runScenario', () => {
|
||||
expect((await nextChunk).sessionUpdate).toBe('agent_message_chunk')
|
||||
expect(launched.updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true)
|
||||
expect(launched.rawStdout()).toContain('permission:{\\"outcome\\":\\"cancelled\\"}')
|
||||
expect(launched.rawStdout()).toContain('elicitation:{\\"action\\":\\"cancel\\"}')
|
||||
expect(launched.stderr()).toContain('launcher stderr')
|
||||
const unmatched = expect(launched.waitForUpdate(() => false)).rejects.toThrow(/update stream closed/)
|
||||
await launched.close()
|
||||
@@ -716,6 +717,69 @@ describe('runScenario', () => {
|
||||
expect(result.sessionLogs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drives session/set_mode and swallows the expected rejection of setModeExpectError', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({})
|
||||
const result = await runScenario(
|
||||
{ steps: [...boot, { op: 'setMode', modeId: 'plan' }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)
|
||||
expect(result.rawStdout).toContain('setMode:plan')
|
||||
|
||||
const rejecting = await scenario({ setMode: 'error' })
|
||||
const rejected = await runScenario(
|
||||
{ steps: [...boot, { op: 'setModeExpectError', modeId: 'yolo' }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile: rejecting.fixtureFile },
|
||||
)
|
||||
expect(rejected.rawStdout).toContain('unknown mode')
|
||||
})
|
||||
|
||||
it('fails the run when setModeExpectError unexpectedly succeeds, and both mode ops require a session', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({})
|
||||
await expect(runScenario(
|
||||
{ steps: [...boot, { op: 'setModeExpectError', modeId: 'plan' }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)).rejects.toThrow(/expected session\/set_mode to be rejected/)
|
||||
await expect(runScenario(
|
||||
{ steps: [{ op: 'initialize' }, { op: 'setMode', modeId: 'plan' }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)).rejects.toThrow(/setMode before newSession/)
|
||||
await expect(runScenario(
|
||||
{ steps: [{ op: 'initialize' }, { op: 'setModeExpectError', modeId: 'plan' }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)).rejects.toThrow(/setModeExpectError before newSession/)
|
||||
})
|
||||
|
||||
it('answers elicitations from the scripted queue, falling back to cancel on exhaustion', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ elicitationProbe: true })
|
||||
// Three prompts → three elicitations: an accept-with-choice, an
|
||||
// accept-with-custom (feedback), then the exhausted-queue cancel.
|
||||
const result = await runScenario(
|
||||
{
|
||||
steps: [...boot, { op: 'prompt', text: 'one' }, { op: 'prompt', text: 'two' }, { op: 'prompt', text: 'three' }],
|
||||
elicitationAnswers: [
|
||||
{ action: 'accept', choice: 'Approve' },
|
||||
{ action: 'accept', custom: 'add tests first' },
|
||||
],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)
|
||||
const first = result.rawStdout.indexOf('elicitation:{\\"action\\":\\"accept\\",\\"content\\":{\\"choice\\":\\"Approve\\"}}')
|
||||
const second = result.rawStdout.indexOf('elicitation:{\\"action\\":\\"accept\\",\\"content\\":{\\"custom\\":\\"add tests first\\"}}')
|
||||
const third = result.rawStdout.indexOf('elicitation:{\\"action\\":\\"cancel\\"}')
|
||||
expect(first).toBeGreaterThanOrEqual(0)
|
||||
expect(second).toBeGreaterThan(first)
|
||||
expect(third).toBeGreaterThan(second)
|
||||
})
|
||||
|
||||
it('a scripted elicitation cancel answers cancel', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ elicitationProbe: true })
|
||||
const result = await runScenario(
|
||||
{ steps: [...boot, { op: 'prompt', text: 'one' }], elicitationAnswers: [{ action: 'cancel' }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)
|
||||
expect(result.rawStdout).toContain('elicitation:{\\"action\\":\\"cancel\\"}')
|
||||
})
|
||||
|
||||
it('answers permission requests from the scripted queue by option kind, falling back to cancelled', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ permissionProbe: true })
|
||||
// Two prompts → two permission round-trips; one scripted answer, so the
|
||||
@@ -744,8 +808,11 @@ describe('runScenario', () => {
|
||||
|
||||
it('rejects the run on a scripted permission kind the agent never offered', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ permissionProbe: true })
|
||||
// The fake offers only allow_once/reject_once. The harness must reject an impossible click,
|
||||
// not merely send an RPC error that a tolerant agent could absorb.
|
||||
// The fake bin offers allow_once/reject_once; scripting allow_always is a
|
||||
// scenario bug. The agent is answered `cancelled` (it must not be able to
|
||||
// absorb the bug as an error-means-denial), and the RUN fails: a callback
|
||||
// throw would only reach the agent as a JSON-RPC error response, letting
|
||||
// a tolerant agent carry on and the scenario pass — or record.
|
||||
await expect(runScenario(
|
||||
{ steps: [...boot, { op: 'prompt', text: 'impossible click' }], permissionAnswers: [{ kind: 'allow_always' }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
|
||||
@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
|
||||
|
||||
## At a glance
|
||||
|
||||
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
|
||||
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection and permission presets, and **session modes** (the picker, via `@deepseek-ai/dsh-plan-mode`). The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
|
||||
|
||||
## 1. Agent methods (client → agent)
|
||||
|
||||
@@ -25,7 +25,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
|
||||
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
|
||||
| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. |
|
||||
| `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. |
|
||||
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). |
|
||||
| `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-plan-mode` mounted, `session/new`/`session/load` advertise the fixed `default` / `plan` projection and `session/set_mode` records the boolean pending intent (optimistic `current_mode_update`; logged `plan/mode` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). |
|
||||
| `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |
|
||||
| model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. |
|
||||
| `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. |
|
||||
@@ -85,7 +85,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
|
||||
| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. |
|
||||
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). |
|
||||
| `available_commands_update` | S | ✅ | ✅ | ✅ | Full effective snapshot after create/load and registry changes; names, descriptions, and unstructured-input hints come from `ctx.commands`. |
|
||||
| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. |
|
||||
| `current_mode_update` | S | ✅ | ✅ | ✅ | Echoed optimistically on `session/set_mode` and re-notified when a logged `plan/mode` maps to a different wire id (covers the `exit_plan_mode` tool flipping the session back). |
|
||||
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox Agent Note § Per-session mode switching](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). |
|
||||
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
|
||||
| `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. |
|
||||
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
|
||||
|
||||
## 6. Session modes / config options / models
|
||||
|
||||
Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. Session modes stay deliberately unmodeled because config options replace them in ACP v2. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
Session modes ✅ (the [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md)): ACP owns the fixed `default` / `plan` wire vocabulary and projects it onto `ctx.planMode`'s boolean `{ active, pending? }` state; `session/set_mode` calls `set()` and `current_mode_update` tracks the optimistic selection plus each distinct committed `plan/mode` flip. Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. The division is picker-to-collaboration-state / knobs-to-config-options: individual environment knobs and the provider/model selector are not modes. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
## 7. Content blocks
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-plan-mode": "^0.0.1",
|
||||
"@deepseek-ai/dsh-permission": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -61,6 +62,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -36,11 +36,14 @@ import {
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type SessionConfigOption,
|
||||
type SessionModeState,
|
||||
type SessionConfigSelectGroup,
|
||||
type SessionConfigSelectOption,
|
||||
type SessionNotification,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionConfigOptionResponse,
|
||||
type SetSessionModeRequest,
|
||||
type SetSessionModeResponse,
|
||||
type Stream,
|
||||
type StopReason,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
@@ -64,6 +67,9 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f
|
||||
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
|
||||
// Context (the bridge injects it and reads `list()` for load cwd validation).
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
// Type-only edge: resolves `ctx.get('planMode')` when dsh-plan-mode is composed;
|
||||
// the runtime read stays opportunistic.
|
||||
import type {} from '@deepseek-ai/dsh-plan-mode'
|
||||
// Side-effect type import: declaration-merges prompt assembly onto Context and
|
||||
// the scoped waterfall used to keep persona variables aligned with requests.
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -94,6 +100,18 @@ function invalidParams(detail: string): RequestError {
|
||||
return RequestError.invalidParams(undefined, detail)
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_MODE_ID = 'default'
|
||||
const PLAN_SESSION_MODE_ID = 'plan'
|
||||
const AVAILABLE_SESSION_MODES = [
|
||||
{ id: DEFAULT_SESSION_MODE_ID, name: DEFAULT_SESSION_MODE_ID },
|
||||
{ id: PLAN_SESSION_MODE_ID, name: PLAN_SESSION_MODE_ID },
|
||||
]
|
||||
|
||||
/** Map plan state onto ACP's named collaboration-mode protocol. */
|
||||
function sessionModeId(active: boolean): string {
|
||||
return active ? PLAN_SESSION_MODE_ID : DEFAULT_SESSION_MODE_ID
|
||||
}
|
||||
|
||||
/** Render arbitrary thrown values without trusting their string coercion. */
|
||||
function renderThrown(value: unknown): string {
|
||||
try {
|
||||
@@ -186,11 +204,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,
|
||||
@@ -224,7 +245,7 @@ function elicitationForQuestion(
|
||||
return {
|
||||
sessionId,
|
||||
mode: 'form',
|
||||
message: question.question,
|
||||
message,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
title,
|
||||
@@ -286,6 +307,13 @@ interface SessionRecord {
|
||||
presenter: ToolPresenter
|
||||
/** Terminal capability snapshot shared by matching call and result updates. */
|
||||
terminalEnabled: boolean
|
||||
/**
|
||||
* The last mode id this session sent to the client (advertised at
|
||||
* session/new+load, echoed optimistically on session/set_mode, re-notified on
|
||||
* each logged `plan/mode` that differs). `undefined` when dsh-plan-mode is
|
||||
* not composed, so no mode surface is advertised or notified.
|
||||
*/
|
||||
lastModeId: string | undefined
|
||||
/** Session-local provider/model selection and the current step snapshot. */
|
||||
target: LlmTargetRef
|
||||
/** In-flight prompt and its captured turn number for exact settlement. */
|
||||
@@ -542,6 +570,21 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
|
||||
// --- Stream the harness event taxonomy to ACP session/update --------------
|
||||
|
||||
// --- Session modes (dsh-plan-mode, opportunistic) -------------------------
|
||||
// ACP's generic mode picker projects the one plan capability as the fixed
|
||||
// `default` / `plan` vocabulary. A selection is echoed optimistically; the
|
||||
// logged `plan/mode` follows at the boundary and tool-driven exits are
|
||||
// re-notified from that event. Environment knobs remain config options.
|
||||
const modesStateFor = (agent: Agent): SessionModeState | undefined => {
|
||||
const planMode = ctx.get('planMode')
|
||||
if (planMode === undefined) return undefined
|
||||
const { active, pending } = planMode.get(agent)
|
||||
return {
|
||||
availableModes: AVAILABLE_SESSION_MODES,
|
||||
currentModeId: sessionModeId(pending ?? active),
|
||||
}
|
||||
}
|
||||
|
||||
// All content streaming AND the prompt settle flow through `session/event`,
|
||||
// the canonical log: every assistant/chunk and tool/call/result is logged, so
|
||||
// translating from the log makes live streaming and `session/load` replay
|
||||
@@ -567,6 +610,20 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
cwd: session.header.cwd,
|
||||
}, { includeUserMessages: false })
|
||||
} finally {
|
||||
// Re-notify from the EVENT's value, not from planMode.get(): the service
|
||||
// holds one coalesced pending slot (every flush reads the latest
|
||||
// selection, so a flush can never be stale against the picker), and for
|
||||
// any other writer — the exit tool, a test, a foreign plugin — the logged
|
||||
// value IS the truth the picker should track, in log order. Inside the
|
||||
// containment `finally` like the prompt settlement: a throwing presenter
|
||||
// must not desync the picker.
|
||||
if (event.type === 'plan/mode') {
|
||||
const modeId = sessionModeId(event.data.active)
|
||||
if (modeId !== rec.lastModeId) {
|
||||
rec.lastModeId = modeId
|
||||
notify({ sessionId: rec.agent.session.id, update: { sessionUpdate: 'current_mode_update', currentModeId: modeId } })
|
||||
}
|
||||
}
|
||||
const inflight = rec.inflight
|
||||
if (inflight !== undefined && event.type === 'turn/start') {
|
||||
// The first message-triggered turn after prompt installation owns the
|
||||
@@ -726,11 +783,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
await handle.dispose()
|
||||
throw internalError('connection closed during session/new')
|
||||
}
|
||||
const modes = modesStateFor(handle.agent)
|
||||
const record: SessionRecord = {
|
||||
agent: handle.agent,
|
||||
dispose: () => handle.dispose(),
|
||||
presenter: makePresenter(handle.agent),
|
||||
terminalEnabled: terminalOutputCap,
|
||||
lastModeId: modes?.currentModeId,
|
||||
target,
|
||||
inflight: undefined,
|
||||
commandAbort: undefined,
|
||||
@@ -739,7 +798,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
sessions.set(sessionId, record)
|
||||
pendingCommandSnapshots.set(sessionId, record)
|
||||
const configOptions = configOptionsFor(handle.agent, directory)
|
||||
return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} }
|
||||
return {
|
||||
sessionId,
|
||||
...modes !== undefined ? { modes } : {},
|
||||
...configOptions.length > 0 ? { configOptions } : {},
|
||||
}
|
||||
},
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
@@ -811,11 +874,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// the replay below and the post-load live stream) so a later
|
||||
// `initialize` can't desync the call/result of a tool card.
|
||||
const terminalEnabled = terminalOutputCap
|
||||
const modes = modesStateFor(agent)
|
||||
const record: SessionRecord = {
|
||||
agent,
|
||||
dispose: () => handle.dispose(),
|
||||
presenter: makePresenter(agent),
|
||||
terminalEnabled,
|
||||
lastModeId: modes?.currentModeId,
|
||||
target,
|
||||
inflight: undefined,
|
||||
commandAbort: undefined,
|
||||
@@ -845,12 +910,33 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
}
|
||||
notifyCommands(record)
|
||||
const configOptions = configOptionsFor(agent, directory)
|
||||
return configOptions.length > 0 ? { configOptions } : {}
|
||||
return {
|
||||
...modes !== undefined ? { modes } : {},
|
||||
...configOptions.length > 0 ? { configOptions } : {},
|
||||
}
|
||||
} finally {
|
||||
loadingIds.delete(sessionId)
|
||||
}
|
||||
},
|
||||
|
||||
setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
|
||||
assertOpen()
|
||||
const rec = requireSession(SessionId(params.sessionId))
|
||||
const planMode = ctx.get('planMode')
|
||||
if (planMode === undefined) throw invalidParams('session modes are not composed in this deployment')
|
||||
if (params.modeId !== DEFAULT_SESSION_MODE_ID && params.modeId !== PLAN_SESSION_MODE_ID) {
|
||||
throw invalidParams(`unknown session mode ${JSON.stringify(params.modeId)} — available modes: default, plan`)
|
||||
}
|
||||
planMode.set(rec.agent, params.modeId === PLAN_SESSION_MODE_ID)
|
||||
// Optimistic echo: the pending mode IS the user's selection; the logged
|
||||
// `plan/mode` lands at the next turn boundary and, matching lastModeId,
|
||||
// is not re-notified. A no-op selection (already current) echoes too —
|
||||
// cheap, idempotent, and the picker settles regardless.
|
||||
rec.lastModeId = params.modeId
|
||||
notify({ sessionId: rec.agent.session.id, update: { sessionUpdate: 'current_mode_update', currentModeId: params.modeId } })
|
||||
return Promise.resolve({})
|
||||
},
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
assertOpen()
|
||||
const rec = requireSession(SessionId(params.sessionId))
|
||||
|
||||
@@ -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' }],
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
@@ -191,6 +192,8 @@ export async function makeBridgeHarness(options: {
|
||||
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
|
||||
*/
|
||||
withTodo?: boolean
|
||||
/** Plug the REAL `dsh-plan-mode` plugin so a test can drive the session-mode picker. */
|
||||
withModes?: boolean
|
||||
/**
|
||||
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
|
||||
* `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge
|
||||
@@ -225,6 +228,9 @@ export async function makeBridgeHarness(options: {
|
||||
if (options.withTodo) {
|
||||
await ctx.plugin(ToolTodo)
|
||||
}
|
||||
if (options.withModes) {
|
||||
await ctx.plugin(PlanModeService, { section: 'Test plan mode instructions.' })
|
||||
}
|
||||
if (options.withFs) {
|
||||
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })
|
||||
await ctx.plugin(FsPolicy)
|
||||
|
||||
116
packages/ui/acp/tests/modes.spec.ts
Normal file
116
packages/ui/acp/tests/modes.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
|
||||
/** The `current_mode_update` notifications, in order. */
|
||||
function modeUpdates(updates: CapturedUpdate[]): string[] {
|
||||
return updates
|
||||
.filter(update => update.sessionUpdate === 'current_mode_update')
|
||||
.map(update => update.currentModeId)
|
||||
}
|
||||
|
||||
describe('acp bridge — plan mode projection', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
let loader: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-modes-')) })
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.dispose()
|
||||
if (loader) await loader.dispose()
|
||||
harness = loader = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('advertises no mode surface and rejects session/set_mode when plan mode is not composed', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.modes).toBeUndefined()
|
||||
await expect(harness.client.setSessionMode({ sessionId: res.sessionId, modeId: 'plan' }))
|
||||
.rejects.toMatchObject({ message: expect.stringContaining('session modes are not composed') as string })
|
||||
})
|
||||
|
||||
it('advertises availableModes/currentModeId on session/new', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withModes: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.modes).toEqual({
|
||||
availableModes: [
|
||||
{ id: 'default', name: 'default' },
|
||||
{ id: 'plan', name: 'plan' },
|
||||
],
|
||||
currentModeId: 'default',
|
||||
})
|
||||
})
|
||||
|
||||
it('session/set_mode records the pending intent and echoes one optimistic current_mode_update', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withModes: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
|
||||
expect(modeUpdates(harness.updates)).toEqual(['plan'])
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
expect(harness.ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
|
||||
})
|
||||
|
||||
it('rejects an unknown ACP mode id at the adapter boundary', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withModes: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.setSessionMode({ sessionId, modeId: 'nope' }))
|
||||
.rejects.toMatchObject({ message: expect.stringContaining('unknown session mode "nope"') as string })
|
||||
expect(modeUpdates(harness.updates)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not re-notify when the boundary flush logs the mode the picker already showed', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(true)
|
||||
expect(modeUpdates(harness.updates)).toEqual(['plan'])
|
||||
})
|
||||
|
||||
it('re-notifies on a logged flip the picker has not seen (the tool-driven exit shape)', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
|
||||
// A writer other than the picker (exit_plan_mode's execute) appends the
|
||||
// flip back; the bridge must re-notify the client off the logged event.
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
agent.session.append('plan/mode', { active: false })
|
||||
// The notification crosses the in-memory JSON-RPC transport asynchronously.
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(modeUpdates(harness.updates)).toEqual(['plan', 'default'])
|
||||
})
|
||||
|
||||
it('advertises the folded mode on session/load', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
|
||||
await harness.dispose()
|
||||
harness = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, withModes: true, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.modes).toEqual({
|
||||
availableModes: [
|
||||
{ id: 'default', name: 'default' },
|
||||
{ id: 'plan', name: 'plan' },
|
||||
],
|
||||
currentModeId: 'plan',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -41,6 +41,9 @@
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../plan/plan-mode"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plu
|
||||
|
||||
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
|
||||
|
||||
Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it.
|
||||
Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it.
|
||||
|
||||
## Composition
|
||||
|
||||
@@ -22,11 +22,11 @@ The terminal and ACP app bundles mount this service with their consuming front d
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt.
|
||||
The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) submits the optional message in `/plan [message]` after selecting plan mode.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Command discovery, execution, and UI output add no model tokens. A command plugin may separately mutate a model-visible domain through that domain's durable APIs.
|
||||
Command discovery, execution, and UI output add no model tokens. Explicit agent work scheduled by a command producer has the same token effect as the corresponding agent input.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label
|
||||
|
||||
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
|
||||
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly and unknown commands produce a warning, with no automatic fallthrough to the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
|
||||
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
|
||||
@@ -58,7 +58,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only; command results remain terminal notices.
|
||||
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -949,9 +949,15 @@ class QuestionDialog implements Component, Focusable {
|
||||
const lines = [
|
||||
this.palette.muted(header),
|
||||
...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth),
|
||||
'',
|
||||
]
|
||||
const push = (line: string): void => { lines.push(line) }
|
||||
// Supporting detail (e.g. the full plan under review) renders between the
|
||||
// question and the answer surface, kept out of option labels.
|
||||
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)
|
||||
push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
|
||||
|
||||
@@ -1120,12 +1120,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('Question 1/1 (1 unanswered) · Mode')
|
||||
expect(result.terminal.output).toContain('1/2')
|
||||
result.terminal.send('\x1b[B')
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
@@ -13,12 +13,14 @@ export interface AskUserQuestionOption {
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** One question in an ask_user_question request. */
|
||||
/** One question in a user-interaction request. */
|
||||
export interface AskUserQuestionItem {
|
||||
/** Stable model-provided question id, echoed in the answer. */
|
||||
/** Stable caller-provided question id, echoed in the answer. */
|
||||
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. */
|
||||
|
||||
Reference in New Issue
Block a user