plan-mode.spec.ts 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
  4. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  5. import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  6. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  7. import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
  8. import { createScope } from '@deepseek-ai/dsh-scope'
  9. import UserInteractionService, {
  10. UserInteractionError, type AskUserQuestionRequest,
  11. } from '@deepseek-ai/dsh-user-interaction'
  12. import CommandService from '@deepseek-ai/dsh-commands'
  13. import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
  14. import PlanModeService, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts'
  15. import type { PlanModeConfig } from '../src/index.ts'
  16. const TEST_PLAN_SECTION = 'Test plan mode instructions.'
  17. const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
  18. /**
  19. * Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and
  20. * `ToolRegistry` services, with fake Agents carrying real `Session`s and a
  21. * real scoped `agent.ctx` minted through `createScope`.
  22. * Request boundaries are simulated by dispatching the real prompt-admission
  23. * and between-step seams used by the loop.
  24. */
  25. async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
  26. // A live store session when a store is mounted (the command executor logs
  27. // lifecycle events through it); bare otherwise (fold/tool-only benches).
  28. const session = Session.create(SessionId(id))
  29. const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
  30. let scoped!: Context
  31. await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
  32. inject: ['tools'],
  33. }))
  34. ;(agent as { ctx?: Context }).ctx = scoped
  35. // Seeded plan state lands before the creation announcement, matching resume.
  36. if (active !== undefined) session.append('plan/mode', { active })
  37. // The loop announces creation after publication.
  38. ctx.emit('agent/created', agent)
  39. return agent
  40. }
  41. /** Assemble exactly as the loop does: the agent is both subject and scope. */
  42. function assembleFor(ctx: Context, agent: Agent) {
  43. return ctx.systemPrompt.assemble({ agent, scope: agent })
  44. }
  45. async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
  46. const ctx = new Context()
  47. await ctx.plugin(SystemPrompt)
  48. await ctx.plugin(ToolRegistry)
  49. await ctx.plugin(PlanModeService, config)
  50. return ctx
  51. }
  52. /**
  53. * Dispatch either prompt admission or the between-step checkpoint.
  54. */
  55. async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'turn/start' | 'step/end'): Promise<void> {
  56. const events = agentEvents(ctx, agent)
  57. if (type === 'turn/start') {
  58. await events.waterfall(
  59. 'agent/prompt-submit',
  60. createUserMessage({
  61. content: [{ type: 'text', text: 'boundary probe' }],
  62. source: { kind: 'user' },
  63. }),
  64. new AbortController().signal,
  65. () => Promise.resolve({ kind: 'allow' }),
  66. )
  67. return
  68. }
  69. await events.serial('agent/step', 1, 2, new AbortController().signal)
  70. }
  71. /** Open a turn so a selection queues for the boundary flush (the mid-turn shape). */
  72. function openTurn(session: Session, turn = 0): void {
  73. session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
  74. }
  75. /** Close the open turn (the between-turns shape: selections commit immediately). */
  76. function closeTurn(session: Session, turn = 0): void {
  77. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  78. }
  79. /** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
  80. function header(session: Session): void {
  81. session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'initial' })
  82. }
  83. function noticeTexts(session: Session): string[] {
  84. return session.events
  85. .filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
  86. .map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join(''))
  87. }
  88. function registerNamedTools(ctx: Context, names: string[]): void {
  89. for (const name of names) {
  90. ctx.tools.register(defineContentToolFixture({
  91. name,
  92. description: `test tool ${name}`,
  93. parameters: {},
  94. execute: () => Promise.resolve([{ type: 'text', text: `ran ${name}` }]),
  95. }))
  96. }
  97. }
  98. /** Assert the mapped Code Mode SDK includes the stable plan exit binding and test tools. */
  99. function expectPlanCodeSdkBindings(sdk: string): void {
  100. expect(sdk).toContain('interface ToolArgsMap {')
  101. expect(sdk).toContain('read: Record<string, JsonValue>;')
  102. expect(sdk).toContain('write: Record<string, JsonValue>;')
  103. expect(sdk).toContain('interface ToolOutputMap {')
  104. expect(sdk).toContain('exit_plan_mode: {\n approved: true;\n };')
  105. expect(sdk).toContain('[K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;')
  106. }
  107. let callCounter = 0
  108. function execute(ctx: Context, name: string, agent?: Agent) {
  109. return ctx.tools.execute({
  110. callId: CallId(`call-${++callCounter}`),
  111. name,
  112. arguments: {},
  113. signal: new AbortController().signal,
  114. ...agent ? { agent } : {},
  115. })
  116. }
  117. describe('resolveConfig', () => {
  118. it('requires string, non-empty plan instructions', () => {
  119. expect(() => resolveConfig({} as PlanModeConfig))
  120. .toThrow('needs a string `section`')
  121. expect(() => resolveConfig({ section: 5 } as unknown as PlanModeConfig))
  122. .toThrow('needs a string `section`')
  123. expect(() => resolveConfig({ section: ' ' }))
  124. .toThrow('needs a non-empty `section`')
  125. })
  126. it('returns a detached plan config', () => {
  127. const config = { section: TEST_PLAN_SECTION }
  128. const resolved = resolveConfig(config)
  129. expect(resolved).toEqual(config)
  130. expect(resolved).not.toBe(config)
  131. })
  132. it('rejects fields outside the plan policy config', () => {
  133. expect(() => resolveConfig({ section: TEST_PLAN_SECTION, tools: ['read'] } as unknown as PlanModeConfig))
  134. .toThrow('unknown key(s) tools — config is { section }')
  135. })
  136. })
  137. describe('foldPlanMode', () => {
  138. it('folds an empty log to inactive and takes the last plan/mode otherwise', () => {
  139. const session = Session.create(SessionId('fold'))
  140. expect(foldPlanMode(session.events)).toBe(false)
  141. session.append('plan/mode', { active: true })
  142. session.append('plan/mode', { active: false })
  143. session.append('plan/mode', { active: true })
  144. expect(foldPlanMode(session.events)).toBe(true)
  145. })
  146. it('folds a prefix when `end` is given', () => {
  147. const session = Session.create(SessionId('fold-prefix'))
  148. session.append('plan/mode', { active: true })
  149. session.append('plan/mode', { active: false })
  150. expect(foldPlanMode(session.events, 1)).toBe(true)
  151. expect(foldPlanMode(session.events, 0)).toBe(false)
  152. })
  153. })
  154. describe('ctx.planMode: get/set', () => {
  155. it('reads the folded state', async () => {
  156. const ctx = await setup()
  157. const agent = await agentWithSession(ctx)
  158. expect(ctx.planMode.get(agent)).toEqual({ active: false })
  159. agent.session.append('plan/mode', { active: true })
  160. expect(ctx.planMode.get(agent)).toEqual({ active: true })
  161. })
  162. it('selects inactive as the plan exit target during an open turn', async () => {
  163. const ctx = await setup()
  164. const agent = await agentWithSession(ctx)
  165. agent.session.append('plan/mode', { active: true })
  166. openTurn(agent.session)
  167. expect(ctx.planMode.set(agent, false)).toBe('queued')
  168. expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
  169. })
  170. it('drops a no-op set (target equals pending, else the current fold)', async () => {
  171. const ctx = await setup()
  172. const agent = await agentWithSession(ctx)
  173. openTurn(agent.session)
  174. expect(ctx.planMode.set(agent, false)).toBe('noop')
  175. expect(ctx.planMode.get(agent)).toEqual({ active: false })
  176. expect(ctx.planMode.set(agent, true)).toBe('queued')
  177. expect(ctx.planMode.set(agent, true)).toBe('noop')
  178. expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
  179. })
  180. it('a between-turns selection commits plan/mode immediately (no boundary would come)', async () => {
  181. const ctx = await setup()
  182. const agent = await agentWithSession(ctx, 'agent-idle')
  183. expect(ctx.planMode.set(agent, true)).toBe('committed')
  184. expect(foldPlanMode(agent.session.events)).toBe(true)
  185. expect(ctx.planMode.get(agent)).toEqual({ active: true })
  186. // Immediately reversible, still without a boundary.
  187. expect(ctx.planMode.set(agent, false)).toBe('committed')
  188. expect(foldPlanMode(agent.session.events)).toBe(false)
  189. // A later boundary finds nothing pending — no double append.
  190. await boundary(ctx, agent, 'step/end')
  191. expect(agent.session.events.filter(event => event.type === 'plan/mode')).toHaveLength(2)
  192. })
  193. it('a between-turns reversal of a mid-turn pending intent cancels without logging', async () => {
  194. const ctx = await setup()
  195. const agent = await agentWithSession(ctx)
  196. openTurn(agent.session)
  197. expect(ctx.planMode.set(agent, true)).toBe('queued')
  198. closeTurn(agent.session)
  199. // Back to the logged state: the pending intent clears, nothing lands.
  200. expect(ctx.planMode.set(agent, false)).toBe('cancelled')
  201. expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  202. expect(ctx.planMode.get(agent)).toEqual({ active: false })
  203. })
  204. it('a between-turns commit narrates when the last header told the model otherwise', async () => {
  205. const ctx = await setup()
  206. const agent = await agentWithSession(ctx, 'agent-idle-narrate')
  207. header(agent.session)
  208. ctx.planMode.set(agent, true)
  209. expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
  210. })
  211. })
  212. describe('the boundary flush', () => {
  213. it('does not flush at prompt admission — the seam is pre-turn, so the first step boundary lands it', async () => {
  214. const ctx = await setup()
  215. const agent = await agentWithSession(ctx)
  216. openTurn(agent.session)
  217. ctx.planMode.set(agent, true)
  218. // Prompt admission runs before any turn opens; a plan/mode appended there
  219. // would sit outside the turn. The pending intent survives admission and
  220. // the in-turn agent/step boundary flushes it before the request derives.
  221. await boundary(ctx, agent, 'turn/start')
  222. expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  223. expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
  224. await boundary(ctx, agent, 'step/end')
  225. expect(foldPlanMode(agent.session.events)).toBe(true)
  226. expect(ctx.planMode.get(agent)).toEqual({ active: true })
  227. })
  228. it('skips the flush after the plugin fiber is disposed (a captured wrapper must not write into a dead service)', async () => {
  229. const ctx = new Context()
  230. await ctx.plugin(SystemPrompt)
  231. await ctx.plugin(ToolRegistry)
  232. const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
  233. const agent = await agentWithSession(ctx)
  234. openTurn(agent.session)
  235. ctx.planMode.set(agent, true)
  236. // A listener captured in the same dispatch snapshot keeps the plan-mode
  237. // callback alive across the unload; the resumed wrapper must not append
  238. // through the disposed service. Registered prepended AFTER the plugin so
  239. // it runs before plan-mode's own prepended flush.
  240. ctx.on('agent/step', async () => {
  241. await fiber.dispose()
  242. }, { prepend: true })
  243. await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
  244. expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  245. })
  246. it('skips the step-seam flush after the plugin fiber is disposed (a captured listener must not write into a dead service)', async () => {
  247. const ctx = new Context()
  248. await ctx.plugin(SystemPrompt)
  249. await ctx.plugin(ToolRegistry)
  250. const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
  251. const agent = await agentWithSession(ctx)
  252. openTurn(agent.session)
  253. ctx.planMode.set(agent, true)
  254. // Serial dispatch captures its listener list up front; prepending after
  255. // the plugin puts this listener ahead of the plugin's own prepended one,
  256. // so the plugin's captured callback still runs after the disposal below.
  257. ctx.on('agent/step', async () => {
  258. await fiber.dispose()
  259. }, { prepend: true })
  260. await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
  261. expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  262. })
  263. it('flushes at the between-step seam too', async () => {
  264. const ctx = await setup()
  265. const agent = await agentWithSession(ctx)
  266. ctx.planMode.set(agent, true)
  267. await boundary(ctx, agent, 'step/end')
  268. expect(foldPlanMode(agent.session.events)).toBe(true)
  269. })
  270. it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
  271. const ctx = await setup()
  272. const agent = await agentWithSession(ctx)
  273. openTurn(agent.session)
  274. ctx.planMode.set(agent, true)
  275. ctx.planMode.set(agent, false)
  276. await boundary(ctx, agent, 'turn/start')
  277. expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  278. expect(noticeTexts(agent.session)).toEqual([])
  279. })
  280. it('narrates nothing before the first request header (the section is the state statement)', async () => {
  281. const ctx = await setup()
  282. const agent = await agentWithSession(ctx)
  283. ctx.planMode.set(agent, true)
  284. await boundary(ctx, agent, 'turn/start')
  285. expect(noticeTexts(agent.session)).toEqual([])
  286. })
  287. it('narrates once when the flushed mode differs from what the last header told the model', async () => {
  288. const ctx = await setup()
  289. const agent = await agentWithSession(ctx)
  290. header(agent.session)
  291. ctx.planMode.set(agent, true)
  292. await boundary(ctx, agent, 'step/end')
  293. expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
  294. await boundary(ctx, agent, 'step/end')
  295. expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
  296. })
  297. it('narrates a switch back to the default mode with the default wording', async () => {
  298. const ctx = await setup()
  299. const agent = await agentWithSession(ctx)
  300. agent.session.append('plan/mode', { active: true })
  301. header(agent.session)
  302. ctx.planMode.set(agent, false)
  303. await boundary(ctx, agent, 'step/end')
  304. expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.'])
  305. })
  306. it('stays silent when the header already reflects the flushed mode', async () => {
  307. const ctx = await setup()
  308. const agent = await agentWithSession(ctx)
  309. agent.session.append('plan/mode', { active: true })
  310. header(agent.session)
  311. agent.session.append('plan/mode', { active: false })
  312. ctx.planMode.set(agent, true)
  313. await boundary(ctx, agent, 'step/end')
  314. expect(foldPlanMode(agent.session.events)).toBe(true)
  315. expect(noticeTexts(agent.session)).toEqual([])
  316. })
  317. it('contains an append failure instead of blocking the prompt or the turn', async () => {
  318. const ctx = await setup()
  319. const warn = vi.fn()
  320. ctx.logger.warn = warn as never
  321. const agent = await agentWithSession(ctx)
  322. openTurn(agent.session)
  323. ctx.planMode.set(agent, true)
  324. const original = agent.session.append.bind(agent.session)
  325. // Only the flush's own plan/mode append fails; the boundary event itself
  326. // lands (the loop appended it before the seam fires).
  327. agent.session.append = (((type: string, ...rest: unknown[]) => {
  328. if (type === 'plan/mode') throw new Error('backend gone')
  329. return (original as (...args: unknown[]) => unknown)(type, ...rest)
  330. }) as unknown) as typeof agent.session.append
  331. await boundary(ctx, agent, 'step/end')
  332. expect(warn).toHaveBeenCalledOnce()
  333. // The failed flush re-parks the intent (cleared only after a landed
  334. // append), so the next healthy boundary converges the log with the
  335. // picker's optimistic state instead of dropping the switch forever.
  336. expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
  337. agent.session.append = original
  338. await boundary(ctx, agent, 'step/end')
  339. expect(foldPlanMode(agent.session.events)).toBe(true)
  340. expect(ctx.planMode.get(agent).pending).toBeUndefined()
  341. })
  342. it('prompt admission never appends, so a broken backend surfaces only at the step boundary', async () => {
  343. const ctx = await setup()
  344. const warn = vi.fn()
  345. ctx.logger.warn = warn as never
  346. const agent = await agentWithSession(ctx)
  347. openTurn(agent.session)
  348. ctx.planMode.set(agent, true)
  349. const original = agent.session.append.bind(agent.session)
  350. agent.session.append = (((type: string, ...rest: unknown[]) => {
  351. if (type === 'plan/mode') throw new Error('backend gone')
  352. return (original as (...args: unknown[]) => unknown)(type, ...rest)
  353. }) as unknown) as typeof agent.session.append
  354. await boundary(ctx, agent, 'turn/start')
  355. expect(warn).not.toHaveBeenCalled()
  356. await boundary(ctx, agent, 'step/end')
  357. expect(warn).toHaveBeenCalledOnce()
  358. expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
  359. })
  360. })
  361. describe('the soft layer', () => {
  362. it('keeps the tool schemas identical across default and plan mode', async () => {
  363. const ctx = await setup()
  364. registerNamedTools(ctx, ['read', 'write'])
  365. const agent = await agentWithSession(ctx)
  366. const defaultAssembly = await assembleFor(ctx, agent)
  367. expect(defaultAssembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write'])
  368. expect(defaultAssembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
  369. agent.session.append('plan/mode', { active: true })
  370. const planAssembly = await assembleFor(ctx, agent)
  371. expect(planAssembly.tools).toEqual(defaultAssembly.tools)
  372. expect(planAssembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
  373. })
  374. it('leaves an agent-less assembly untouched', async () => {
  375. const ctx = await setup()
  376. registerNamedTools(ctx, ['read'])
  377. const assembly = await ctx.systemPrompt.assemble()
  378. expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read'])
  379. expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
  380. })
  381. it('keeps the full toolset in plan mode and renders the configured mode section', async () => {
  382. const ctx = await setup()
  383. registerNamedTools(ctx, ['read', 'write', 'todo_write'])
  384. const agent = await agentWithSession(ctx, 'agent-1', { active: true })
  385. const assembly = await assembleFor(ctx, agent)
  386. expect(assembly.tools.map(tool => tool.name).sort()).toEqual([EXIT_PLAN_MODE, 'read', 'todo_write', 'write'])
  387. expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
  388. })
  389. it('leaves foreign assemble additions alone (no assemble-layer filtering)', async () => {
  390. // Plan guidance does not filter the registry or later assembly additions.
  391. const ctx = new Context()
  392. await ctx.plugin(SystemPrompt)
  393. await ctx.plugin(ToolRegistry)
  394. ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
  395. const final = await next()
  396. final.tools = [...final.tools, { name: 'added-later', description: 'added after next()', parameters: {} }]
  397. return final
  398. })
  399. await ctx.plugin(PlanModeService, PLAN_CONFIG)
  400. registerNamedTools(ctx, ['read'])
  401. const planning = await agentWithSession(ctx, 'planning', { active: true })
  402. expect((await assembleFor(ctx, planning)).tools.map(tool => tool.name))
  403. .toEqual(['exit_plan_mode', 'read', 'added-later'])
  404. const defaulted = await agentWithSession(ctx, 'defaulted')
  405. expect((await assembleFor(ctx, defaulted)).tools.map(tool => tool.name))
  406. .toEqual(['exit_plan_mode', 'read', 'added-later'])
  407. })
  408. it('keeps run_code the only wire tool in plan mode under the registry Code Mode; the SDK gains the exit binding', async () => {
  409. // Minimal scriptable runtime: the SDK section resolves ctx.codeRuntime at
  410. // assembly time (the code-mode.spec fake's shape).
  411. class FakeRuntime extends CodeRuntime {
  412. readonly language = 'typescript'
  413. readonly isolation = 'fake'
  414. run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
  415. }
  416. const ctx = new Context()
  417. await ctx.plugin(SystemPrompt)
  418. await ctx.plugin(ToolRegistry, { mode: 'code' })
  419. await ctx.plugin(FakeRuntime)
  420. await ctx.plugin(PlanModeService, PLAN_CONFIG)
  421. registerNamedTools(ctx, ['read', 'write'])
  422. const agent = await agentWithSession(ctx, 'agent-1', { active: true })
  423. const assembly = await assembleFor(ctx, agent)
  424. expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
  425. // The SDK documents the full binding set plus the exit; plan mode never
  426. // prunes capabilities and restrains through guidance alone.
  427. const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
  428. expectPlanCodeSdkBindings(sdk)
  429. })
  430. it('keeps native wire schemas and the SDK in step under mode both', async () => {
  431. class FakeRuntime extends CodeRuntime {
  432. readonly language = 'typescript'
  433. readonly isolation = 'fake'
  434. run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
  435. }
  436. const ctx = new Context()
  437. await ctx.plugin(SystemPrompt)
  438. await ctx.plugin(ToolRegistry, { mode: 'both' })
  439. await ctx.plugin(FakeRuntime)
  440. await ctx.plugin(PlanModeService, PLAN_CONFIG)
  441. registerNamedTools(ctx, ['read', 'write'])
  442. const agent = await agentWithSession(ctx, 'agent-1', { active: true })
  443. const assembly = await assembleFor(ctx, agent)
  444. // The stable registry contribution reaches both surfaces: the exit tool
  445. // is present on the wire AND in the SDK alongside the untouched toolset.
  446. expect(assembly.tools.map(tool => tool.name).sort()).toEqual(['exit_plan_mode', 'read', 'run_code', 'write'])
  447. const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
  448. expectPlanCodeSdkBindings(sdk)
  449. })
  450. it('keeps the Code Mode SDK byte-identical across mode switches', async () => {
  451. class FakeRuntime extends CodeRuntime {
  452. readonly language = 'typescript'
  453. readonly isolation = 'fake'
  454. run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
  455. }
  456. const withPlanMode = new Context()
  457. await withPlanMode.plugin(SystemPrompt)
  458. await withPlanMode.plugin(ToolRegistry, { mode: 'code' })
  459. await withPlanMode.plugin(FakeRuntime)
  460. await withPlanMode.plugin(PlanModeService, PLAN_CONFIG)
  461. registerNamedTools(withPlanMode, ['read', 'write'])
  462. const agent = await agentWithSession(withPlanMode)
  463. const defaultSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
  464. expectPlanCodeSdkBindings(defaultSdk)
  465. agent.session.append('plan/mode', { active: true })
  466. const planSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
  467. expect(planSdk).toBe(defaultSdk)
  468. // Loading the plan-mode plugin deliberately adds one stable binding compared
  469. // with a deployment that does not compose plan mode at all.
  470. const bare = new Context()
  471. await bare.plugin(SystemPrompt)
  472. await bare.plugin(ToolRegistry, { mode: 'code' })
  473. await bare.plugin(FakeRuntime)
  474. registerNamedTools(bare, ['read', 'write'])
  475. const bareSdk = (await bare.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
  476. expect(bareSdk).not.toContain('exit_plan_mode:')
  477. expect(defaultSdk).not.toBe(bareSdk)
  478. })
  479. })
  480. describe('no execution gating beyond the exit tool', () => {
  481. it('passes agent-less and default-mode executions through', async () => {
  482. const ctx = await setup()
  483. registerNamedTools(ctx, ['write'])
  484. const agentless = await execute(ctx, 'write')
  485. expect(agentless.isError).toBe(false)
  486. const agent = await agentWithSession(ctx)
  487. const defaulted = await execute(ctx, 'write', agent)
  488. expect(defaulted.isError).toBe(false)
  489. })
  490. it('runs every call in plan mode untouched — guidance and enforcement are separate axes', async () => {
  491. const ctx = await setup()
  492. registerNamedTools(ctx, ['read', 'write', 'bash'])
  493. const agent = await agentWithSession(ctx, 'agent-1', { active: true })
  494. for (const name of ['read', 'write', 'bash']) {
  495. const result = await execute(ctx, name, agent)
  496. expect(result.isError).toBe(false)
  497. }
  498. })
  499. })
  500. describe('/plan', () => {
  501. it('registers only when a commands service is composed and optionally submits the next-step message', async () => {
  502. const bare = await setup()
  503. expect(bare.get('commands')).toBeUndefined()
  504. const ctx = await setup()
  505. await ctx.plugin(CommandService)
  506. // The `ctx.inject` child mounts asynchronously once `commands` resolves.
  507. await new Promise(resolve => setImmediate(resolve))
  508. const plainAgent = await agentWithSession(ctx, 'plain-plan-command')
  509. openTurn(plainAgent.session)
  510. const plainSteer = vi.fn()
  511. ;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer
  512. expect(ctx.commands.list(plainAgent)).toEqual([
  513. { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
  514. ])
  515. const signal = new AbortController().signal
  516. expect(await ctx.commands.execute(plainAgent, '/mode', signal)).toBeUndefined()
  517. expect(await ctx.commands.execute(plainAgent, '/review', signal)).toBeUndefined()
  518. const plain = await ctx.commands.execute(plainAgent, '/plan', signal)
  519. expect(plain?.result).toEqual({
  520. kind: 'success',
  521. text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
  522. })
  523. expect(ctx.planMode.get(plainAgent)).toEqual({ active: false, pending: true })
  524. expect(plainSteer).not.toHaveBeenCalled()
  525. const messageAgent = await agentWithSession(ctx, 'message-plan-command')
  526. openTurn(messageAgent.session)
  527. const messageSteer = vi.fn()
  528. ;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer
  529. const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', signal)
  530. expect(plan?.result).toEqual({
  531. kind: 'success',
  532. text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
  533. })
  534. expect(ctx.planMode.get(messageAgent)).toEqual({ active: false, pending: true })
  535. expect(messageSteer).toHaveBeenCalledExactlyOnceWith({
  536. id: expect.any(String) as unknown,
  537. role: 'user',
  538. content: [{ type: 'text', text: 'draft the migration' }],
  539. source: { kind: 'user' },
  540. })
  541. })
  542. it('leaves active plan mode, cancels a pending entry, and treats inactive exit as idempotent', async () => {
  543. const ctx = await setup()
  544. await ctx.plugin(CommandService)
  545. await new Promise(resolve => setImmediate(resolve))
  546. const signal = new AbortController().signal
  547. const inactive = await agentWithSession(ctx, 'inactive-plan-command')
  548. expect((await ctx.commands.execute(inactive, '/plan off', signal))?.result)
  549. .toEqual({ kind: 'success', text: 'Plan mode is already inactive.' })
  550. expect(ctx.planMode.get(inactive)).toEqual({ active: false })
  551. const entering = await agentWithSession(ctx, 'entering-plan-command')
  552. openTurn(entering.session)
  553. const enteringSteer = vi.fn()
  554. ;(entering as unknown as { steer: typeof enteringSteer }).steer = enteringSteer
  555. await ctx.commands.execute(entering, '/plan', signal)
  556. expect((await ctx.commands.execute(entering, '/plan off', signal))?.result)
  557. .toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' })
  558. expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false })
  559. expect(enteringSteer).not.toHaveBeenCalled()
  560. await boundary(ctx, entering, 'step/end')
  561. expect(ctx.planMode.get(entering)).toEqual({ active: false })
  562. expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  563. const active = await agentWithSession(ctx, 'active-plan-command', { active: true })
  564. openTurn(active.session)
  565. const activeSteer = vi.fn()
  566. ;(active as unknown as { steer: typeof activeSteer }).steer = activeSteer
  567. expect((await ctx.commands.execute(active, '/plan off', signal))?.result)
  568. .toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
  569. expect(ctx.planMode.get(active)).toEqual({ active: true, pending: false })
  570. expect((await ctx.commands.execute(active, '/plan off', signal))?.result)
  571. .toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
  572. expect(activeSteer).not.toHaveBeenCalled()
  573. await boundary(ctx, active, 'step/end')
  574. expect(ctx.planMode.get(active)).toEqual({ active: false })
  575. })
  576. it('idle sessions get the immediate-commit copy on both /plan and /plan off', async () => {
  577. const ctx = await setup()
  578. await ctx.plugin(CommandService)
  579. await new Promise(resolve => setImmediate(resolve))
  580. const signal = new AbortController().signal
  581. const agent = await agentWithSession(ctx, 'idle-plan-command')
  582. expect((await ctx.commands.execute(agent, '/plan', signal))?.result)
  583. .toEqual({ kind: 'success', text: 'Plan mode on. Use /plan off to leave.' })
  584. expect(foldPlanMode(agent.session.events)).toBe(true)
  585. expect((await ctx.commands.execute(agent, '/plan off', signal))?.result)
  586. .toEqual({ kind: 'success', text: 'Plan mode off.' })
  587. expect(foldPlanMode(agent.session.events)).toBe(false)
  588. })
  589. it('removes the contributed command when the plan-mode plugin is disposed', async () => {
  590. const ctx = new Context()
  591. await ctx.plugin(SystemPrompt)
  592. await ctx.plugin(ToolRegistry)
  593. await ctx.plugin(CommandService)
  594. const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
  595. await new Promise(resolve => setImmediate(resolve))
  596. const agent = await agentWithSession(ctx)
  597. expect(ctx.commands.list(agent).map(command => command.name)).toEqual(['plan'])
  598. await fiber.dispose()
  599. expect(ctx.commands.list(agent)).toEqual([])
  600. })
  601. })
  602. describe('exit_plan_mode', () => {
  603. async function setupWithReview(answer?: { selected: string[]; custom?: string }) {
  604. const ctx = await setup()
  605. await ctx.plugin(UserInteractionService)
  606. const asked: AskUserQuestionRequest[] = []
  607. if (answer !== undefined) {
  608. ctx.userInteraction.registerProvider({
  609. ask: (request) => {
  610. asked.push(request)
  611. return Promise.resolve({ answers: [{ id: 'plan-review', ...answer }] })
  612. },
  613. })
  614. }
  615. const agent = await agentWithSession(ctx, 'agent-1', { active: true })
  616. return { ctx, agent, asked }
  617. }
  618. function callExit(ctx: Context, agent: Agent | undefined, plan = '# The plan\n\ndo things') {
  619. return ctx.tools.execute({
  620. callId: CallId(`call-exit-${++callCounter}`),
  621. name: EXIT_PLAN_MODE,
  622. arguments: { plan },
  623. signal: new AbortController().signal,
  624. ...agent ? { agent } : {},
  625. })
  626. }
  627. it('registers the tool with one required plan argument', async () => {
  628. const ctx = await setup()
  629. const schema = ctx.tools.schemas().find(entry => entry.name === EXIT_PLAN_MODE)
  630. const parameters = schema?.parameters as { required?: string[]; properties?: Record<string, unknown> }
  631. expect(schema?.description).toMatch(/^Use only in plan mode\./)
  632. expect(Object.keys(parameters.properties ?? {})).toEqual(['plan'])
  633. expect(parameters.required).toEqual(['plan'])
  634. })
  635. it('rejects an agent-less call', async () => {
  636. const ctx = await setup()
  637. const result = await callExit(ctx, undefined)
  638. expect(result.isError).toBe(true)
  639. expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a calling agent (no session to switch)' }])
  640. })
  641. it('rejects a call outside plan mode while remaining advertised', async () => {
  642. const ctx = await setup()
  643. const agent = await agentWithSession(ctx)
  644. expect(ctx.tools.schemas().map(tool => tool.name)).toContain(EXIT_PLAN_MODE)
  645. const result = await callExit(ctx, agent)
  646. expect(result.isError).toBe(true)
  647. expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode is only available in plan mode' }])
  648. })
  649. it('rejects an empty or heading-less plan before asking the reviewer', async () => {
  650. const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
  651. for (const plan of ['', 'do things']) {
  652. const result = await callExit(ctx, agent, plan)
  653. expect(result.isError).toBe(true)
  654. expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a non-empty markdown plan starting with a # heading' }])
  655. }
  656. expect(asked).toHaveLength(0)
  657. expect(foldPlanMode(agent.session.events)).toBe(true)
  658. })
  659. it('degrades to the manual exit when no user-interaction seam is composed', async () => {
  660. const ctx = await setup()
  661. const agent = await agentWithSession(ctx, 'agent-1', { active: true })
  662. const result = await callExit(ctx, agent)
  663. expect(result.isError).toBe(true)
  664. 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' }])
  665. expect(foldPlanMode(agent.session.events)).toBe(true)
  666. })
  667. it('degrades the same way when the seam has no provider (NO_PROVIDER)', async () => {
  668. const { ctx, agent } = await setupWithReview()
  669. const result = await callExit(ctx, agent)
  670. expect(result.isError).toBe(true)
  671. expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction provider is registered' }])
  672. expect(foldPlanMode(agent.session.events)).toBe(true)
  673. })
  674. it('approve: records the boundary-applied switch and confirms (the fold flips at the flush)', async () => {
  675. const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
  676. const result = await callExit(ctx, agent)
  677. expect(result.isError).toBe(false)
  678. if (result.isError) throw new Error('expected approved plan result')
  679. expect(result.value).toEqual({ approved: true })
  680. expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }])
  681. // Boundary-applied, not a direct append: the fold stays plan until the
  682. // step's end, so the plan policy covers any remaining call of the SAME batch.
  683. expect(foldPlanMode(agent.session.events)).toBe(true)
  684. expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
  685. await boundary(ctx, agent, 'step/end')
  686. expect(foldPlanMode(agent.session.events)).toBe(false)
  687. expect(asked).toHaveLength(1)
  688. expect(asked[0]?.agent).toBe(agent)
  689. expect(asked[0]?.questions[0]?.detail).toBe('# The plan\n\ndo things')
  690. expect(asked[0]?.questions[0]?.options?.map(option => option.label)).toEqual(['Approve', 'Keep planning'])
  691. })
  692. it('carries the exact plan through a Code Mode review and logs the nested dispatch', async () => {
  693. const plan = '# Code Mode plan\n\nUse the existing seam.'
  694. class ExitRuntime extends CodeRuntime {
  695. readonly language = 'typescript'
  696. readonly isolation = 'fake'
  697. async run(request: CodeRunRequest): Promise<CodeRunResult> {
  698. const exit = request.bindings[0]?.functions[EXIT_PLAN_MODE]
  699. if (exit === undefined) throw new Error('missing exit_plan_mode binding')
  700. return { logs: [], value: await exit({ plan }) }
  701. }
  702. }
  703. const ctx = new Context()
  704. await ctx.plugin(SystemPrompt)
  705. await ctx.plugin(ToolRegistry, { mode: 'code' })
  706. await ctx.plugin(ExitRuntime)
  707. await ctx.plugin(PlanModeService, PLAN_CONFIG)
  708. await ctx.plugin(UserInteractionService)
  709. const asked: AskUserQuestionRequest[] = []
  710. ctx.userInteraction.registerProvider({
  711. ask: (request) => {
  712. asked.push(request)
  713. return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
  714. },
  715. })
  716. const agent = await agentWithSession(ctx, 'code-mode-exit', { active: true })
  717. const result = await ctx.tools.execute({
  718. callId: CallId(`call-exit-${++callCounter}`),
  719. name: RUN_CODE_NAME,
  720. arguments: { code: `return await tools.${EXIT_PLAN_MODE}({ plan: ${JSON.stringify(plan)} })`, description: 'Submit the plan for review' },
  721. signal: new AbortController().signal,
  722. agent,
  723. })
  724. expect(result.isError).toBe(false)
  725. expect(asked).toHaveLength(1)
  726. expect(asked[0]?.questions[0]).toMatchObject({
  727. header: 'Plan review',
  728. question: 'Approve this plan and leave plan mode?',
  729. detail: plan,
  730. })
  731. expect(agent.session.events.find(event => event.type === 'tool/code-dispatch')?.data).toMatchObject({
  732. name: EXIT_PLAN_MODE,
  733. arguments: { plan },
  734. isError: false,
  735. })
  736. expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
  737. })
  738. it('an approved exit keeps plan guidance until the boundary and never removes the tool', async () => {
  739. const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
  740. const approved = await callExit(ctx, agent)
  741. expect(approved.isError).toBe(false)
  742. // Calls of the SAME assistant response (no boundary between) were
  743. // requested under the plan-shaped header — the fold stays plan for that
  744. // whole batch; the boundary flush is what flips the next step.
  745. expect(foldPlanMode(agent.session.events)).toBe(true)
  746. const assembly = await ctx.systemPrompt.assemble({ agent })
  747. expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true)
  748. expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
  749. await boundary(ctx, agent, 'step/end')
  750. expect(foldPlanMode(agent.session.events)).toBe(false)
  751. const afterExit = await ctx.systemPrompt.assemble({ agent })
  752. expect(afterExit.tools).toEqual(assembly.tools)
  753. expect(afterExit.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
  754. })
  755. it('the exit flush narrates nothing — the tool result is the narration', async () => {
  756. const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
  757. header(agent.session)
  758. await callExit(ctx, agent)
  759. await boundary(ctx, agent, 'step/end')
  760. expect(foldPlanMode(agent.session.events)).toBe(false)
  761. expect(noticeTexts(agent.session)).toEqual([])
  762. })
  763. it('keep planning returns the corrective error carrying the feedback verbatim', async () => {
  764. const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'], custom: 'consider the resume path' })
  765. const result = await callExit(ctx, agent)
  766. expect(result.isError).toBe(true)
  767. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: consider the resume path' }])
  768. expect(foldPlanMode(agent.session.events)).toBe(true)
  769. })
  770. it('keep planning without feedback returns the generic corrective error', async () => {
  771. const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'] })
  772. const result = await callExit(ctx, agent)
  773. expect(result.isError).toBe(true)
  774. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
  775. })
  776. it('a custom-text-only answer is feedback, never consent', async () => {
  777. const { ctx, agent } = await setupWithReview({ selected: [], custom: 'add tests first' })
  778. const result = await callExit(ctx, agent)
  779. expect(result.isError).toBe(true)
  780. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: add tests first' }])
  781. expect(foldPlanMode(agent.session.events)).toBe(true)
  782. })
  783. it('requires exactly the single Approve selection', async () => {
  784. const { ctx, agent } = await setupWithReview({ selected: ['Approve', 'Keep planning'] })
  785. const result = await callExit(ctx, agent)
  786. expect(result.isError).toBe(true)
  787. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
  788. expect(foldPlanMode(agent.session.events)).toBe(true)
  789. })
  790. it('treats custom text alongside Approve as feedback, not consent', async () => {
  791. const { ctx, agent } = await setupWithReview({ selected: ['Approve'], custom: 'change the tests' })
  792. const result = await callExit(ctx, agent)
  793. expect(result.isError).toBe(true)
  794. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: change the tests' }])
  795. expect(foldPlanMode(agent.session.events)).toBe(true)
  796. })
  797. it('treats duplicate review answer items as non-consent', async () => {
  798. const { ctx, agent } = await setupWithReview()
  799. ctx.userInteraction.registerProvider({
  800. ask: () => Promise.resolve({ answers: [
  801. { id: 'plan-review', selected: ['Approve'] },
  802. { id: 'plan-review', selected: ['Keep planning'] },
  803. ] }),
  804. })
  805. const result = await callExit(ctx, agent)
  806. expect(result.isError).toBe(true)
  807. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
  808. expect(foldPlanMode(agent.session.events)).toBe(true)
  809. })
  810. it('a missing answer item reads as keep-planning', async () => {
  811. const { ctx, agent } = await setupWithReview()
  812. ctx.userInteraction.registerProvider({ ask: () => Promise.resolve({ answers: [] }) })
  813. const result = await callExit(ctx, agent)
  814. expect(result.isError).toBe(true)
  815. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
  816. })
  817. it('declares the plan-review presentation intent naming its approve option', async () => {
  818. const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
  819. await callExit(ctx, agent)
  820. const question = asked[0]?.questions[0]
  821. expect(question?.intent).toEqual({ kind: 'plan-review', approve: 'Approve' })
  822. // The named label is one this same question offers, so a UI honouring the
  823. // intent answers a choice this tool accepts.
  824. expect(question?.options?.map(option => option.label)).toContain(question?.intent?.approve)
  825. })
  826. it('reads a dismissed review as the user taking the turn back, not as a failure', async () => {
  827. const { ctx, agent } = await setupWithReview()
  828. ctx.userInteraction.registerProvider({
  829. ask: () => Promise.reject(new UserInteractionError(
  830. 'the user cancelled ask_user_question', 'ASK_CANCELLED')),
  831. })
  832. const result = await callExit(ctx, agent)
  833. expect(result.isError).toBe(true)
  834. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user dismissed the plan review to speak instead; stay in plan mode, stop here, and wait for their message.' }])
  835. expect(foldPlanMode(agent.session.events)).toBe(true)
  836. })
  837. it('leaves every other review failure its own message', async () => {
  838. const { ctx, agent } = await setupWithReview()
  839. ctx.userInteraction.registerProvider({
  840. ask: () => Promise.reject(new UserInteractionError(
  841. 'ask_user_question was aborted before the user answered', 'ASK_ABORTED')),
  842. })
  843. const result = await callExit(ctx, agent)
  844. expect(result.isError).toBe(true)
  845. expect(result.content).toEqual([{ type: 'text', text: 'Error: ask_user_question was aborted before the user answered' }])
  846. expect(foldPlanMode(agent.session.events)).toBe(true)
  847. })
  848. it('forwards the execution abort signal to the review question', async () => {
  849. const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
  850. const controller = new AbortController()
  851. const result = await ctx.tools.execute({
  852. callId: CallId(`call-exit-${++callCounter}`),
  853. name: EXIT_PLAN_MODE,
  854. arguments: { plan: '# P' },
  855. agent,
  856. signal: controller.signal,
  857. })
  858. expect(result.isError).toBe(false)
  859. expect(asked[0]?.signal).toBe(controller.signal)
  860. })
  861. it('fails the call when the plugin is disposed while the review awaits (no phantom exit)', async () => {
  862. const ctx = new Context()
  863. await ctx.plugin(SystemPrompt)
  864. await ctx.plugin(ToolRegistry)
  865. const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
  866. await ctx.plugin(UserInteractionService)
  867. let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void
  868. ctx.userInteraction.registerProvider({
  869. ask: () => new Promise((resolve) => { answer = resolve }),
  870. })
  871. const agent = await agentWithSession(ctx, 'agent-1', { active: true })
  872. const pending = callExit(ctx, agent)
  873. // Let execute reach the review await, then unload the plugin (HMR) and
  874. // only afterwards approve. The boundary listeners are gone, so a success
  875. // would claim an exit that can never flush — the call must fail instead.
  876. await new Promise(resolve => setImmediate(resolve))
  877. await fiber.dispose()
  878. answer({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
  879. const result = await pending
  880. expect(result.isError).toBe(true)
  881. expect(result.content).toEqual([{ type: 'text', text: 'Error: the plan-mode service was reloaded while the plan was under review; present the plan again' }])
  882. expect(foldPlanMode(agent.session.events)).toBe(true)
  883. })
  884. it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => {
  885. const { ctx, agent } = await setupWithReview()
  886. ctx.userInteraction.registerProvider({ ask: () => { throw new Error('review aborted') } })
  887. const result = await callExit(ctx, agent)
  888. expect(result.isError).toBe(true)
  889. expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }])
  890. expect(foldPlanMode(agent.session.events)).toBe(true)
  891. })
  892. it('presents the call as a generic card titled by the plan first heading', async () => {
  893. const ctx = await setup()
  894. const def = ctx.tools.get(EXIT_PLAN_MODE)!
  895. expect(def.presentCall?.({ plan: '## Fix the flake\n\nsteps' })).toEqual({
  896. card: 'generic',
  897. title: 'Fix the flake',
  898. kind: 'other',
  899. content: [{ type: 'text', text: '## Fix the flake\n\nsteps' }],
  900. })
  901. expect(def.presentCall?.({ plan: 'no heading here' })).toEqual({
  902. card: 'generic',
  903. title: 'Plan',
  904. kind: 'other',
  905. content: [{ type: 'text', text: 'no heading here' }],
  906. })
  907. })
  908. it('presents the result as a generic review card', async () => {
  909. const ctx = await setup()
  910. const def = ctx.tools.get(EXIT_PLAN_MODE)!
  911. const content = [{ type: 'text' as const, text: 'ok' }]
  912. expect(def.presentResult?.({ plan: '# P' }, { content, isError: false })).toEqual({
  913. card: 'generic',
  914. title: 'Plan review',
  915. content,
  916. })
  917. })
  918. })
  919. describe('HMR disposal', () => {
  920. it('unregisters the service, listeners, prompt section, and stable exit tool with the plugin fiber', async () => {
  921. const ctx = new Context()
  922. await ctx.plugin(SystemPrompt)
  923. await ctx.plugin(ToolRegistry)
  924. const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
  925. const agent = await agentWithSession(ctx, 'disposed-recovery')
  926. openTurn(agent.session)
  927. ctx.planMode.set(agent, true)
  928. expect(ctx.get('planMode')).toBeInstanceOf(PlanModeService)
  929. expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()
  930. expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).toContain('plan:policy')
  931. await fiber.dispose()
  932. expect(ctx.get('planMode')).toBeUndefined()
  933. expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
  934. expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy')
  935. await boundary(ctx, agent, 'step/end')
  936. expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  937. })
  938. })