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.
117 lines
6.2 KiB
TypeScript
117 lines
6.2 KiB
TypeScript
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',
|
|
})
|
|
})
|
|
})
|