plan-mode.spec.ts 42 KB

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