plan-mode.spec.ts 51 KB

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