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

@@ -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))