feat(mode): exit_plan_mode + the ACP session-mode picker + scriptable review answers

Plan mode's stage 2 (RFC 2026-07-07-plan-mode). The exit tool: one
required plan argument (the durable log artifact), execute re-checks the
folded mode, then conducts the review over the user-interaction seam —
one single-select question (Approve / Keep planning) with free text open
— so an approval appends mode/set back to default in-turn and every
other outcome (keep-planning feedback verbatim, aborted, no provider)
returns the corrective isError with the mode unchanged. presentCall is a
generic card titled by the plan's first heading carrying the plan
markdown; over ACP the review rides the ask_user elicitation flow, in
the terminal the stdio prompt queue — no approval-seam dependency.

The ACP bridge maps the picker 1:1 onto ctx.modes (opportunistic, a
type-only peer edge): session/new + session/load advertise
availableModes/currentModeId, session/set_mode validates through set()
and echoes an optimistic current_mode_update (the pending mode IS the
selection; the logged mode/set lands at the boundary and, matching, is
not re-sent), and a session/event listener re-notifies on each logged
flip that differs from the last sent — the tool-driven exit updates the
picker. The feature matrix rows move from 'not modeled' to the
picker-to-modes / knobs-to-config-options division, with the ACP v2
removal direction recorded as a mechanical-migration risk.

The snapshot harness gains the setMode/setModeExpectError ops and a
scripted elicitationAnswers FIFO (cancel on exhaustion; a stray choice
string reaches the agent verbatim as a non-consenting custom answer, so
a scenario bug fails safe). The suite factory's header-pin requirement
now applies only to model-turn scenarios — a protocol-only suite has no
header content to anchor. examples/plan-acp-agent is the live
composition; its keyless modes-advertise scenario pins the wire surface
(advertisement, both set_mode round-trips, unknown-id rejection). The
recorded plan-mode approve/reject arc awaits a with-key recording
session; its texts are pinned at the unit tier meanwhile.

examples/AGENTS.md ceiling 653 → 680: the new example's required smoke
row does not fit the old budget.
This commit is contained in:
kingwl
2026-07-10 02:57:40 +08:00
parent 63ced3e0e2
commit edc065666f
43 changed files with 947 additions and 50 deletions

View File

@@ -130,7 +130,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
summary: '`ctx.modes`: the session-mode service.',
methods: [
'list(): string[]',
'get(agent: Agent): { current: string, pending?: string }',
'get(agent: Agent): { current: string; pending?: string }',
'set(agent: Agent, mode: string): void',
],
},

View File

@@ -4,6 +4,6 @@ Session modes: named, logged, per-agent policy states, with **plan mode** as the
| Package | Role | ctx key |
|---|---|---|
| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the soft layer (assemble filter + `mode:policy` section) and the hard layer (`tools/pre-execute` deny-by-default gate) | `ctx.modes` |
| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the soft layer (assemble filter + `mode:policy` section), the hard layer (`tools/pre-execute` deny-by-default gate), and the model-facing `exit_plan_mode` review tool | `ctx.modes` |
The mode in force is a pure function of the session log (`SessionEventMap['mode/set']`, last one wins), so resume and fork restore it with no extra machinery; the default mode is the absence of policy, keeping the plugin invisible until a mode is set. UIs read flips off `session/event`: the [stdio app](../ui/stdio-agent) exposes `/mode`, the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker. RFC: [plan mode](../../docs/rfc/proposed/feature/2026-07-07-plan-mode.md).

View File

@@ -10,7 +10,7 @@ The `default` mode is the absence of policy: no section, no filtering, no gate.
## Two layers of enforcement
**Soft — what the model sees.** A `system-prompt/assemble` listener filters the returned assembly's tools down to the mode's allowlist and the `mode:policy` section (order 50) renders the mode's guidance text. Every transition therefore surfaces as an attributable `request/header-delta` on the next step. The `exit_plan_mode` tool is visible IFF the folded mode is `plan`.
**Soft — what the model sees.** A `system-prompt/assemble` listener filters the returned assembly's tools down to the mode's allowlist and the `mode:policy` section (order 50) renders the mode's guidance text. Every transition therefore surfaces as an attributable `request/header` event on the next step (a delta when expressible; adding `exit_plan_mode` resorts the canonical tool list, which the delta encoding cannot express, so entering plan mode logs the full fallback snapshot). The `exit_plan_mode` tool is visible IFF the folded mode is `plan`.
**Hard — what can run.** A `tools/pre-execute` listener denies, deny-by-default against the same allowlist, any call the mode does not permit — a hallucinated call to a still-registered (or freshly re-widened) tool cannot run. Agent-less executions and the default mode pass through; the gate judges by the LOGGED mode only, never a pending intent.
@@ -20,6 +20,10 @@ The `default` mode is the absence of policy: no section, no filtering, no gate.
`AgentOptions.mode` (declaration-merged) seeds a child's initial mode through the same pending-intent flush; explicit options beat the logged baseline on create AND resume. A fork child needs no mechanism — the parent's `mode/set` is inside the seeded prefix.
## `exit_plan_mode`
The model-facing exit tool. Its single required argument is the plan text — a durable, replayable log artifact riding the ordinary `tool/call` event. `execute` re-checks the folded mode, then conducts the review over the user-interaction seam (`ctx.get('userInteraction')`, opportunistic): one single-select question — Approve, or Keep planning — with the free-text channel open. Approve appends `mode/set { mode: 'default' }` in-turn and the next step's assembly restores the full toolset; every other outcome (keep-planning with the user's feedback verbatim, an aborted question, no provider) returns the corrective `isError` and the mode stays `plan`. `presentCall` renders a `generic` card titled by the plan's first heading with the plan markdown as content; over ACP the review rides the same elicitation flow as `ask_user_question`, in the terminal the stdio provider's prompt queue.
## Config
```yaml

View File

@@ -26,6 +26,7 @@
"@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.6"
},
"devDependencies": {
@@ -35,6 +36,7 @@
"@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.6"
}
}

View File

@@ -26,8 +26,10 @@
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 { PreToolDecision } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
@@ -112,6 +114,27 @@ const PLAN_SECTION
const PLAN_TOOLS = ['read', 'todo_write', 'web_search', 'web_fetch', EXIT_PLAN_MODE]
/** The review question's approve option label — the answer item is matched by it. */
const APPROVE_LABEL = 'Approve'
/** The review question's keep-planning option label. */
const KEEP_PLANNING_LABEL = 'Keep planning'
const EXIT_DESCRIPTION
= '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 (the full toolset returns on your next step) or keep '
+ 'planning — their feedback comes back in the tool result; revise and present again.'
/** The plan's first markdown heading (any level), or `undefined` when it has none. */
function firstHeading(plan: string): string | undefined {
for (const line of plan.split('\n')) {
const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)
if (match) return match[1]
}
return undefined
}
/**
* Validate the config and merge the built-in `plan` definition (explicit
* resolve step — the `dsh-bash` request/spec template). Fail-loud: a
@@ -240,6 +263,61 @@ export class ModesService extends Service {
: `tool "${exec.name}" is not available in "${active.name}" mode`
return Promise.resolve({ kind: 'deny', reason })
})
ctx.tools.register(defineTool({
name: EXIT_PLAN_MODE,
description: EXIT_DESCRIPTION,
parameters: {
plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },
},
execute: async (_args, exec) => {
const agent = exec.agent
if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
if (this.activeDefinition(agent.session)?.name !== PLAN_MODE) {
throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
}
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?',
options: [
{ label: APPROVE_LABEL, description: 'Leave plan mode; the full toolset returns on the next step.' },
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
],
}],
agent,
...exec.signal ? { signal: exec.signal } : {},
})
const item = answer.answers.find(entry => entry.id === 'plan-review')
if (!item?.selected.includes(APPROVE_LABEL)) {
// A custom-text-only answer is feedback, not consent — approval is
// exactly the approve option (an unknown selection never exits).
const feedback = item?.custom ?? ''
throw new Error(feedback === ''
? 'The user chose to keep planning; revise the plan and present it again.'
: `The user chose to keep planning; their feedback: ${feedback}`)
}
agent.session.append('mode/set', { mode: DEFAULT_MODE })
const note = item.custom === undefined || item.custom === '' ? '' : ` User note: ${item.custom}`
return [{ type: 'text', text: `Plan approved — plan mode exited; the full toolset returns on your next step.${note}` }]
},
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,
}),
}))
}
/**

View File

@@ -78,7 +78,7 @@ describe('plan mode through the agent loop', () => {
const header = findEvent(log, 'request/header')
expect(modeSet.seq).toBeLessThan(header.seq)
expect(header.data.reason).toBe('initial')
expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['read'])
expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read'])
expect(header.data.header.system).toContain('plan mode')
const result = findEvent(log, 'tool/result')
@@ -110,8 +110,12 @@ describe('plan mode through the agent loop', () => {
expect(findEvent(log, 'context/message').data.content).toEqual([
{ type: 'text', text: 'The user switched this session to plan mode.' },
])
const delta = findEvent(log, 'request/header-delta')
expect(delta.data.tools).toBeDefined()
expect(delta.data.system).toBeDefined()
// The narrowing header change is logged as a FULL fallback snapshot, not a
// delta: adding exit_plan_mode reorders the canonical tool list (it sorts
// first), and a pure reordering is inexpressible in the delta encoding.
const second = findEvent(log, 'request/header', 'last')
expect(second.data.reason).toBe('fallback')
expect(second.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read'])
expect(second.data.header.system).toContain('plan mode')
})
})

View File

@@ -6,6 +6,7 @@ import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts'
import type { ModeConfig } from '../src/index.ts'
@@ -283,7 +284,7 @@ describe('the boundary flush', () => {
describe('the soft layer', () => {
it('keeps a default-mode assembly identical to a no-dsh-mode deployment (exit tool dropped)', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
@@ -292,7 +293,7 @@ describe('the soft layer', () => {
it('leaves an agent-less assembly untouched', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', EXIT_PLAN_MODE])
registerNamedTools(ctx, ['read'])
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
@@ -300,7 +301,7 @@ describe('the soft layer', () => {
it('filters plan-mode tools to the allowlist and renders the mode section', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', 'todo_write', EXIT_PLAN_MODE])
registerNamedTools(ctx, ['read', 'write', 'todo_write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
@@ -310,7 +311,7 @@ describe('the soft layer', () => {
it('drops exit_plan_mode outside plan mode even when a custom allowlist names it', async () => {
const ctx = await setup({ modes: { review: { section: 'reviewing', tools: ['read', EXIT_PLAN_MODE] } } })
registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'review' })
const assembly = await ctx.systemPrompt.assemble({ agent })
@@ -320,7 +321,7 @@ describe('the soft layer', () => {
it('treats a dropped folded definition as the default mode', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
const assembly = await ctx.systemPrompt.assemble({ agent })
@@ -382,3 +383,172 @@ describe('the hard layer', () => {
expect(result.isError).toBe(false)
})
})
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 = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
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 },
...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(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 (defense in depth behind the gate)', async () => {
const ctx = await setup()
const agent = agentWithSession()
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('degrades to the manual exit when no user-interaction seam is composed', async () => {
const ctx = await setup()
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction channel is available to review the plan; ask the user to switch the session mode instead' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
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(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
it('approve: appends mode/set default in-turn and confirms', 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; the full toolset returns on your next step.' }])
expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
expect(asked).toHaveLength(1)
expect(asked[0]?.agent).toBe(agent)
expect(asked[0]?.questions[0]?.options?.map(option => option.label)).toEqual(['Approve', 'Keep planning'])
})
it('approve with a note carries the note into the confirmation', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'], custom: 'ship it small' })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; the full toolset returns on your next step. User note: ship it small' }])
})
it('keep planning returns the corrective error carrying the feedback verbatim', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'], custom: 'consider the resume path' })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: consider the resume path' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
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(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
it('a missing answer item reads as keep-planning', async () => {
const { ctx, agent } = await setupWithReview()
ctx.userInteraction.registerProvider({ ask: () => Promise.resolve({ answers: [] }) })
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('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(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
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,
})
})
})

View File

@@ -25,6 +25,9 @@
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../ui/user-interaction"
}
]
}

View File

@@ -35,4 +35,4 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. Fixture roles, record/replay semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug).
Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Elicitation round-trips (`ask_user_question` / the plan review) script the same way: `InputScript.elicitationAnswers` is a FIFO of `{ action, choice?, custom? }` form answers; exhaustion answers `cancel`, and a stray `choice` string reaches the agent verbatim as a non-consenting custom answer, so a scenario bug fails safe in the transcript.

View File

@@ -29,6 +29,8 @@ import {
PROTOCOL_VERSION,
type Agent as AcpAgent,
type Client,
type CreateElicitationRequest,
type CreateElicitationResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
@@ -85,6 +87,8 @@ export type InputStep =
| { op: 'promptExpectError'; text: string }
| { op: 'promptAndCancel'; text: string }
| { op: 'cancel' }
| { op: 'setMode'; modeId: string }
| { op: 'setModeExpectError'; modeId: string }
/** A scenario's `input.json`: an ordered list of input steps. */
export interface InputScript {
@@ -102,6 +106,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. */
@@ -110,6 +124,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`). */
@@ -251,6 +275,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
@@ -291,6 +317,17 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
}
return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } })
},
unstable_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 client = new ClientSideConnection(makeClient, stream)
@@ -406,6 +443,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
}
default:
throw new Error(`snapshot-harness: unknown input op ${JSON.stringify(step)}`)
}

View File

@@ -18,6 +18,7 @@
export {
runScenario,
type AgentUnderTest,
type ElicitationAnswer,
type HarvestedLog,
type InputScript,
type InputStep,

View File

@@ -229,6 +229,11 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
pinningByClass.set(cls, scenario)
}
for (const scenario of scenarios) {
// Only a scenario that RUNS a model turn produces request-header events
// for the uniformity guard to compare — a protocol-only scenario's fixture
// carries no header content, so it needs no anchor (and a suite of only
// protocol scenarios legitimately has none).
if (!scenario.hasModelTurn) continue
if (!pinningByClass.has(classOf(scenario))) {
throw new Error(`acp-snapshot: no scenario pins the request-header content of class "${classOf(scenario)}" (needed by ${scenario.name})`)
}
@@ -329,9 +334,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
// scenario's fixture) or composition became session-dependent by
// design (give the divergent shape its own pinning scenario and
// class).
if (scenario.pinsHeader !== true) {
/* v8 ignore next -- construction guarantees the pin exists; a miss would fail the one-header assertion loudly. */
const pinningScenario = pinningByClass.get(classOf(scenario)) ?? scenario
const classPin = pinningByClass.get(classOf(scenario))
if (scenario.pinsHeader !== true && classPin !== undefined) {
const pinningScenario = classPin
const pinnedFixture = await readFile(join(snapshotsDir, pinningScenario.name, 'session.jsonl'), 'utf8')
const pinned = normalizedHeaders(pinnedFixture, fixtureContext(pinnedFixture))
expect(pinned.length, `the pinning fixture (${pinningScenario.name}) must carry exactly one request/header`)
@@ -401,7 +406,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
}
expect(Object.fromEntries([...pins].map(([cls, names]) => [cls, names.length]))).toEqual(
Object.fromEntries([...pinningByClass.keys()].map(cls => [cls, 1])))
for (const scenario of scenarios) {
for (const scenario of scenarios.filter(s => s.hasModelTurn)) {
expect(pinningByClass.has(classOf(scenario)), `class "${classOf(scenario)}" (scenario ${scenario.name}) has a pin`).toBe(true)
}
})

View File

@@ -44,6 +44,10 @@ interface Behavior {
prompt?: 'respond' | 'error' | 'hang-until-cancel'
/** 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). */
@@ -79,8 +83,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>()
function send(frame: Record<string, unknown>): void {
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...frame })}\n`)
@@ -136,8 +140,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',
@@ -151,7 +155,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':
@@ -171,10 +192,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) {
@@ -195,6 +216,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/cancel':
if (parkedPromptId !== null) {
const parked = parkedPromptId

View File

@@ -231,6 +231,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

View File

@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
## At a glance
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), and resumable session replay. The largest **unbuilt** areas are the **permission gate** (`session/request_permission`), **MCP passthrough**, **session modes / config options / model selection**, **slash commands**, and **agent plans** — all of which both reference adapters ship — 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), and resumable session replay. The largest **unbuilt** areas are the **permission gate** (`session/request_permission`), **MCP passthrough**, **config options / model selection** (session modes ship via `dsh-mode` — see [§6](#6-session-modes--config-options--models)), **slash commands**, and **agent plans** — all of which both reference adapters ship — 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 | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. |
| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. |
| `session/set_mode` | S | | ✅ | ✅ | Session modes not modeled (see [§6 Modes](#6-session-modes--config-options--models)). |
| `session/set_mode` | S | | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-mode` mounted, `session/new`/`session/load` advertise `availableModes`/`currentModeId` and `session/set_mode` records the pending intent (optimistic `current_mode_update`; the logged `mode/set` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ❌ | ✅ | ✅ | Config options not modeled. |
| model selection | S | ❌ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. The bridge fixes the model per-bridge via config; no runtime switch. Codex still uses 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 `tool/result` via `presentResult`. |
| `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 | ❌ | ✅ | ✅ | No slash commands advertised. |
| `current_mode_update` | S | | ✅ | ✅ | No session modes. |
| `current_mode_update` | S | | ✅ | ✅ | Echoed optimistically on `session/set_mode` and re-notified on each logged `mode/set` that differs from the last sent (covers the `exit_plan_mode` tool flipping the session back). |
| `config_option_update` | S | ❌ | ✅ | ✅ | No config options. |
| `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
❌ None modeled. Both reference adapters ship modes (Claude: a "plan" auto-mode; Codex: read-only / agent / agent-full-access mapping to its approval+sandbox policy), the newer config-option surface, and runtime model selection. The harness fixes the model per-bridge via `AcpConfig.model`. These are coupled to the unbuilt **permission gate** (a mode often selects an approval policy), so they are natural follow-ups to it.
Session modes ✅ (the [plan-mode RFC](../../../docs/rfc/proposed/feature/2026-07-07-plan-mode.md)): the picker maps 1:1 onto `dsh-mode`'s vocabulary — `ctx.modes.list()` fills `availableModes`, `session/set_mode` calls `set()` (pending intent, flushed at the turn boundary), and `current_mode_update` tracks both the optimistic echo and every logged flip. The division is picker-to-modes / knobs-to-config-options: individual environment knobs (sandbox mode, approval policy, the model) are NOT modes and belong to `session/set_config_option` — still unbuilt here, as is runtime model selection (the harness fixes the model per-bridge via `AcpConfig.model`). The ACP draft v2 direction reportedly slates session modes for removal in favor of config options; if that lands, the picker migrates mechanically (the mode state and both enforcement layers are wire-agnostic).
## 7. Content blocks
@@ -142,7 +142,7 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl
1. **Permission gate**`session/request_permission` + permission options. Tracked `TODO(rfc010-permission-gate)`; the reverse map is already wired and shared with `ask_user_question` routing. Foundational, and a prerequisite for modes.
2. **Session lifecycle**`session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
3. **Modes / config options / model selection**coupled to the permission gate.
3. **Config options / model selection**session modes shipped with `dsh-mode`; the knob surface (`session/set_config_option`) is the sandbox stack's config phase.
4. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
5. **Slash commands** (`available_commands_update`).
6. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).

View File

@@ -29,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-mode": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
@@ -42,15 +43,16 @@
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-mode": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -60,7 +60,10 @@ import {
type PlanEntry,
type PromptRequest,
type PromptResponse,
type SessionModeState,
type SessionNotification,
type SetSessionModeRequest,
type SetSessionModeResponse,
type Stream,
type StopReason,
} from '@agentclientprotocol/sdk'
@@ -74,6 +77,9 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence'
// Type-only edge: makes `ctx.get('modes')` resolve the ModesService type when
// @deepseek-ai/dsh-mode is composed; the runtime read stays opportunistic.
import type {} from '@deepseek-ai/dsh-mode'
import {
UserInteractionError,
type AskUserQuestionAnswer,
@@ -282,6 +288,13 @@ interface SessionRecord {
* result terminal) or clobber the card (call terminal, result non-terminal).
*/
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 `mode/set` that differs). `undefined` when dsh-mode is not
* composed — no mode surface is advertised, so nothing is ever notified.
*/
lastModeId: string | undefined
/**
* The in-flight `session/prompt`, or `undefined` when none is pending. A
* prompt resolves with a {@link StopReason} or rejects with an Error (a
@@ -457,6 +470,24 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- Stream the harness event taxonomy to ACP session/update --------------
// --- Session modes (dsh-mode, opportunistic) ------------------------------
// The mode PICKER is dsh-mode's ACP surface (the plan-mode RFC): advertised
// as `modes` on session/new + session/load, switched via session/set_mode —
// optimistic `current_mode_update` (the pending mode IS the user's
// selection; the logged `mode/set` follows at the turn boundary) — and
// re-notified on each logged flip that differs from the last sent (covers
// the exit_plan_mode tool flipping the session back). Environment knobs are
// NOT modes; they stay `session/set_config_option`.
const modesStateFor = (agent: Agent): SessionModeState | undefined => {
const modes = ctx.get('modes')
if (modes === undefined) return undefined
const { current, pending } = modes.get(agent)
return {
availableModes: modes.list().map(name => ({ id: name, name })),
currentModeId: pending ?? current,
}
}
// 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
@@ -480,6 +511,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
enabled: rec.terminalEnabled,
cwd: session.header.cwd,
}, { includeUserMessages: false })
if (event.type === 'mode/set' && event.data.mode !== rec.lastModeId) {
rec.lastModeId = event.data.mode
notify({ sessionId: rec.sessionId, update: { sessionUpdate: 'current_mode_update', currentModeId: event.data.mode } })
}
const inflight = rec.inflight
if (inflight === undefined) return
if (event.type === 'turn/start') {
@@ -603,15 +638,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
agentOptions: agentOptions(config),
})
bySession.set(handle.agent, sessionId)
const modes = modesStateFor(handle.agent)
sessions.set(sessionId, {
sessionId,
agent: handle.agent,
dispose: () => handle.dispose(),
presenter: makePresenter(),
terminalEnabled: terminalOutputCap,
lastModeId: modes?.currentModeId,
inflight: undefined,
})
return Promise.resolve({ sessionId })
return Promise.resolve({ sessionId, ...modes !== undefined ? { modes } : {} })
},
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
@@ -680,12 +717,14 @@ 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 = {
sessionId,
agent,
dispose: () => handle.dispose(),
presenter: makePresenter(),
terminalEnabled,
lastModeId: modes?.currentModeId,
inflight: undefined,
}
sessions.set(sessionId, record)
@@ -710,12 +749,32 @@ export function apply(ctx: Context, config: AcpConfig): void {
for (const event of agent.session.events) {
streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
}
return {}
return modes !== undefined ? { modes } : {}
} finally {
loadingIds.delete(sessionId)
}
},
setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))
const modes = ctx.get('modes')
if (modes === undefined) throw invalidParams('session modes are not composed in this deployment')
try {
modes.set(rec.agent, params.modeId)
} catch (error) {
// ModesService.set throws only Error (its unknown-name validation).
throw invalidParams((error as Error).message)
}
// Optimistic echo: the pending mode IS the user's selection; the logged
// `mode/set` 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.sessionId, update: { sessionUpdate: 'current_mode_update', currentModeId: params.modeId } })
return Promise.resolve({})
},
async prompt(params: PromptRequest): Promise<PromptResponse> {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))

View File

@@ -24,6 +24,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import ModesService from '@deepseek-ai/dsh-mode'
import {
ClientSideConnection,
ndJsonStream,
@@ -180,6 +181,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-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
@@ -211,6 +214,9 @@ export async function makeBridgeHarness(options: {
if (options.withTodo) {
await ctx.plugin(ToolTodo)
}
if (options.withModes) {
await ctx.plugin(ModesService)
}
if (options.withFs) {
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })
await ctx.plugin(FsPolicy)

View 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 { AgentId } from '@deepseek-ai/dsh-agent'
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 — session modes (dsh-mode)', () => {
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 dsh-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(AgentId(sessionId))!
expect(harness.ctx.modes.get(agent)).toEqual({ current: 'default', pending: 'plan' })
})
it('rejects an unknown mode id with the service validation message', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.setSessionMode({ sessionId, modeId: 'nope' }))
.rejects.toMatchObject({ message: expect.stringContaining('unknown mode "nope"') as string })
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(AgentId(sessionId))!
expect(agent.session.events.some(event => event.type === 'mode/set')).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(AgentId(sessionId))!
agent.session.append('mode/set', { mode: 'default' })
// 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',
})
})
})

View File

@@ -32,6 +32,9 @@
{
"path": "../user-interaction"
},
{
"path": "../../mode/mode"
},
{
"path": "../../session-persistence/session-persistence"
}