2026-07-10 01:38:39 +08:00
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'
2026-07-22 21:31:16 +08:00
import ToolRegistry , { RUN_CODE_NAME , defineContentToolFixture } from '@deepseek-ai/dsh-tools'
2026-07-10 01:38:39 +08:00
import { Session , SessionId } from '@deepseek-ai/dsh-session'
2026-07-20 22:52:24 +08:00
import { agentEvents , type Agent , type RequestErrorDecision } from '@deepseek-ai/dsh-agent'
2026-07-21 11:11:35 +08:00
import { createScope } from '@deepseek-ai/dsh-scope'
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.
2026-07-10 02:57:40 +08:00
import UserInteractionService , { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
2026-07-21 11:11:35 +08:00
import CommandService from '@deepseek-ai/dsh-commands'
2026-07-10 20:26:59 +08:00
import { CodeRuntime , type CodeRunRequest , type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
2026-07-22 16:57:23 +08:00
import PlanModeService , { EXIT_PLAN_MODE , foldPlanMode , resolveConfig } from '../src/index.ts'
import type { PlanModeConfig } from '../src/index.ts'
2026-07-10 01:38:39 +08:00
2026-07-20 22:13:59 +08:00
const TEST_PLAN_SECTION = 'Test plan mode instructions.'
2026-07-22 16:57:23 +08:00
const PLAN_CONFIG = { section : TEST_PLAN_SECTION } satisfies PlanModeConfig
2026-07-20 22:13:59 +08:00
2026-07-10 01:38:39 +08:00
/ * *
2026-07-22 16:57:23 +08:00
* Drives the REAL plugin : mounts ` dsh-plan-mode ` beside real ` SystemPrompt ` and
2026-07-21 11:11:35 +08:00
* ` ToolRegistry ` services , with fake Agents carrying real ` Session ` s and a
2026-07-21 11:43:54 +08:00
* real scoped ` agent.ctx ` minted through ` createScope ` .
2026-07-21 11:11:35 +08:00
* Turn boundaries are simulated by appending the real boundary events and
2026-07-21 11:43:54 +08:00
* dispatching the interception seams the loop fires there . Recovery retries
* exercise the separate ` agent/request-error ` wrapper .
2026-07-10 01:38:39 +08:00
* /
2026-07-22 16:57:23 +08:00
async function agentWithSession ( ctx : Context , id = 'agent-1' , { active } : { active? : boolean } = { } ) : Promise < Agent & { session : Session } > {
2026-07-10 01:38:39 +08:00
const session = new Session ( SessionId ( id ) )
2026-07-21 11:11:35 +08:00
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
2026-07-22 16:57:23 +08:00
// Seeded plan state lands before the creation announcement, matching resume.
if ( active !== undefined ) session . append ( 'plan/mode' , { active } )
2026-07-21 11:43:54 +08:00
// The loop announces creation after publication.
2026-07-21 11:11:35 +08:00
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 } )
2026-07-10 01:38:39 +08:00
}
2026-07-22 16:57:23 +08:00
async function setup ( config : PlanModeConfig = PLAN_CONFIG ) : Promise < Context > {
2026-07-10 01:38:39 +08:00
const ctx = new Context ( )
await ctx . plugin ( SystemPrompt )
await ctx . plugin ( ToolRegistry )
2026-07-22 16:57:23 +08:00
await ctx . plugin ( PlanModeService , config )
2026-07-10 01:38:39 +08:00
return ctx
}
2026-07-13 15:12:11 +08:00
/ * *
* Append a boundary event and dispatch the interception seam the loop fires
* there — ` agent/prompt-submit ` inside the just - opened turn ,
2026-07-20 22:52:24 +08:00
* ` 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 .
2026-07-13 15:12:11 +08:00
* /
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' } } } )
2026-07-22 02:13:39 +08:00
await events . waterfall ( 'agent/prompt-submit' , [ { type : 'text' , text : 'boundary probe' } ] , { kind : 'user' } , new AbortController ( ) . signal , ( ) = > Promise . resolve ( { kind : 'allow' } ) )
2026-07-13 15:12:11 +08:00
return
}
agent . session . append ( 'step/end' , { turn : 1 , step : 1 } )
2026-07-22 02:13:39 +08:00
await events . waterfall ( 'agent/turn-continuation' , 1 , { action : 'stop' } , new AbortController ( ) . signal , ( ) = > Promise . resolve ( { action : 'stop' } ) )
2026-07-10 01:38:39 +08:00
}
2026-07-20 22:52:24 +08:00
/** 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 ) ,
)
}
2026-07-10 01:38:39 +08:00
/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
function header ( session : Session ) : void {
2026-07-20 23:28:07 +08:00
session . append ( 'request/header' , { header : { config : { provider : 'test' , model : 'test-model' } } , reason : 'initial' } )
2026-07-10 01:38:39 +08:00
}
function noticeTexts ( session : Session ) : string [ ] {
return session . events
2026-07-23 19:15:45 +08:00
. filter ( event = > event . type === 'user/message' && event . data . source . kind === 'plugin' )
2026-07-10 01:38:39 +08:00
. 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 ) {
2026-07-22 21:31:16 +08:00
ctx . tools . register ( defineContentToolFixture ( {
2026-07-10 01:38:39 +08:00
name ,
description : ` test tool ${ name } ` ,
parameters : { } ,
execute : ( ) = > Promise . resolve ( [ { type : 'text' , text : ` ran ${ name } ` } ] ) ,
} ) )
}
}
2026-07-22 21:49:35 +08:00
/** Assert the mapped Code Mode SDK includes the stable plan exit binding and test tools. */
function expectPlanCodeSdkBindings ( sdk : string ) : void {
expect ( sdk ) . toContain ( 'interface ToolArgsMap {' )
expect ( sdk ) . toContain ( 'read: Record<string, JsonValue>;' )
expect ( sdk ) . toContain ( 'write: Record<string, JsonValue>;' )
expect ( sdk ) . toContain ( 'interface ToolOutputMap {' )
expect ( sdk ) . toContain ( 'exit_plan_mode: {\n approved: true;\n };' )
expect ( sdk ) . toContain ( '[K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;' )
}
2026-07-10 01:38:39 +08:00
let callCounter = 0
function execute ( ctx : Context , name : string , agent? : Agent ) {
return ctx . tools . execute ( {
callId : CallId ( ` call- ${ ++ callCounter } ` ) ,
name ,
arguments : { } ,
2026-07-22 02:13:39 +08:00
signal : new AbortController ( ) . signal ,
2026-07-10 01:38:39 +08:00
. . . agent ? { agent } : { } ,
} )
}
describe ( 'resolveConfig' , ( ) = > {
2026-07-22 16:57:23 +08:00
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`' )
2026-07-22 09:58:57 +08:00
} )
2026-07-22 16:57:23 +08:00
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 )
2026-07-22 14:32:35 +08:00
} )
2026-07-22 16:57:23 +08:00
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 }' )
2026-07-12 22:51:09 +08:00
} )
2026-07-10 01:38:39 +08:00
} )
2026-07-22 16:57:23 +08:00
describe ( 'foldPlanMode' , ( ) = > {
it ( 'folds an empty log to inactive and takes the last plan/mode otherwise' , ( ) = > {
2026-07-10 01:38:39 +08:00
const session = new Session ( SessionId ( 'fold' ) )
2026-07-22 16:57:23 +08:00
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 )
2026-07-10 01:38:39 +08:00
} )
it ( 'folds a prefix when `end` is given' , ( ) = > {
const session = new Session ( SessionId ( 'fold-prefix' ) )
2026-07-22 16:57:23 +08:00
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 )
2026-07-10 01:38:39 +08:00
} )
} )
2026-07-22 16:57:23 +08:00
describe ( 'ctx.planMode: get/set' , ( ) = > {
it ( 'reads the folded state' , async ( ) = > {
2026-07-10 01:38:39 +08:00
const ctx = await setup ( )
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
expect ( ctx . planMode . get ( agent ) ) . toEqual ( { active : false } )
agent . session . append ( 'plan/mode' , { active : true } )
expect ( ctx . planMode . get ( agent ) ) . toEqual ( { active : true } )
2026-07-10 01:38:39 +08:00
} )
2026-07-22 16:57:23 +08:00
it ( 'selects inactive as the plan exit target' , async ( ) = > {
2026-07-10 01:38:39 +08:00
const ctx = await setup ( )
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
agent . session . append ( 'plan/mode' , { active : true } )
ctx . planMode . set ( agent , false )
expect ( ctx . planMode . get ( agent ) ) . toEqual ( { active : true , pending : false } )
2026-07-10 01:38:39 +08:00
} )
it ( 'drops a no-op set (target equals pending, else the current fold)' , async ( ) = > {
const ctx = await setup ( )
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
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 } )
2026-07-10 01:38:39 +08:00
} )
} )
describe ( 'the boundary flush' , ( ) = > {
2026-07-22 16:57:23 +08:00
it ( 'flushes the pending intent as a plan/mode at turn/start' , async ( ) = > {
2026-07-10 01:38:39 +08:00
const ctx = await setup ( )
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , true )
2026-07-13 15:12:11 +08:00
await boundary ( ctx , agent , 'turn/start' )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
expect ( ctx . planMode . get ( agent ) ) . toEqual ( { active : true } )
2026-07-10 01:38:39 +08:00
} )
2026-07-22 09:58:57 +08:00
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
2026-07-22 16:57:23 +08:00
// returns. The prepended flush runs after next(), so the plan/mode still
2026-07-22 09:58:57 +08:00
// precedes the request this boundary gates.
ctx . on ( 'agent/turn-continuation' , async ( _agent , _turn , decision , _signal , next ) = > {
await new Promise ( resolve = > setTimeout ( resolve , 5 ) )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , true )
2026-07-22 09:58:57 +08:00
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' } ) ,
)
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
expect ( ctx . planMode . get ( agent ) ) . toEqual ( { active : true } )
2026-07-22 09:58:57 +08:00
} )
2026-07-22 10:12:09 +08:00
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 )
2026-07-22 16:57:23 +08:00
const fiber = await ctx . plugin ( PlanModeService , PLAN_CONFIG )
2026-07-22 10:12:09 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , true )
2026-07-22 10:12:09 +08:00
// 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' } ) ,
)
2026-07-22 16:57:23 +08:00
expect ( agent . session . events . some ( event = > event . type === 'plan/mode' ) ) . toBe ( false )
2026-07-22 10:12:09 +08:00
} )
2026-07-10 01:38:39 +08:00
it ( 'flushes at step/end too (a mid-turn flip lands on the following step)' , async ( ) = > {
const ctx = await setup ( )
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , true )
2026-07-13 15:12:11 +08:00
await boundary ( ctx , agent , 'step/end' )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
2026-07-10 01:38:39 +08:00
} )
2026-07-20 22:52:24 +08:00
it ( 'keeps the pending intent parked when recovery does not retry' , async ( ) = > {
const ctx = await setup ( )
2026-07-21 11:43:54 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , true )
2026-07-20 22:52:24 +08:00
expect ( await recoveryBoundary ( ctx , agent , { action : 'fail' } ) ) . toEqual ( { action : 'fail' } )
2026-07-22 16:57:23 +08:00
expect ( ctx . planMode . get ( agent ) ) . toEqual ( { active : false , pending : true } )
2026-07-20 22:52:24 +08:00
} )
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
2026-07-21 11:43:54 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , true )
2026-07-20 22:52:24 +08:00
const original = agent . session . append . bind ( agent . session )
agent . session . append = ( ( ( type : string , . . . rest : unknown [ ] ) = > {
2026-07-22 16:57:23 +08:00
if ( type === 'plan/mode' ) throw new Error ( 'backend gone' )
2026-07-20 22:52:24 +08:00
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 ( )
2026-07-22 16:57:23 +08:00
expect ( ctx . planMode . get ( agent ) ) . toEqual ( { active : false , pending : true } )
2026-07-20 22:52:24 +08:00
} )
2026-07-10 01:38:39 +08:00
it ( 'nets out a flip sequence that returns to the folded mode (no append, no notice)' , async ( ) = > {
const ctx = await setup ( )
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , true )
ctx . planMode . set ( agent , false )
2026-07-13 15:12:11 +08:00
await boundary ( ctx , agent , 'turn/start' )
2026-07-22 16:57:23 +08:00
expect ( agent . session . events . some ( event = > event . type === 'plan/mode' ) ) . toBe ( false )
2026-07-10 01:38:39 +08:00
expect ( noticeTexts ( agent . session ) ) . toEqual ( [ ] )
} )
it ( 'narrates nothing before the first request header (the section is the state statement)' , async ( ) = > {
const ctx = await setup ( )
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , true )
2026-07-13 15:12:11 +08:00
await boundary ( ctx , agent , 'turn/start' )
2026-07-10 01:38:39 +08:00
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 ( )
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-10 01:38:39 +08:00
header ( agent . session )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , true )
2026-07-13 15:12:11 +08:00
await boundary ( ctx , agent , 'turn/start' )
2026-07-10 01:38:39 +08:00
expect ( noticeTexts ( agent . session ) ) . toEqual ( [ 'The user switched this session to plan mode.' ] )
2026-07-13 15:12:11 +08:00
await boundary ( ctx , agent , 'step/end' )
2026-07-10 01:38:39 +08:00
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 ( )
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
agent . session . append ( 'plan/mode' , { active : true } )
2026-07-10 01:38:39 +08:00
header ( agent . session )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , false )
2026-07-13 15:12:11 +08:00
await boundary ( ctx , agent , 'step/end' )
2026-07-10 01:38:39 +08:00
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 ( )
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
agent . session . append ( 'plan/mode' , { active : true } )
2026-07-10 01:38:39 +08:00
header ( agent . session )
2026-07-22 16:57:23 +08:00
agent . session . append ( 'plan/mode' , { active : false } )
ctx . planMode . set ( agent , true )
2026-07-13 15:12:11 +08:00
await boundary ( ctx , agent , 'step/end' )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
2026-07-10 01:38:39 +08:00
expect ( noticeTexts ( agent . session ) ) . toEqual ( [ ] )
} )
2026-07-20 22:13:59 +08:00
2026-07-13 15:12:11 +08:00
it ( 'contains an append failure instead of blocking the prompt or the turn' , async ( ) = > {
2026-07-10 01:38:39 +08:00
const ctx = await setup ( )
const warn = vi . fn ( )
ctx . logger . warn = warn as never
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , true )
2026-07-10 01:38:39 +08:00
const original = agent . session . append . bind ( agent . session )
2026-07-22 16:57:23 +08:00
// Only the flush's own plan/mode append fails; the boundary event itself
2026-07-13 15:12:11 +08:00
// lands (the loop appended it before the seam fires).
agent . session . append = ( ( ( type : string , . . . rest : unknown [ ] ) = > {
2026-07-22 16:57:23 +08:00
if ( type === 'plan/mode' ) throw new Error ( 'backend gone' )
2026-07-13 15:12:11 +08:00
return ( original as ( . . . args : unknown [ ] ) = > unknown ) ( type , . . . rest )
} ) as unknown ) as typeof agent . session . append
await boundary ( ctx , agent , 'step/end' )
2026-07-10 01:38:39 +08:00
expect ( warn ) . toHaveBeenCalledOnce ( )
2026-07-10 16:28:11 +08:00
// 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.
2026-07-22 16:57:23 +08:00
expect ( ctx . planMode . get ( agent ) ) . toEqual ( { active : false , pending : true } )
2026-07-10 16:28:11 +08:00
agent . session . append = original
2026-07-13 15:12:11 +08:00
await boundary ( ctx , agent , 'step/end' )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
expect ( ctx . planMode . get ( agent ) . pending ) . toBeUndefined ( )
2026-07-10 01:38:39 +08:00
} )
2026-07-13 15:12:11 +08:00
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
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , true )
2026-07-13 15:12:11 +08:00
const original = agent . session . append . bind ( agent . session )
agent . session . append = ( ( ( type : string , . . . rest : unknown [ ] ) = > {
2026-07-22 16:57:23 +08:00
if ( type === 'plan/mode' ) throw new Error ( 'backend gone' )
2026-07-13 15:12:11 +08:00
return ( original as ( . . . args : unknown [ ] ) = > unknown ) ( type , . . . rest )
} ) as unknown ) as typeof agent . session . append
await boundary ( ctx , agent , 'turn/start' )
expect ( warn ) . toHaveBeenCalledOnce ( )
2026-07-22 16:57:23 +08:00
expect ( ctx . planMode . get ( agent ) ) . toEqual ( { active : false , pending : true } )
2026-07-13 15:12:11 +08:00
} )
2026-07-10 01:38:39 +08:00
} )
describe ( 'the soft layer' , ( ) = > {
2026-07-20 22:13:59 +08:00
it ( 'keeps the tool schemas identical across default and plan mode' , async ( ) = > {
2026-07-10 01:38:39 +08:00
const ctx = await setup ( )
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.
2026-07-10 02:57:40 +08:00
registerNamedTools ( ctx , [ 'read' , 'write' ] )
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-21 11:43:54 +08:00
const defaultAssembly = await assembleFor ( ctx , agent )
2026-07-20 22:13:59 +08:00
expect ( defaultAssembly . tools . map ( tool = > tool . name ) ) . toEqual ( [ EXIT_PLAN_MODE , 'read' , 'write' ] )
2026-07-22 16:57:23 +08:00
expect ( defaultAssembly . sections . find ( section = > section . name === 'plan:policy' ) ? . text ) . toBe ( '' )
2026-07-20 22:13:59 +08:00
2026-07-22 16:57:23 +08:00
agent . session . append ( 'plan/mode' , { active : true } )
2026-07-21 11:43:54 +08:00
const planAssembly = await assembleFor ( ctx , agent )
2026-07-20 22:13:59 +08:00
expect ( planAssembly . tools ) . toEqual ( defaultAssembly . tools )
2026-07-22 16:57:23 +08:00
expect ( planAssembly . sections . find ( section = > section . name === 'plan:policy' ) ? . text ) . toBe ( TEST_PLAN_SECTION )
2026-07-10 01:38:39 +08:00
} )
it ( 'leaves an agent-less assembly untouched' , async ( ) = > {
const ctx = await setup ( )
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.
2026-07-10 02:57:40 +08:00
registerNamedTools ( ctx , [ 'read' ] )
2026-07-10 01:38:39 +08:00
const assembly = await ctx . systemPrompt . assemble ( )
expect ( assembly . tools . map ( tool = > tool . name ) ) . toEqual ( [ EXIT_PLAN_MODE , 'read' ] )
2026-07-22 16:57:23 +08:00
expect ( assembly . sections . find ( section = > section . name === 'plan:policy' ) ? . text ) . toBe ( '' )
2026-07-10 01:38:39 +08:00
} )
2026-07-20 22:13:59 +08:00
it ( 'keeps the full toolset in plan mode and renders the configured mode section' , async ( ) = > {
2026-07-10 01:38:39 +08:00
const ctx = await setup ( )
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.
2026-07-10 02:57:40 +08:00
registerNamedTools ( ctx , [ 'read' , 'write' , 'todo_write' ] )
2026-07-22 16:57:23 +08:00
const agent = await agentWithSession ( ctx , 'agent-1' , { active : true } )
2026-07-21 11:11:35 +08:00
const assembly = await assembleFor ( ctx , agent )
2026-07-12 23:20:36 +08:00
expect ( assembly . tools . map ( tool = > tool . name ) . sort ( ) ) . toEqual ( [ EXIT_PLAN_MODE , 'read' , 'todo_write' , 'write' ] )
2026-07-22 16:57:23 +08:00
expect ( assembly . sections . find ( section = > section . name === 'plan:policy' ) ? . text ) . toBe ( TEST_PLAN_SECTION )
2026-07-10 01:38:39 +08:00
} )
2026-07-22 16:57:23 +08:00
it ( 'leaves foreign assemble additions alone (no assemble-layer filtering)' , async ( ) = > {
// Plan guidance does not filter the registry or later assembly additions.
fix(mode): prepend the assemble filter; structured_output joins the plan allowlist
Review finding with a real in-repo instance: the structured runtime's
per-spawn final-assembly wrapper (prepend, post-next) re-injects
structured_output OUTSIDE the mode filter, so a structured child in
plan mode would see a tool the gate then denies — the soft policy and
the hard gate telling different stories. The suggested fix (make the
mode filter outermost) cannot beat that instance: prepend unshifts, so
the per-spawn listener always registers later and wraps outer.
Two-part resolution instead. Semantically, structured_output enters the
shipped plan allowlist — it is a child's pure result channel, the same
ask/report class as ask_user_question and exit_plan_mode, so the
filter, the re-injection, and the gate now agree wherever a structured
child runs in plan mode. Mechanically, the filter registers with
prepend anyway: it now wraps outside every append-registered listener
regardless of load order (regression test pins a pre-registered
post-next mutator being filtered), narrowing the documented cosmetic
residual to prepend-after-load listeners only, where the gate still
covers execution. Severity note: no execution breach existed — the gate
held throughout; this closes the prompt-honesty gap.
2026-07-10 18:35:44 +08:00
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 ( )
2026-07-12 23:20:36 +08:00
final . tools = [ . . . final . tools , { name : 'added-later' , description : 'added after next()' , parameters : { } } ]
fix(mode): prepend the assemble filter; structured_output joins the plan allowlist
Review finding with a real in-repo instance: the structured runtime's
per-spawn final-assembly wrapper (prepend, post-next) re-injects
structured_output OUTSIDE the mode filter, so a structured child in
plan mode would see a tool the gate then denies — the soft policy and
the hard gate telling different stories. The suggested fix (make the
mode filter outermost) cannot beat that instance: prepend unshifts, so
the per-spawn listener always registers later and wraps outer.
Two-part resolution instead. Semantically, structured_output enters the
shipped plan allowlist — it is a child's pure result channel, the same
ask/report class as ask_user_question and exit_plan_mode, so the
filter, the re-injection, and the gate now agree wherever a structured
child runs in plan mode. Mechanically, the filter registers with
prepend anyway: it now wraps outside every append-registered listener
regardless of load order (regression test pins a pre-registered
post-next mutator being filtered), narrowing the documented cosmetic
residual to prepend-after-load listeners only, where the gate still
covers execution. Severity note: no execution breach existed — the gate
held throughout; this closes the prompt-honesty gap.
2026-07-10 18:35:44 +08:00
return final
} )
2026-07-22 16:57:23 +08:00
await ctx . plugin ( PlanModeService , PLAN_CONFIG )
fix(mode): prepend the assemble filter; structured_output joins the plan allowlist
Review finding with a real in-repo instance: the structured runtime's
per-spawn final-assembly wrapper (prepend, post-next) re-injects
structured_output OUTSIDE the mode filter, so a structured child in
plan mode would see a tool the gate then denies — the soft policy and
the hard gate telling different stories. The suggested fix (make the
mode filter outermost) cannot beat that instance: prepend unshifts, so
the per-spawn listener always registers later and wraps outer.
Two-part resolution instead. Semantically, structured_output enters the
shipped plan allowlist — it is a child's pure result channel, the same
ask/report class as ask_user_question and exit_plan_mode, so the
filter, the re-injection, and the gate now agree wherever a structured
child runs in plan mode. Mechanically, the filter registers with
prepend anyway: it now wraps outside every append-registered listener
regardless of load order (regression test pins a pre-registered
post-next mutator being filtered), narrowing the documented cosmetic
residual to prepend-after-load listeners only, where the gate still
covers execution. Severity note: no execution breach existed — the gate
held throughout; this closes the prompt-honesty gap.
2026-07-10 18:35:44 +08:00
registerNamedTools ( ctx , [ 'read' ] )
2026-07-22 16:57:23 +08:00
const planning = await agentWithSession ( ctx , 'planning' , { active : true } )
2026-07-21 11:11:35 +08:00
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 ) )
2026-07-21 11:43:54 +08:00
. toEqual ( [ 'exit_plan_mode' , 'read' , 'added-later' ] )
fix(mode): prepend the assemble filter; structured_output joins the plan allowlist
Review finding with a real in-repo instance: the structured runtime's
per-spawn final-assembly wrapper (prepend, post-next) re-injects
structured_output OUTSIDE the mode filter, so a structured child in
plan mode would see a tool the gate then denies — the soft policy and
the hard gate telling different stories. The suggested fix (make the
mode filter outermost) cannot beat that instance: prepend unshifts, so
the per-spawn listener always registers later and wraps outer.
Two-part resolution instead. Semantically, structured_output enters the
shipped plan allowlist — it is a child's pure result channel, the same
ask/report class as ask_user_question and exit_plan_mode, so the
filter, the re-injection, and the gate now agree wherever a structured
child runs in plan mode. Mechanically, the filter registers with
prepend anyway: it now wraps outside every append-registered listener
regardless of load order (regression test pins a pre-registered
post-next mutator being filtered), narrowing the documented cosmetic
residual to prepend-after-load listeners only, where the gate still
covers execution. Severity note: no execution breach existed — the gate
held throughout; this closes the prompt-honesty gap.
2026-07-10 18:35:44 +08:00
} )
2026-07-12 23:20:36 +08:00
it ( 'keeps run_code the only wire tool in plan mode under the registry Code Mode; the SDK gains the exit binding' , async ( ) = > {
2026-07-10 20:26:59 +08:00
// 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 )
2026-07-22 16:57:23 +08:00
await ctx . plugin ( PlanModeService , PLAN_CONFIG )
2026-07-10 20:26:59 +08:00
registerNamedTools ( ctx , [ 'read' , 'write' ] )
2026-07-22 16:57:23 +08:00
const agent = await agentWithSession ( ctx , 'agent-1' , { active : true } )
2026-07-21 11:11:35 +08:00
const assembly = await assembleFor ( ctx , agent )
2026-07-10 20:26:59 +08:00
expect ( assembly . tools . map ( tool = > tool . name ) ) . toEqual ( [ 'run_code' ] )
2026-07-22 16:57:23 +08:00
// The SDK documents the full binding set plus the exit; plan mode never
// prunes capabilities and restrains through guidance alone.
2026-07-10 21:09:28 +08:00
const sdk = assembly . sections . find ( section = > section . name === 'tools:sdk' ) ? . text ? ? ''
2026-07-22 21:49:35 +08:00
expectPlanCodeSdkBindings ( sdk )
2026-07-10 21:09:28 +08:00
} )
2026-07-12 23:20:36 +08:00
it ( 'keeps native wire schemas and the SDK in step under mode both' , async ( ) = > {
2026-07-10 21:24:07 +08:00
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 )
2026-07-22 16:57:23 +08:00
await ctx . plugin ( PlanModeService , PLAN_CONFIG )
2026-07-10 21:24:07 +08:00
registerNamedTools ( ctx , [ 'read' , 'write' ] )
2026-07-22 16:57:23 +08:00
const agent = await agentWithSession ( ctx , 'agent-1' , { active : true } )
2026-07-21 11:11:35 +08:00
const assembly = await assembleFor ( ctx , agent )
2026-07-20 22:13:59 +08:00
// The stable registry contribution reaches both surfaces: the exit tool
// is present on the wire AND in the SDK alongside the untouched toolset.
2026-07-12 23:20:36 +08:00
expect ( assembly . tools . map ( tool = > tool . name ) . sort ( ) ) . toEqual ( [ 'exit_plan_mode' , 'read' , 'run_code' , 'write' ] )
2026-07-10 21:24:07 +08:00
const sdk = assembly . sections . find ( section = > section . name === 'tools:sdk' ) ? . text ? ? ''
2026-07-22 21:49:35 +08:00
expectPlanCodeSdkBindings ( sdk )
2026-07-10 21:24:07 +08:00
} )
2026-07-20 22:13:59 +08:00
it ( 'keeps the Code Mode SDK byte-identical across mode switches' , async ( ) = > {
2026-07-10 21:09:28 +08:00
class FakeRuntime extends CodeRuntime {
readonly language = 'typescript'
readonly isolation = 'fake'
run ( _request : CodeRunRequest ) : Promise < CodeRunResult > { return Promise . resolve ( { logs : [ ] } ) }
}
2026-07-22 16:57:23 +08:00
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 ? ? ''
2026-07-22 21:49:35 +08:00
expectPlanCodeSdkBindings ( defaultSdk )
2026-07-22 16:57:23 +08:00
agent . session . append ( 'plan/mode' , { active : true } )
const planSdk = ( await assembleFor ( withPlanMode , agent ) ) . sections . find ( section = > section . name === 'tools:sdk' ) ? . text ? ? ''
2026-07-20 22:13:59 +08:00
expect ( planSdk ) . toBe ( defaultSdk )
2026-07-22 16:57:23 +08:00
// Loading the plan-mode plugin deliberately adds one stable binding compared
2026-07-20 22:13:59 +08:00
// with a deployment that does not compose plan mode at all.
2026-07-10 22:09:36 +08:00
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 ? ? ''
2026-07-22 21:49:35 +08:00
expect ( bareSdk ) . not . toContain ( 'exit_plan_mode:' )
2026-07-20 22:13:59 +08:00
expect ( defaultSdk ) . not . toBe ( bareSdk )
2026-07-10 20:26:59 +08:00
} )
2026-07-10 01:38:39 +08:00
} )
2026-07-21 11:11:35 +08:00
describe ( 'no execution gating beyond the exit tool' , ( ) = > {
2026-07-10 01:38:39 +08:00
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 )
2026-07-21 11:11:35 +08:00
const agent = await agentWithSession ( ctx )
2026-07-10 01:38:39 +08:00
const defaulted = await execute ( ctx , 'write' , agent )
expect ( defaulted . isError ) . toBe ( false )
} )
2026-07-22 16:57:23 +08:00
it ( 'runs every call in plan mode untouched — guidance and enforcement are separate axes' , async ( ) = > {
2026-07-10 01:38:39 +08:00
const ctx = await setup ( )
2026-07-20 13:34:30 +08:00
registerNamedTools ( ctx , [ 'read' , 'write' , 'bash' ] )
2026-07-22 16:57:23 +08:00
const agent = await agentWithSession ( ctx , 'agent-1' , { active : true } )
2026-07-20 13:34:30 +08:00
for ( const name of [ 'read' , 'write' , 'bash' ] ) {
const result = await execute ( ctx , name , agent )
expect ( result . isError ) . toBe ( false )
}
2026-07-10 01:38:39 +08:00
} )
2026-07-21 11:11:35 +08:00
} )
2026-07-22 16:57:23 +08:00
describe ( '/plan' , ( ) = > {
it ( 'registers only when a commands service is composed and optionally submits the next-step message' , async ( ) = > {
2026-07-21 11:11:35 +08:00
const bare = await setup ( )
expect ( bare . get ( 'commands' ) ) . toBeUndefined ( )
2026-07-22 16:57:23 +08:00
const ctx = await setup ( )
2026-07-21 11:11:35 +08:00
await ctx . plugin ( CommandService )
// The `ctx.inject` child mounts asynchronously once `commands` resolves.
await new Promise ( resolve = > setImmediate ( resolve ) )
2026-07-22 16:57:23 +08:00
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 ( [
2026-07-23 21:06:03 +08:00
{ name : 'plan' , description : 'Enter or leave plan mode' , input : { hint : '[off|message]' } } ,
2026-07-22 14:32:35 +08:00
] )
2026-07-21 11:11:35 +08:00
const signal = new AbortController ( ) . signal
2026-07-22 16:57:23 +08:00
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 )
2026-07-23 21:34:40 +08:00
expect ( plain ) . toEqual ( {
kind : 'success' ,
text : 'Entering plan mode (applies from the next step). Use /plan off to leave.' ,
} )
2026-07-22 16:57:23 +08:00
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 )
2026-07-23 21:34:40 +08:00
expect ( plan ) . toEqual ( {
kind : 'success' ,
text : 'Entering plan mode (applies from the next step). Use /plan off to leave.' ,
} )
2026-07-22 16:57:23 +08:00
expect ( ctx . planMode . get ( messageAgent ) ) . toEqual ( { active : false , pending : true } )
expect ( messageSteer ) . toHaveBeenCalledExactlyOnceWith ( [ { type : 'text' , text : 'draft the migration' } ] )
2026-07-22 14:32:35 +08:00
} )
2026-07-23 21:06:03 +08:00
it ( 'leaves active plan mode, cancels a pending entry, and treats inactive exit as idempotent' , async ( ) = > {
const ctx = await setup ( )
await ctx . plugin ( CommandService )
await new Promise ( resolve = > setImmediate ( resolve ) )
const signal = new AbortController ( ) . signal
const inactive = await agentWithSession ( ctx , 'inactive-plan-command' )
expect ( await ctx . commands . execute ( inactive , '/plan off' , signal ) )
. toEqual ( { kind : 'success' , text : 'Plan mode is already inactive.' } )
expect ( ctx . planMode . get ( inactive ) ) . toEqual ( { active : false } )
const entering = await agentWithSession ( ctx , 'entering-plan-command' )
const enteringSteer = vi . fn ( )
; ( entering as unknown as { steer : typeof enteringSteer } ) . steer = enteringSteer
await ctx . commands . execute ( entering , '/plan' , signal )
expect ( await ctx . commands . execute ( entering , '/plan off' , signal ) )
. toEqual ( { kind : 'success' , text : 'Plan mode entry cancelled.' } )
expect ( ctx . planMode . get ( entering ) ) . toEqual ( { active : false , pending : false } )
expect ( enteringSteer ) . not . toHaveBeenCalled ( )
await boundary ( ctx , entering , 'turn/start' )
expect ( ctx . planMode . get ( entering ) ) . toEqual ( { active : false } )
expect ( entering . session . events . some ( event = > event . type === 'plan/mode' ) ) . toBe ( false )
const active = await agentWithSession ( ctx , 'active-plan-command' , { active : true } )
const activeSteer = vi . fn ( )
; ( active as unknown as { steer : typeof activeSteer } ) . steer = activeSteer
expect ( await ctx . commands . execute ( active , '/plan off' , signal ) )
. toEqual ( { kind : 'success' , text : 'Leaving plan mode (applies from the next step).' } )
expect ( ctx . planMode . get ( active ) ) . toEqual ( { active : true , pending : false } )
expect ( await ctx . commands . execute ( active , '/plan off' , signal ) )
. toEqual ( { kind : 'success' , text : 'Leaving plan mode (applies from the next step).' } )
expect ( activeSteer ) . not . toHaveBeenCalled ( )
await boundary ( ctx , active , 'turn/start' )
expect ( ctx . planMode . get ( active ) ) . toEqual ( { active : false } )
} )
2026-07-22 16:57:23 +08:00
it ( 'removes the contributed command when the plan-mode plugin is disposed' , async ( ) = > {
2026-07-22 14:32:35 +08:00
const ctx = new Context ( )
await ctx . plugin ( SystemPrompt )
await ctx . plugin ( ToolRegistry )
await ctx . plugin ( CommandService )
2026-07-22 16:57:23 +08:00
const fiber = await ctx . plugin ( PlanModeService , PLAN_CONFIG )
2026-07-22 14:32:35 +08:00
await new Promise ( resolve = > setImmediate ( resolve ) )
const agent = await agentWithSession ( ctx )
2026-07-22 16:57:23 +08:00
expect ( ctx . commands . list ( agent ) . map ( command = > command . name ) ) . toEqual ( [ 'plan' ] )
2026-07-22 14:32:35 +08:00
await fiber . dispose ( )
2026-07-21 11:11:35 +08:00
2026-07-22 14:32:35 +08:00
expect ( ctx . commands . list ( agent ) ) . toEqual ( [ ] )
2026-07-21 11:11:35 +08:00
} )
2026-07-10 01:38:39 +08:00
} )
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.
2026-07-10 02:57:40 +08:00
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 } ] } )
} ,
} )
}
2026-07-22 16:57:23 +08:00
const agent = await agentWithSession ( ctx , 'agent-1' , { active : true } )
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.
2026-07-10 02:57:40 +08:00
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 } ,
2026-07-22 02:13:39 +08:00
signal : new AbortController ( ) . signal ,
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.
2026-07-10 02:57:40 +08:00
. . . 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 > }
2026-07-20 22:13:59 +08:00
expect ( schema ? . description ) . toMatch ( /^Use only in plan mode\./ )
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.
2026-07-10 02:57:40 +08:00
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)' } ] )
} )
2026-07-20 22:13:59 +08:00
it ( 'rejects a call outside plan mode while remaining advertised' , async ( ) = > {
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.
2026-07-10 02:57:40 +08:00
const ctx = await setup ( )
2026-07-21 11:43:54 +08:00
const agent = await agentWithSession ( ctx )
2026-07-20 22:13:59 +08:00
expect ( ctx . tools . schemas ( ) . map ( tool = > tool . name ) ) . toContain ( EXIT_PLAN_MODE )
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.
2026-07-10 02:57:40 +08:00
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' } ] )
} )
2026-07-20 22:13:59 +08:00
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 )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
2026-07-20 22:13:59 +08:00
} )
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.
2026-07-10 02:57:40 +08:00
it ( 'degrades to the manual exit when no user-interaction seam is composed' , async ( ) = > {
const ctx = await setup ( )
2026-07-22 16:57:23 +08:00
const agent = await agentWithSession ( ctx , 'agent-1' , { active : true } )
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.
2026-07-10 02:57:40 +08:00
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' } ] )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
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.
2026-07-10 02:57:40 +08:00
} )
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' } ] )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
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.
2026-07-10 02:57:40 +08:00
} )
2026-07-10 10:35:43 +08:00
it ( 'approve: records the boundary-applied switch and confirms (the fold flips at the flush)' , async ( ) = > {
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.
2026-07-10 02:57:40 +08:00
const { ctx , agent , asked } = await setupWithReview ( { selected : [ 'Approve' ] } )
const result = await callExit ( ctx , agent )
expect ( result . isError ) . toBe ( false )
2026-07-22 21:31:16 +08:00
if ( result . isError ) throw new Error ( 'expected approved plan result' )
expect ( result . value ) . toEqual ( { approved : true } )
2026-07-12 23:20:36 +08:00
expect ( result . content ) . toEqual ( [ { type : 'text' , text : 'Plan approved — plan mode exited; carry out the plan starting with your next step.' } ] )
2026-07-10 10:35:43 +08:00
// Boundary-applied, not a direct append: the fold stays plan until the
2026-07-12 23:20:36 +08:00
// step's end, so the plan policy covers any remaining call of the SAME batch.
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
expect ( ctx . planMode . get ( agent ) ) . toEqual ( { active : true , pending : false } )
2026-07-13 15:12:11 +08:00
await boundary ( ctx , agent , 'step/end' )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( false )
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.
2026-07-10 02:57:40 +08:00
expect ( asked ) . toHaveLength ( 1 )
expect ( asked [ 0 ] ? . agent ) . toBe ( agent )
2026-07-20 22:13:59 +08:00
expect ( asked [ 0 ] ? . questions [ 0 ] ? . detail ) . toBe ( '# The plan\n\ndo things' )
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.
2026-07-10 02:57:40 +08:00
expect ( asked [ 0 ] ? . questions [ 0 ] ? . options ? . map ( option = > option . label ) ) . toEqual ( [ 'Approve' , 'Keep planning' ] )
} )
2026-07-20 22:13:59 +08:00
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 )
2026-07-22 16:57:23 +08:00
await ctx . plugin ( PlanModeService , PLAN_CONFIG )
2026-07-20 22:13:59 +08:00
await ctx . plugin ( UserInteractionService )
const asked : AskUserQuestionRequest [ ] = [ ]
ctx . userInteraction . registerProvider ( {
ask : ( request ) = > {
asked . push ( request )
return Promise . resolve ( { answers : [ { id : 'plan-review' , selected : [ 'Approve' ] } ] } )
} ,
} )
2026-07-22 16:57:23 +08:00
const agent = await agentWithSession ( ctx , 'code-mode-exit' , { active : true } )
2026-07-20 22:13:59 +08:00
const result = await ctx . tools . execute ( {
callId : CallId ( ` call-exit- ${ ++ callCounter } ` ) ,
name : RUN_CODE_NAME ,
2026-07-26 02:43:34 +08:00
arguments : { code : ` return await tools. ${ EXIT_PLAN_MODE } ({ plan: ${ JSON . stringify ( plan ) } }) ` , description : 'Submit the plan for review' } ,
2026-07-22 02:13:39 +08:00
signal : new AbortController ( ) . signal ,
2026-07-20 22:13:59 +08:00
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 ,
} )
2026-07-22 16:57:23 +08:00
expect ( ctx . planMode . get ( agent ) ) . toEqual ( { active : true , pending : false } )
2026-07-20 22:13:59 +08:00
} )
it ( 'an approved exit keeps plan guidance until the boundary and never removes the tool' , async ( ) = > {
2026-07-10 10:35:43 +08:00
const { ctx , agent } = await setupWithReview ( { selected : [ 'Approve' ] } )
const approved = await callExit ( ctx , agent )
expect ( approved . isError ) . toBe ( false )
2026-07-20 13:34:30 +08:00
// 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.
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
2026-07-20 13:34:30 +08:00
const assembly = await ctx . systemPrompt . assemble ( { agent } )
expect ( assembly . tools . some ( tool = > tool . name === EXIT_PLAN_MODE ) ) . toBe ( true )
2026-07-22 16:57:23 +08:00
expect ( assembly . sections . find ( section = > section . name === 'plan:policy' ) ? . text ) . toBe ( TEST_PLAN_SECTION )
2026-07-13 15:12:11 +08:00
await boundary ( ctx , agent , 'step/end' )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( false )
2026-07-20 22:13:59 +08:00
const afterExit = await ctx . systemPrompt . assemble ( { agent } )
expect ( afterExit . tools ) . toEqual ( assembly . tools )
2026-07-22 16:57:23 +08:00
expect ( afterExit . sections . find ( section = > section . name === 'plan:policy' ) ? . text ) . toBe ( '' )
2026-07-10 10:35:43 +08:00
} )
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 )
2026-07-13 15:12:11 +08:00
await boundary ( ctx , agent , 'step/end' )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( false )
2026-07-10 10:35:43 +08:00
expect ( noticeTexts ( agent . session ) ) . toEqual ( [ ] )
} )
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.
2026-07-10 02:57:40 +08:00
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' } ] )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
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.
2026-07-10 02:57:40 +08:00
} )
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' } ] )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
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.
2026-07-10 02:57:40 +08:00
} )
2026-07-20 22:13:59 +08:00
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.' } ] )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
2026-07-20 22:13:59 +08:00
} )
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' } ] )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
2026-07-20 22:13:59 +08:00
} )
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.' } ] )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
2026-07-20 22:13:59 +08:00
} )
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.
2026-07-10 02:57:40 +08:00
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 )
} )
2026-07-22 09:58:57 +08:00
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 )
2026-07-22 16:57:23 +08:00
const fiber = await ctx . plugin ( PlanModeService , PLAN_CONFIG )
2026-07-22 09:58:57 +08:00
await ctx . plugin ( UserInteractionService )
let answer ! : ( value : { answers : { id : string ; selected : string [ ] } [ ] } ) = > void
ctx . userInteraction . registerProvider ( {
ask : ( ) = > new Promise ( ( resolve ) = > { answer = resolve } ) ,
} )
2026-07-22 16:57:23 +08:00
const agent = await agentWithSession ( ctx , 'agent-1' , { active : true } )
2026-07-22 09:58:57 +08:00
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 )
2026-07-22 16:57:23 +08:00
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 )
2026-07-22 09:58:57 +08:00
} )
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.
2026-07-10 02:57:40 +08:00
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' } ] )
2026-07-22 16:57:23 +08:00
expect ( foldPlanMode ( agent . session . events ) ) . toBe ( true )
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.
2026-07-10 02:57:40 +08:00
} )
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 ,
} )
} )
} )
2026-07-20 22:13:59 +08:00
describe ( 'HMR disposal' , ( ) = > {
2026-07-20 23:00:27 +08:00
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 )
2026-07-22 16:57:23 +08:00
const fiber = await ctx . plugin ( PlanModeService , PLAN_CONFIG )
2026-07-21 11:43:54 +08:00
const agent = await agentWithSession ( ctx , 'disposed-in-flight-recovery' )
2026-07-20 23:00:27 +08:00
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' }
} )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , true )
2026-07-20 23:00:27 +08:00
const recovery = recoveryBoundary ( ctx , agent , { action : 'fail' } )
await recoveryEntered . promise
await fiber . dispose ( )
releaseRecovery . resolve ( true )
expect ( await recovery ) . toEqual ( { action : 'retry' } )
2026-07-22 16:57:23 +08:00
expect ( agent . session . events . some ( event = > event . type === 'plan/mode' ) ) . toBe ( false )
2026-07-20 23:00:27 +08:00
} )
2026-07-20 22:52:24 +08:00
it ( 'unregisters the service, listeners, prompt section, and stable exit tool with the plugin fiber' , async ( ) = > {
2026-07-20 22:13:59 +08:00
const ctx = new Context ( )
await ctx . plugin ( SystemPrompt )
await ctx . plugin ( ToolRegistry )
2026-07-22 16:57:23 +08:00
const fiber = await ctx . plugin ( PlanModeService , PLAN_CONFIG )
2026-07-21 11:43:54 +08:00
const agent = await agentWithSession ( ctx , 'disposed-recovery' )
2026-07-22 16:57:23 +08:00
ctx . planMode . set ( agent , true )
expect ( ctx . get ( 'planMode' ) ) . toBeInstanceOf ( PlanModeService )
2026-07-20 22:13:59 +08:00
expect ( ctx . tools . get ( EXIT_PLAN_MODE ) ) . toBeDefined ( )
2026-07-22 16:57:23 +08:00
expect ( ( await ctx . systemPrompt . assemble ( ) ) . sections . map ( section = > section . name ) ) . toContain ( 'plan:policy' )
2026-07-20 22:13:59 +08:00
await fiber . dispose ( )
2026-07-22 16:57:23 +08:00
expect ( ctx . get ( 'planMode' ) ) . toBeUndefined ( )
2026-07-20 22:13:59 +08:00
expect ( ctx . tools . get ( EXIT_PLAN_MODE ) ) . toBeUndefined ( )
2026-07-22 16:57:23 +08:00
expect ( ( await ctx . systemPrompt . assemble ( ) ) . sections . map ( section = > section . name ) ) . not . toContain ( 'plan:policy' )
2026-07-20 22:52:24 +08:00
expect ( await recoveryBoundary ( ctx , agent , { action : 'retry' } ) ) . toEqual ( { action : 'retry' } )
2026-07-22 16:57:23 +08:00
expect ( agent . session . events . some ( event = > event . type === 'plan/mode' ) ) . toBe ( false )
2026-07-20 22:13:59 +08:00
} )
} )