plan-mode.spec.ts 56 KB

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