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:
@@ -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)}`)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
export {
|
||||
runScenario,
|
||||
type AgentUnderTest,
|
||||
type ElicitationAnswer,
|
||||
type HarvestedLog,
|
||||
type InputScript,
|
||||
type InputStep,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user