plan-mode.spec.ts 47 KB

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