plan-mode.spec.ts 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { CallId } from '@deepseek-ai/dsh-llm'
  4. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  5. import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  6. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  7. import { agentEvents, type Agent, type RequestErrorDecision } from '@deepseek-ai/dsh-agent'
  8. import { createScope } from '@deepseek-ai/dsh-scope'
  9. import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
  10. import CommandService from '@deepseek-ai/dsh-commands'
  11. import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
  12. import PlanModeService, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts'
  13. import type { PlanModeConfig } from '../src/index.ts'
  14. const TEST_PLAN_SECTION = 'Test plan mode instructions.'
  15. const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
  16. /**
  17. * Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and
  18. * `ToolRegistry` services, with fake Agents carrying real `Session`s and a
  19. * real scoped `agent.ctx` minted through `createScope`.
  20. * Turn boundaries are simulated by appending the real boundary events and
  21. * dispatching the interception seams the loop fires there. Recovery retries
  22. * exercise the separate `agent/request-error` wrapper.
  23. */
  24. async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
  25. const session = new Session(SessionId(id))
  26. const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
  27. let scoped!: Context
  28. await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
  29. inject: ['tools'],
  30. }))
  31. ;(agent as { ctx?: Context }).ctx = scoped
  32. // Seeded plan state lands before the creation announcement, matching resume.
  33. if (active !== undefined) session.append('plan/mode', { active })
  34. // The loop announces creation after publication.
  35. ctx.emit('agent/created', agent)
  36. return agent
  37. }
  38. /** Assemble exactly as the loop does: the agent is both subject and scope. */
  39. function assembleFor(ctx: Context, agent: Agent) {
  40. return ctx.systemPrompt.assemble({ agent, scope: agent })
  41. }
  42. async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
  43. const ctx = new Context()
  44. await ctx.plugin(SystemPrompt)
  45. await ctx.plugin(ToolRegistry)
  46. await ctx.plugin(PlanModeService, config)
  47. return ctx
  48. }
  49. /**
  50. * Append a boundary event and dispatch the interception seam the loop fires
  51. * there — `agent/prompt-submit` inside the just-opened turn,
  52. * `agent/turn-continuation` after the step closed. Recovery retries use the
  53. * separately covered `agent/request-error` wrapper; post-commit
  54. * `session/event` observers remain observe-only.
  55. */
  56. async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'turn/start' | 'step/end'): Promise<void> {
  57. const events = agentEvents(ctx, agent)
  58. if (type === 'turn/start') {
  59. agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  60. await events.waterfall('agent/prompt-submit', [{ type: 'text', text: 'boundary probe' }], { kind: 'user' }, new AbortController().signal, () => Promise.resolve({ kind: 'allow' }))
  61. return
  62. }
  63. agent.session.append('step/end', { turn: 1, step: 1 })
  64. await events.waterfall('agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal, () => Promise.resolve({ action: 'stop' }))
  65. }
  66. /** Dispatch the closed-step recovery seam with one terminal decision. */
  67. function recoveryBoundary(
  68. ctx: Context,
  69. agent: Agent & { session: Session },
  70. decision: RequestErrorDecision,
  71. ): Promise<RequestErrorDecision> {
  72. return agentEvents(ctx, agent).waterfall(
  73. 'agent/request-error',
  74. 1,
  75. 1,
  76. new Error('request failed'),
  77. { message: 'request failed', code: 'SERVER' },
  78. [],
  79. new AbortController().signal,
  80. () => Promise.resolve(decision),
  81. )
  82. }
  83. /** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
  84. function header(session: Session): void {
  85. session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'initial' })
  86. }
  87. function noticeTexts(session: Session): string[] {
  88. return session.events
  89. .filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
  90. .map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join(''))
  91. }
  92. function registerNamedTools(ctx: Context, names: string[]): void {
  93. for (const name of names) {
  94. ctx.tools.register(defineContentToolFixture({
  95. name,
  96. description: `test tool ${name}`,
  97. parameters: {},
  98. execute: () => Promise.resolve([{ type: 'text', text: `ran ${name}` }]),
  99. }))
  100. }
  101. }
  102. /** Assert the mapped Code Mode SDK includes the stable plan exit binding and test tools. */
  103. function expectPlanCodeSdkBindings(sdk: string): void {
  104. expect(sdk).toContain('interface ToolArgsMap {')
  105. expect(sdk).toContain('read: Record<string, JsonValue>;')
  106. expect(sdk).toContain('write: Record<string, JsonValue>;')
  107. expect(sdk).toContain('interface ToolOutputMap {')
  108. expect(sdk).toContain('exit_plan_mode: {\n approved: true;\n };')
  109. expect(sdk).toContain('[K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;')
  110. }
  111. let callCounter = 0
  112. function execute(ctx: Context, name: string, agent?: Agent) {
  113. return ctx.tools.execute({
  114. callId: CallId(`call-${++callCounter}`),
  115. name,
  116. arguments: {},
  117. signal: new AbortController().signal,
  118. ...agent ? { agent } : {},
  119. })
  120. }
  121. describe('resolveConfig', () => {
  122. it('requires string, non-empty plan instructions', () => {
  123. expect(() => resolveConfig({} as PlanModeConfig))
  124. .toThrow('needs a string `section`')
  125. expect(() => resolveConfig({ section: 5 } as unknown as PlanModeConfig))
  126. .toThrow('needs a string `section`')
  127. expect(() => resolveConfig({ section: ' ' }))
  128. .toThrow('needs a non-empty `section`')
  129. })
  130. it('returns a detached plan config', () => {
  131. const config = { section: TEST_PLAN_SECTION }
  132. const resolved = resolveConfig(config)
  133. expect(resolved).toEqual(config)
  134. expect(resolved).not.toBe(config)
  135. })
  136. it('rejects fields outside the plan policy config', () => {
  137. expect(() => resolveConfig({ section: TEST_PLAN_SECTION, tools: ['read'] } as unknown as PlanModeConfig))
  138. .toThrow('unknown key(s) tools — config is { section }')
  139. })
  140. })
  141. describe('foldPlanMode', () => {
  142. it('folds an empty log to inactive and takes the last plan/mode otherwise', () => {
  143. const session = new Session(SessionId('fold'))
  144. expect(foldPlanMode(session.events)).toBe(false)
  145. session.append('plan/mode', { active: true })
  146. session.append('plan/mode', { active: false })
  147. session.append('plan/mode', { active: true })
  148. expect(foldPlanMode(session.events)).toBe(true)
  149. })
  150. it('folds a prefix when `end` is given', () => {
  151. const session = new Session(SessionId('fold-prefix'))
  152. session.append('plan/mode', { active: true })
  153. session.append('plan/mode', { active: false })
  154. expect(foldPlanMode(session.events, 1)).toBe(true)
  155. expect(foldPlanMode(session.events, 0)).toBe(false)
  156. })
  157. })
  158. describe('ctx.planMode: get/set', () => {
  159. it('reads the folded state', async () => {
  160. const ctx = await setup()
  161. const agent = await agentWithSession(ctx)
  162. expect(ctx.planMode.get(agent)).toEqual({ active: false })
  163. agent.session.append('plan/mode', { active: true })
  164. expect(ctx.planMode.get(agent)).toEqual({ active: true })
  165. })
  166. it('selects inactive as the plan exit target', async () => {
  167. const ctx = await setup()
  168. const agent = await agentWithSession(ctx)
  169. agent.session.append('plan/mode', { active: true })
  170. ctx.planMode.set(agent, false)
  171. expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
  172. })
  173. it('drops a no-op set (target equals pending, else the current fold)', async () => {
  174. const ctx = await setup()
  175. const agent = await agentWithSession(ctx)
  176. ctx.planMode.set(agent, false)
  177. expect(ctx.planMode.get(agent)).toEqual({ active: false })
  178. ctx.planMode.set(agent, true)
  179. ctx.planMode.set(agent, true)
  180. expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
  181. })
  182. })
  183. describe('the boundary flush', () => {
  184. it('flushes the pending intent as a plan/mode at turn/start', async () => {
  185. const ctx = await setup()
  186. const agent = await agentWithSession(ctx)
  187. ctx.planMode.set(agent, true)
  188. await boundary(ctx, agent, 'turn/start')
  189. expect(foldPlanMode(agent.session.events)).toBe(true)
  190. expect(ctx.planMode.get(agent)).toEqual({ active: true })
  191. })
  192. it('flushes a set() that arrives while a downstream listener is still awaiting (post-next ordering)', async () => {
  193. const ctx = await setup()
  194. const agent = await agentWithSession(ctx)
  195. // A downstream async listener (the shipped hooks listeners' shape): the
  196. // selection lands DURING its await — after this boundary began, before it
  197. // returns. The prepended flush runs after next(), so the plan/mode still
  198. // precedes the request this boundary gates.
  199. ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
  200. await new Promise(resolve => setTimeout(resolve, 5))
  201. ctx.planMode.set(agent, true)
  202. await next()
  203. return decision
  204. })
  205. agent.session.append('step/end', { turn: 1, step: 1 })
  206. await agentEvents(ctx, agent).waterfall(
  207. 'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
  208. () => Promise.resolve({ action: 'stop' }),
  209. )
  210. expect(foldPlanMode(agent.session.events)).toBe(true)
  211. expect(ctx.planMode.get(agent)).toEqual({ active: true })
  212. })
  213. it('skips the flush after the plugin fiber is disposed (a captured wrapper must not write into a dead service)', async () => {
  214. const ctx = new Context()
  215. await ctx.plugin(SystemPrompt)
  216. await ctx.plugin(ToolRegistry)
  217. const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
  218. const agent = await agentWithSession(ctx)
  219. ctx.planMode.set(agent, true)
  220. // A downstream listener captured before disposal keeps the waterfall
  221. // continuation alive across the unload; the resumed wrapper must not
  222. // append through the disposed service.
  223. ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
  224. await fiber.dispose()
  225. await next()
  226. return decision
  227. })
  228. agent.session.append('step/end', { turn: 1, step: 1 })
  229. await agentEvents(ctx, agent).waterfall(
  230. 'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
  231. () => Promise.resolve({ action: 'stop' }),
  232. )
  233. expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  234. })
  235. it('flushes at step/end too (a mid-turn flip lands on the following step)', async () => {
  236. const ctx = await setup()
  237. const agent = await agentWithSession(ctx)
  238. ctx.planMode.set(agent, true)
  239. await boundary(ctx, agent, 'step/end')
  240. expect(foldPlanMode(agent.session.events)).toBe(true)
  241. })
  242. it('keeps the pending intent parked when recovery does not retry', async () => {
  243. const ctx = await setup()
  244. const agent = await agentWithSession(ctx)
  245. ctx.planMode.set(agent, true)
  246. expect(await recoveryBoundary(ctx, agent, { action: 'fail' })).toEqual({ action: 'fail' })
  247. expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
  248. })
  249. it('contains an append failure at the retry boundary without changing its decision', async () => {
  250. const ctx = await setup()
  251. const warn = vi.fn()
  252. ctx.logger.warn = warn as never
  253. const agent = await agentWithSession(ctx)
  254. ctx.planMode.set(agent, true)
  255. const original = agent.session.append.bind(agent.session)
  256. agent.session.append = (((type: string, ...rest: unknown[]) => {
  257. if (type === 'plan/mode') throw new Error('backend gone')
  258. return (original as (...args: unknown[]) => unknown)(type, ...rest)
  259. }) as unknown) as typeof agent.session.append
  260. expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
  261. expect(warn).toHaveBeenCalledOnce()
  262. expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
  263. })
  264. it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
  265. const ctx = await setup()
  266. const agent = await agentWithSession(ctx)
  267. ctx.planMode.set(agent, true)
  268. ctx.planMode.set(agent, false)
  269. await boundary(ctx, agent, 'turn/start')
  270. expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  271. expect(noticeTexts(agent.session)).toEqual([])
  272. })
  273. it('narrates nothing before the first request header (the section is the state statement)', async () => {
  274. const ctx = await setup()
  275. const agent = await agentWithSession(ctx)
  276. ctx.planMode.set(agent, true)
  277. await boundary(ctx, agent, 'turn/start')
  278. expect(noticeTexts(agent.session)).toEqual([])
  279. })
  280. it('narrates once when the flushed mode differs from what the last header told the model', async () => {
  281. const ctx = await setup()
  282. const agent = await agentWithSession(ctx)
  283. header(agent.session)
  284. ctx.planMode.set(agent, true)
  285. await boundary(ctx, agent, 'turn/start')
  286. expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
  287. await boundary(ctx, agent, 'step/end')
  288. expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
  289. })
  290. it('narrates a switch back to the default mode with the default wording', async () => {
  291. const ctx = await setup()
  292. const agent = await agentWithSession(ctx)
  293. agent.session.append('plan/mode', { active: true })
  294. header(agent.session)
  295. ctx.planMode.set(agent, false)
  296. await boundary(ctx, agent, 'step/end')
  297. expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.'])
  298. })
  299. it('stays silent when the header already reflects the flushed mode', async () => {
  300. const ctx = await setup()
  301. const agent = await agentWithSession(ctx)
  302. agent.session.append('plan/mode', { active: true })
  303. header(agent.session)
  304. agent.session.append('plan/mode', { active: false })
  305. ctx.planMode.set(agent, true)
  306. await boundary(ctx, agent, 'step/end')
  307. expect(foldPlanMode(agent.session.events)).toBe(true)
  308. expect(noticeTexts(agent.session)).toEqual([])
  309. })
  310. it('contains an append failure instead of blocking the prompt or the turn', async () => {
  311. const ctx = await setup()
  312. const warn = vi.fn()
  313. ctx.logger.warn = warn as never
  314. const agent = await agentWithSession(ctx)
  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/end')
  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/end')
  331. expect(foldPlanMode(agent.session.events)).toBe(true)
  332. expect(ctx.planMode.get(agent).pending).toBeUndefined()
  333. })
  334. it('contains an append failure on the prompt-submit seam the same way', 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. 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, 'turn/start')
  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. const plainSteer = vi.fn()
  499. ;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer
  500. expect(ctx.commands.list(plainAgent)).toEqual([
  501. { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
  502. ])
  503. const signal = new AbortController().signal
  504. expect(await ctx.commands.execute(plainAgent, '/mode', signal)).toBeUndefined()
  505. expect(await ctx.commands.execute(plainAgent, '/review', signal)).toBeUndefined()
  506. const plain = await ctx.commands.execute(plainAgent, '/plan', signal)
  507. expect(plain).toEqual({
  508. kind: 'success',
  509. text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
  510. })
  511. expect(ctx.planMode.get(plainAgent)).toEqual({ active: false, pending: true })
  512. expect(plainSteer).not.toHaveBeenCalled()
  513. const messageAgent = await agentWithSession(ctx, 'message-plan-command')
  514. const messageSteer = vi.fn()
  515. ;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer
  516. const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', signal)
  517. expect(plan).toEqual({
  518. kind: 'success',
  519. text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
  520. })
  521. expect(ctx.planMode.get(messageAgent)).toEqual({ active: false, pending: true })
  522. expect(messageSteer).toHaveBeenCalledExactlyOnceWith([{ type: 'text', text: 'draft the migration' }])
  523. })
  524. it('leaves active plan mode, cancels a pending entry, and treats inactive exit as idempotent', async () => {
  525. const ctx = await setup()
  526. await ctx.plugin(CommandService)
  527. await new Promise(resolve => setImmediate(resolve))
  528. const signal = new AbortController().signal
  529. const inactive = await agentWithSession(ctx, 'inactive-plan-command')
  530. expect(await ctx.commands.execute(inactive, '/plan off', signal))
  531. .toEqual({ kind: 'success', text: 'Plan mode is already inactive.' })
  532. expect(ctx.planMode.get(inactive)).toEqual({ active: false })
  533. const entering = await agentWithSession(ctx, 'entering-plan-command')
  534. const enteringSteer = vi.fn()
  535. ;(entering as unknown as { steer: typeof enteringSteer }).steer = enteringSteer
  536. await ctx.commands.execute(entering, '/plan', signal)
  537. expect(await ctx.commands.execute(entering, '/plan off', signal))
  538. .toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' })
  539. expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false })
  540. expect(enteringSteer).not.toHaveBeenCalled()
  541. await boundary(ctx, entering, 'turn/start')
  542. expect(ctx.planMode.get(entering)).toEqual({ active: false })
  543. expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  544. const active = await agentWithSession(ctx, 'active-plan-command', { active: true })
  545. const activeSteer = vi.fn()
  546. ;(active as unknown as { steer: typeof activeSteer }).steer = activeSteer
  547. expect(await ctx.commands.execute(active, '/plan off', signal))
  548. .toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
  549. expect(ctx.planMode.get(active)).toEqual({ active: true, pending: false })
  550. expect(await ctx.commands.execute(active, '/plan off', signal))
  551. .toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
  552. expect(activeSteer).not.toHaveBeenCalled()
  553. await boundary(ctx, active, 'turn/start')
  554. expect(ctx.planMode.get(active)).toEqual({ active: false })
  555. })
  556. it('removes the contributed command when the plan-mode plugin is disposed', async () => {
  557. const ctx = new Context()
  558. await ctx.plugin(SystemPrompt)
  559. await ctx.plugin(ToolRegistry)
  560. await ctx.plugin(CommandService)
  561. const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
  562. await new Promise(resolve => setImmediate(resolve))
  563. const agent = await agentWithSession(ctx)
  564. expect(ctx.commands.list(agent).map(command => command.name)).toEqual(['plan'])
  565. await fiber.dispose()
  566. expect(ctx.commands.list(agent)).toEqual([])
  567. })
  568. })
  569. describe('exit_plan_mode', () => {
  570. async function setupWithReview(answer?: { selected: string[]; custom?: string }) {
  571. const ctx = await setup()
  572. await ctx.plugin(UserInteractionService)
  573. const asked: AskUserQuestionRequest[] = []
  574. if (answer !== undefined) {
  575. ctx.userInteraction.registerProvider({
  576. ask: (request) => {
  577. asked.push(request)
  578. return Promise.resolve({ answers: [{ id: 'plan-review', ...answer }] })
  579. },
  580. })
  581. }
  582. const agent = await agentWithSession(ctx, 'agent-1', { active: true })
  583. return { ctx, agent, asked }
  584. }
  585. function callExit(ctx: Context, agent: Agent | undefined, plan = '# The plan\n\ndo things') {
  586. return ctx.tools.execute({
  587. callId: CallId(`call-exit-${++callCounter}`),
  588. name: EXIT_PLAN_MODE,
  589. arguments: { plan },
  590. signal: new AbortController().signal,
  591. ...agent ? { agent } : {},
  592. })
  593. }
  594. it('registers the tool with one required plan argument', async () => {
  595. const ctx = await setup()
  596. const schema = ctx.tools.schemas().find(entry => entry.name === EXIT_PLAN_MODE)
  597. const parameters = schema?.parameters as { required?: string[]; properties?: Record<string, unknown> }
  598. expect(schema?.description).toMatch(/^Use only in plan mode\./)
  599. expect(Object.keys(parameters.properties ?? {})).toEqual(['plan'])
  600. expect(parameters.required).toEqual(['plan'])
  601. })
  602. it('rejects an agent-less call', async () => {
  603. const ctx = await setup()
  604. const result = await callExit(ctx, undefined)
  605. expect(result.isError).toBe(true)
  606. expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a calling agent (no session to switch)' }])
  607. })
  608. it('rejects a call outside plan mode while remaining advertised', async () => {
  609. const ctx = await setup()
  610. const agent = await agentWithSession(ctx)
  611. expect(ctx.tools.schemas().map(tool => tool.name)).toContain(EXIT_PLAN_MODE)
  612. const result = await callExit(ctx, agent)
  613. expect(result.isError).toBe(true)
  614. expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode is only available in plan mode' }])
  615. })
  616. it('rejects an empty or heading-less plan before asking the reviewer', async () => {
  617. const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
  618. for (const plan of ['', 'do things']) {
  619. const result = await callExit(ctx, agent, plan)
  620. expect(result.isError).toBe(true)
  621. expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a non-empty markdown plan starting with a # heading' }])
  622. }
  623. expect(asked).toHaveLength(0)
  624. expect(foldPlanMode(agent.session.events)).toBe(true)
  625. })
  626. it('degrades to the manual exit when no user-interaction seam is composed', async () => {
  627. const ctx = await setup()
  628. const agent = await agentWithSession(ctx, 'agent-1', { active: true })
  629. const result = await callExit(ctx, agent)
  630. expect(result.isError).toBe(true)
  631. 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' }])
  632. expect(foldPlanMode(agent.session.events)).toBe(true)
  633. })
  634. it('degrades the same way when the seam has no provider (NO_PROVIDER)', async () => {
  635. const { ctx, agent } = await setupWithReview()
  636. const result = await callExit(ctx, agent)
  637. expect(result.isError).toBe(true)
  638. expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction provider is registered' }])
  639. expect(foldPlanMode(agent.session.events)).toBe(true)
  640. })
  641. it('approve: records the boundary-applied switch and confirms (the fold flips at the flush)', async () => {
  642. const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
  643. const result = await callExit(ctx, agent)
  644. expect(result.isError).toBe(false)
  645. if (result.isError) throw new Error('expected approved plan result')
  646. expect(result.value).toEqual({ approved: true })
  647. expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }])
  648. // Boundary-applied, not a direct append: the fold stays plan until the
  649. // step's end, so the plan policy covers any remaining call of the SAME batch.
  650. expect(foldPlanMode(agent.session.events)).toBe(true)
  651. expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
  652. await boundary(ctx, agent, 'step/end')
  653. expect(foldPlanMode(agent.session.events)).toBe(false)
  654. expect(asked).toHaveLength(1)
  655. expect(asked[0]?.agent).toBe(agent)
  656. expect(asked[0]?.questions[0]?.detail).toBe('# The plan\n\ndo things')
  657. expect(asked[0]?.questions[0]?.options?.map(option => option.label)).toEqual(['Approve', 'Keep planning'])
  658. })
  659. it('carries the exact plan through a Code Mode review and logs the nested dispatch', async () => {
  660. const plan = '# Code Mode plan\n\nUse the existing seam.'
  661. class ExitRuntime extends CodeRuntime {
  662. readonly language = 'typescript'
  663. readonly isolation = 'fake'
  664. async run(request: CodeRunRequest): Promise<CodeRunResult> {
  665. const exit = request.bindings[0]?.functions[EXIT_PLAN_MODE]
  666. if (exit === undefined) throw new Error('missing exit_plan_mode binding')
  667. return { logs: [], value: await exit({ plan }) }
  668. }
  669. }
  670. const ctx = new Context()
  671. await ctx.plugin(SystemPrompt)
  672. await ctx.plugin(ToolRegistry, { mode: 'code' })
  673. await ctx.plugin(ExitRuntime)
  674. await ctx.plugin(PlanModeService, PLAN_CONFIG)
  675. await ctx.plugin(UserInteractionService)
  676. const asked: AskUserQuestionRequest[] = []
  677. ctx.userInteraction.registerProvider({
  678. ask: (request) => {
  679. asked.push(request)
  680. return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
  681. },
  682. })
  683. const agent = await agentWithSession(ctx, 'code-mode-exit', { active: true })
  684. const result = await ctx.tools.execute({
  685. callId: CallId(`call-exit-${++callCounter}`),
  686. name: RUN_CODE_NAME,
  687. arguments: { code: `return await tools.${EXIT_PLAN_MODE}({ plan: ${JSON.stringify(plan)} })` },
  688. signal: new AbortController().signal,
  689. agent,
  690. })
  691. expect(result.isError).toBe(false)
  692. expect(asked).toHaveLength(1)
  693. expect(asked[0]?.questions[0]).toMatchObject({
  694. header: 'Plan review',
  695. question: 'Approve this plan and leave plan mode?',
  696. detail: plan,
  697. })
  698. expect(agent.session.events.find(event => event.type === 'tool/code-dispatch')?.data).toMatchObject({
  699. name: EXIT_PLAN_MODE,
  700. arguments: { plan },
  701. isError: false,
  702. })
  703. expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
  704. })
  705. it('an approved exit keeps plan guidance until the boundary and never removes the tool', async () => {
  706. const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
  707. const approved = await callExit(ctx, agent)
  708. expect(approved.isError).toBe(false)
  709. // Calls of the SAME assistant response (no boundary between) were
  710. // requested under the plan-shaped header — the fold stays plan for that
  711. // whole batch; the boundary flush is what flips the next step.
  712. expect(foldPlanMode(agent.session.events)).toBe(true)
  713. const assembly = await ctx.systemPrompt.assemble({ agent })
  714. expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true)
  715. expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
  716. await boundary(ctx, agent, 'step/end')
  717. expect(foldPlanMode(agent.session.events)).toBe(false)
  718. const afterExit = await ctx.systemPrompt.assemble({ agent })
  719. expect(afterExit.tools).toEqual(assembly.tools)
  720. expect(afterExit.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
  721. })
  722. it('the exit flush narrates nothing — the tool result is the narration', async () => {
  723. const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
  724. header(agent.session)
  725. await callExit(ctx, agent)
  726. await boundary(ctx, agent, 'step/end')
  727. expect(foldPlanMode(agent.session.events)).toBe(false)
  728. expect(noticeTexts(agent.session)).toEqual([])
  729. })
  730. it('keep planning returns the corrective error carrying the feedback verbatim', async () => {
  731. const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'], custom: 'consider the resume path' })
  732. const result = await callExit(ctx, agent)
  733. expect(result.isError).toBe(true)
  734. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: consider the resume path' }])
  735. expect(foldPlanMode(agent.session.events)).toBe(true)
  736. })
  737. it('keep planning without feedback returns the generic corrective error', async () => {
  738. const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'] })
  739. const result = await callExit(ctx, agent)
  740. expect(result.isError).toBe(true)
  741. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
  742. })
  743. it('a custom-text-only answer is feedback, never consent', async () => {
  744. const { ctx, agent } = await setupWithReview({ selected: [], custom: 'add tests first' })
  745. const result = await callExit(ctx, agent)
  746. expect(result.isError).toBe(true)
  747. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: add tests first' }])
  748. expect(foldPlanMode(agent.session.events)).toBe(true)
  749. })
  750. it('requires exactly the single Approve selection', async () => {
  751. const { ctx, agent } = await setupWithReview({ selected: ['Approve', 'Keep planning'] })
  752. const result = await callExit(ctx, agent)
  753. expect(result.isError).toBe(true)
  754. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
  755. expect(foldPlanMode(agent.session.events)).toBe(true)
  756. })
  757. it('treats custom text alongside Approve as feedback, not consent', async () => {
  758. const { ctx, agent } = await setupWithReview({ selected: ['Approve'], custom: 'change the tests' })
  759. const result = await callExit(ctx, agent)
  760. expect(result.isError).toBe(true)
  761. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: change the tests' }])
  762. expect(foldPlanMode(agent.session.events)).toBe(true)
  763. })
  764. it('treats duplicate review answer items as non-consent', async () => {
  765. const { ctx, agent } = await setupWithReview()
  766. ctx.userInteraction.registerProvider({
  767. ask: () => Promise.resolve({ answers: [
  768. { id: 'plan-review', selected: ['Approve'] },
  769. { id: 'plan-review', selected: ['Keep planning'] },
  770. ] }),
  771. })
  772. const result = await callExit(ctx, agent)
  773. expect(result.isError).toBe(true)
  774. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
  775. expect(foldPlanMode(agent.session.events)).toBe(true)
  776. })
  777. it('a missing answer item reads as keep-planning', async () => {
  778. const { ctx, agent } = await setupWithReview()
  779. ctx.userInteraction.registerProvider({ ask: () => Promise.resolve({ answers: [] }) })
  780. const result = await callExit(ctx, agent)
  781. expect(result.isError).toBe(true)
  782. expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
  783. })
  784. it('forwards the execution abort signal to the review question', async () => {
  785. const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
  786. const controller = new AbortController()
  787. const result = await ctx.tools.execute({
  788. callId: CallId(`call-exit-${++callCounter}`),
  789. name: EXIT_PLAN_MODE,
  790. arguments: { plan: '# P' },
  791. agent,
  792. signal: controller.signal,
  793. })
  794. expect(result.isError).toBe(false)
  795. expect(asked[0]?.signal).toBe(controller.signal)
  796. })
  797. it('fails the call when the plugin is disposed while the review awaits (no phantom exit)', async () => {
  798. const ctx = new Context()
  799. await ctx.plugin(SystemPrompt)
  800. await ctx.plugin(ToolRegistry)
  801. const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
  802. await ctx.plugin(UserInteractionService)
  803. let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void
  804. ctx.userInteraction.registerProvider({
  805. ask: () => new Promise((resolve) => { answer = resolve }),
  806. })
  807. const agent = await agentWithSession(ctx, 'agent-1', { active: true })
  808. const pending = callExit(ctx, agent)
  809. // Let execute reach the review await, then unload the plugin (HMR) and
  810. // only afterwards approve. The boundary listeners are gone, so a success
  811. // would claim an exit that can never flush — the call must fail instead.
  812. await new Promise(resolve => setImmediate(resolve))
  813. await fiber.dispose()
  814. answer({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
  815. const result = await pending
  816. expect(result.isError).toBe(true)
  817. expect(result.content).toEqual([{ type: 'text', text: 'Error: the plan-mode service was reloaded while the plan was under review; present the plan again' }])
  818. expect(foldPlanMode(agent.session.events)).toBe(true)
  819. })
  820. it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => {
  821. const { ctx, agent } = await setupWithReview()
  822. ctx.userInteraction.registerProvider({ ask: () => { throw new Error('review aborted') } })
  823. const result = await callExit(ctx, agent)
  824. expect(result.isError).toBe(true)
  825. expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }])
  826. expect(foldPlanMode(agent.session.events)).toBe(true)
  827. })
  828. it('presents the call as a generic card titled by the plan first heading', async () => {
  829. const ctx = await setup()
  830. const def = ctx.tools.get(EXIT_PLAN_MODE)!
  831. expect(def.presentCall?.({ plan: '## Fix the flake\n\nsteps' })).toEqual({
  832. card: 'generic',
  833. title: 'Fix the flake',
  834. kind: 'other',
  835. content: [{ type: 'text', text: '## Fix the flake\n\nsteps' }],
  836. })
  837. expect(def.presentCall?.({ plan: 'no heading here' })).toEqual({
  838. card: 'generic',
  839. title: 'Plan',
  840. kind: 'other',
  841. content: [{ type: 'text', text: 'no heading here' }],
  842. })
  843. })
  844. it('presents the result as a generic review card', async () => {
  845. const ctx = await setup()
  846. const def = ctx.tools.get(EXIT_PLAN_MODE)!
  847. const content = [{ type: 'text' as const, text: 'ok' }]
  848. expect(def.presentResult?.({ plan: '# P' }, { content, isError: false })).toEqual({
  849. card: 'generic',
  850. title: 'Plan review',
  851. content,
  852. })
  853. })
  854. })
  855. describe('HMR disposal', () => {
  856. it('does not flush a retry boundary that resumes after plugin disposal', async () => {
  857. const ctx = new Context()
  858. await ctx.plugin(SystemPrompt)
  859. await ctx.plugin(ToolRegistry)
  860. const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
  861. const agent = await agentWithSession(ctx, 'disposed-in-flight-recovery')
  862. const recoveryEntered = Promise.withResolvers<true>()
  863. const releaseRecovery = Promise.withResolvers<true>()
  864. ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, _next) => {
  865. recoveryEntered.resolve(true)
  866. await releaseRecovery.promise
  867. return { action: 'retry' }
  868. })
  869. ctx.planMode.set(agent, true)
  870. const recovery = recoveryBoundary(ctx, agent, { action: 'fail' })
  871. await recoveryEntered.promise
  872. await fiber.dispose()
  873. releaseRecovery.resolve(true)
  874. expect(await recovery).toEqual({ action: 'retry' })
  875. expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  876. })
  877. it('unregisters the service, listeners, prompt section, and stable exit tool with the plugin fiber', async () => {
  878. const ctx = new Context()
  879. await ctx.plugin(SystemPrompt)
  880. await ctx.plugin(ToolRegistry)
  881. const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
  882. const agent = await agentWithSession(ctx, 'disposed-recovery')
  883. ctx.planMode.set(agent, true)
  884. expect(ctx.get('planMode')).toBeInstanceOf(PlanModeService)
  885. expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()
  886. expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).toContain('plan:policy')
  887. await fiber.dispose()
  888. expect(ctx.get('planMode')).toBeUndefined()
  889. expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
  890. expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy')
  891. expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
  892. expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  893. })
  894. })