plan-mode.spec.ts 46 KB

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