approval.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import type { Agent } from '@deepseek-ai/dsh-agent'
  4. import { ToolCallId } from '@deepseek-ai/dsh-llm'
  5. import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope'
  6. import type { Scope } from '@deepseek-ai/dsh-scope'
  7. import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
  8. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  9. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  10. import ApprovalService, { ApprovalOutcome, ApprovalRequest, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
  11. /**
  12. * A minimal Agent stand-in — the service only reaches `agent.session.append`
  13. * and indexed log reads. Seeded inside an open turn by default (request()'s
  14. * turn-enclosure precondition); pass `seed` to stage idle/closed logs.
  15. * Returns the recorded audit appends alongside the fake.
  16. */
  17. function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record<string, unknown> }> } {
  18. const appended: Array<{ type: string; data: Record<string, unknown> }> = []
  19. const events: Array<{ type: string; data?: Record<string, unknown> }> = [...seed]
  20. const agent = {
  21. session: {
  22. get seq() { return events.length },
  23. eventAt: (seq: number) => events[seq],
  24. append: (type: string, data: Record<string, unknown>) => {
  25. const event = { type, data }
  26. events.push(event)
  27. appended.push(event)
  28. return event as unknown as SessionEvent
  29. },
  30. },
  31. } as unknown as Agent
  32. return { agent, appended }
  33. }
  34. async function mounted(): Promise<Context> {
  35. const ctx = new Context()
  36. await ctx.plugin(ApprovalService)
  37. return ctx
  38. }
  39. function requestOf(agent: Agent, overrides: Partial<ApprovalRequest> = {}): ApprovalRequest {
  40. return { agent, toolName: 'echo', ...overrides }
  41. }
  42. describe('ApprovalService.request', () => {
  43. it('throws before appending anything when no turn has ever opened (idle ask)', async () => {
  44. const ctx = await mounted()
  45. const { agent, appended } = fakeAgent([])
  46. await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/)
  47. expect(appended).toHaveLength(0)
  48. })
  49. it('throws between turns — a closed turn does not satisfy the enclosure precondition', async () => {
  50. const ctx = await mounted()
  51. const { agent, appended } = fakeAgent([{ type: 'turn/start' }, { type: 'turn/end' }])
  52. await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/)
  53. expect(appended).toHaveLength(0)
  54. })
  55. it('fails closed to unavailable when nobody listens, auditing the asked/decided pair', async () => {
  56. const ctx = await mounted()
  57. const { agent, appended } = fakeAgent()
  58. const outcome = await ctx.approval.request(requestOf(agent, { callId: ToolCallId('call-1'), reason: 'hook says ask' }))
  59. expect(outcome).toBe('unavailable')
  60. expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
  61. const [asked, decided] = appended
  62. expect(asked?.data).toMatchObject({ toolName: 'echo', callId: 'call-1', reason: 'hook says ask' })
  63. expect(decided?.data).toMatchObject({ outcome: 'unavailable' })
  64. expect(decided?.data['id']).toBe(asked?.data['id'])
  65. })
  66. it('omits absent optional fields from the asked audit event', async () => {
  67. const ctx = await mounted()
  68. const { agent, appended } = fakeAgent()
  69. await ctx.approval.request(requestOf(agent))
  70. expect(Object.keys(appended[0]?.data ?? {}).sort()).toEqual(['id', 'toolName'])
  71. })
  72. it('borrows the exact readonly request for scoped dispatch and audit', async () => {
  73. const ctx = await mounted()
  74. const { agent, appended } = fakeAgent()
  75. let scope!: Scope
  76. const scopeFiber = await ctx.plugin(Object.assign((inner: Context) => {
  77. scope = createScope(inner, agent)
  78. }, { inject: ['approval'] }))
  79. let received: ApprovalRequest | undefined
  80. let carrier: unknown
  81. scope.ctx.on('approval/request', function (req) {
  82. received = req
  83. carrier = carrierKeyOf(this)
  84. return Promise.resolve<ApprovalOutcome>('allowed-once')
  85. })
  86. const request = requestOf(agent, {
  87. toolName: 'scoped-tool',
  88. callId: ToolCallId('scoped-call'),
  89. reason: 'scoped reason',
  90. })
  91. await expect(ctx.approval.request(request)).resolves.toBe('allowed-once')
  92. expect(carrier).toBe(agent)
  93. expect(received).toBe(request)
  94. expect(appended).toHaveLength(2)
  95. expect(appended[0]?.data).toMatchObject({
  96. toolName: 'scoped-tool',
  97. callId: 'scoped-call',
  98. reason: 'scoped reason',
  99. })
  100. expect(appended[1]?.data).toMatchObject({ outcome: 'allowed-once' })
  101. expect(appended[1]?.data['id']).toBe(appended[0]?.data['id'])
  102. await scopeFiber.dispose()
  103. })
  104. it('contains an approval/asked observer throw after append and still completes the pair', async () => {
  105. const ctx = new Context()
  106. await ctx.plugin(SessionStore)
  107. await ctx.plugin(ApprovalService)
  108. const session = ctx.sessions.create(SessionId('asked-observer-throw'))
  109. session.append('turn/start', { turn: 1 })
  110. const agent = { session } as unknown as Agent
  111. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  112. ctx.on('session/event', (_session, event) => {
  113. if (event.type === 'approval/asked') throw new Error('observer failed after asked append')
  114. })
  115. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  116. await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
  117. const audit = session.snapshotEvents().filter(event => event.type.startsWith('approval/'))
  118. const asked = session.snapshotEvents().find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked')
  119. const decided = session.snapshotEvents().find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided')
  120. expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
  121. expect(decided?.data.id).toBe(asked?.data.id)
  122. expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after asked append'))
  123. })
  124. it('contains an approval/decided observer throw after append and still resolves', async () => {
  125. const ctx = new Context()
  126. await ctx.plugin(SessionStore)
  127. await ctx.plugin(ApprovalService)
  128. const session = ctx.sessions.create(SessionId('decided-observer-throw'))
  129. session.append('turn/start', { turn: 1 })
  130. const agent = { session } as unknown as Agent
  131. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  132. ctx.on('session/event', (_session, event) => {
  133. if (event.type === 'approval/decided') throw new Error('observer failed after decided append')
  134. })
  135. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
  136. await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected')
  137. const audit = session.snapshotEvents().filter(event => event.type.startsWith('approval/'))
  138. const asked = session.snapshotEvents().find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked')
  139. const decided = session.snapshotEvents().find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided')
  140. expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
  141. expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' })
  142. expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after decided append'))
  143. })
  144. it('propagates an append failure that prevented audit log growth', async () => {
  145. const ctx = await mounted()
  146. const failure = new Error('append failed before log growth')
  147. const agent = {
  148. session: {
  149. seq: 1,
  150. eventAt: () => ({ type: 'turn/start' }),
  151. append: () => { throw failure },
  152. },
  153. } as unknown as Agent
  154. await expect(ctx.approval.request(requestOf(agent))).rejects.toBe(failure)
  155. })
  156. it('returns the first answering listener outcome (single decision slot)', async () => {
  157. const ctx = await mounted()
  158. const { agent } = fakeAgent()
  159. let secondRan = false
  160. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  161. ctx.on('approval/request', () => {
  162. secondRan = true
  163. return Promise.resolve<ApprovalOutcome>('rejected')
  164. })
  165. await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
  166. expect(secondRan).toBe(false)
  167. })
  168. it('lets a non-owning listener delegate via next() down to the fail-closed default', async () => {
  169. const ctx = await mounted()
  170. const { agent } = fakeAgent()
  171. ctx.on('approval/request', (_req, next) => next())
  172. await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
  173. })
  174. it('dispatches to global and matching agent-scoped listeners, never a foreign scope', async () => {
  175. const ctx = await mounted()
  176. const { agent: agentA } = fakeAgent()
  177. const { agent: agentB } = fakeAgent()
  178. let scopeA!: Scope
  179. let scopeB!: Scope
  180. const scopesFiber = await ctx.plugin(Object.assign((inner: Context) => {
  181. scopeA = createScope(inner, agentA)
  182. scopeB = createScope(inner, agentB)
  183. }, { inject: ['approval'] }))
  184. const heard: string[] = []
  185. ctx.on('approval/request', (req, next) => {
  186. heard.push(req.agent === agentA ? 'global:A' : 'global:B')
  187. return next()
  188. })
  189. scopeA.ctx.on('approval/request', (_req, next) => {
  190. heard.push('scoped:A')
  191. return next()
  192. })
  193. scopeB.ctx.on('approval/request', (_req, next) => {
  194. heard.push('scoped:B')
  195. return next()
  196. })
  197. await expect(ctx.approval.request(requestOf(agentA))).resolves.toBe('unavailable')
  198. await expect(ctx.approval.request(requestOf(agentB))).resolves.toBe('unavailable')
  199. expect(heard).toEqual(['global:A', 'scoped:A', 'global:B', 'scoped:B'])
  200. await scopesFiber.dispose()
  201. })
  202. it('keys the scoped dispatch carrier to the exact request agent', async () => {
  203. const ctx = await mounted()
  204. const { agent } = fakeAgent()
  205. let scope!: Scope
  206. const scopeFiber = await ctx.plugin(Object.assign((inner: Context) => {
  207. scope = createScope(inner, agent)
  208. }, { inject: ['approval'] }))
  209. let seenKey: object | undefined
  210. scope.ctx.on('approval/request', function (req, next) {
  211. seenKey = carrierKeyOf(this)
  212. expect(req.agent).toBe(agent)
  213. return next()
  214. })
  215. await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
  216. expect(seenKey).toBe(agent)
  217. await scopeFiber.dispose()
  218. })
  219. it('contains a throwing answerer as unavailable', async () => {
  220. const ctx = await mounted()
  221. const { agent, appended } = fakeAgent()
  222. ctx.on('approval/request', () => Promise.reject(new Error('transport died')))
  223. await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
  224. expect(appended[1]?.data).toMatchObject({ outcome: 'unavailable' })
  225. })
  226. it('normalizes a rogue non-vocabulary answer to unavailable', async () => {
  227. const ctx = await mounted()
  228. const { agent } = fakeAgent()
  229. // A JS answerer can return anything; the seam must not leak it into
  230. // callers' closed-union switches.
  231. ctx.on('approval/request', () => Promise.resolve('yolo' as ApprovalOutcome))
  232. await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
  233. })
  234. it('settles cancelled immediately on an already-aborted signal without asking anyone', async () => {
  235. const ctx = await mounted()
  236. const { agent, appended } = fakeAgent()
  237. let asked = false
  238. ctx.on('approval/request', () => {
  239. asked = true
  240. return Promise.resolve<ApprovalOutcome>('allowed-once')
  241. })
  242. const outcome = await ctx.approval.request(requestOf(agent, { signal: AbortSignal.abort() }))
  243. expect(outcome).toBe('cancelled')
  244. expect(asked).toBe(false)
  245. expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
  246. expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' })
  247. })
  248. it('resolves cancelled when the signal aborts mid-question and discards the late answer', async () => {
  249. const ctx = await mounted()
  250. const { agent, appended } = fakeAgent()
  251. let settleLate: ((outcome: ApprovalOutcome) => void) | undefined
  252. ctx.on('approval/request', () => new Promise<ApprovalOutcome>((resolve) => { settleLate = resolve }))
  253. const controller = new AbortController()
  254. const pending = ctx.approval.request(requestOf(agent, { signal: controller.signal }))
  255. controller.abort()
  256. await expect(pending).resolves.toBe('cancelled')
  257. // The answerer settles after the fact: no second decided event appears.
  258. settleLate?.('allowed-once')
  259. await Promise.resolve()
  260. expect(appended.filter(e => e.type === 'approval/decided')).toHaveLength(1)
  261. expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' })
  262. })
  263. it('discards a late REJECTION after abort without an unhandled rejection', async () => {
  264. const ctx = await mounted()
  265. const { agent } = fakeAgent()
  266. let rejectLate: ((error: Error) => void) | undefined
  267. ctx.on('approval/request', () => new Promise<ApprovalOutcome>((_resolve, reject) => { rejectLate = reject }))
  268. const controller = new AbortController()
  269. const pending = ctx.approval.request(requestOf(agent, { signal: controller.signal }))
  270. controller.abort()
  271. await expect(pending).resolves.toBe('cancelled')
  272. rejectLate?.(new Error('answered too late'))
  273. // Drain microtasks: the contained rejection must not escape the seam.
  274. await new Promise((resolve) => { setTimeout(resolve, 0) })
  275. })
  276. it('resolves the answer when the signal never aborts', async () => {
  277. const ctx = await mounted()
  278. const { agent } = fakeAgent()
  279. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
  280. const controller = new AbortController()
  281. await expect(ctx.approval.request(requestOf(agent, { signal: controller.signal }))).resolves.toBe('rejected')
  282. })
  283. it('issues a fresh id per request', async () => {
  284. const ctx = await mounted()
  285. const { agent, appended } = fakeAgent()
  286. await ctx.approval.request(requestOf(agent))
  287. await ctx.approval.request(requestOf(agent))
  288. const ids = appended.filter(e => e.type === 'approval/asked').map(e => e.data['id'])
  289. expect(ids).toHaveLength(2)
  290. expect(ids[0]).not.toBe(ids[1])
  291. })
  292. it('drops a disposed plugin listener from the chain (HMR safety)', async () => {
  293. const ctx = await mounted()
  294. const { agent } = fakeAgent()
  295. const fiber = await ctx.plugin((inner: Context) => {
  296. inner.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  297. })
  298. await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
  299. await fiber.dispose()
  300. await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
  301. })
  302. })
  303. describe('approval policy (the approval/policy fold)', () => {
  304. const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
  305. const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
  306. /**
  307. * An agent stand-in over a REAL Session — gate and context fold real events;
  308. * the opened turn satisfies request()'s enclosure precondition.
  309. */
  310. function sessionAgent(id: string): { agent: Agent; session: Session } {
  311. const session = Session.create(SessionId(id))
  312. session.append('turn/start', { turn: 1 })
  313. const agent = { id, session } as unknown as Agent
  314. return { agent, session }
  315. }
  316. it('folds to the last event, or undefined without one', () => {
  317. const service = new ApprovalService(new Context(), {})
  318. const { session } = sessionAgent('sess-fold')
  319. expect(service.overrideOf(session)).toBeUndefined()
  320. setApprovalPolicy(session, 'never')
  321. setApprovalPolicy(session, 'ask')
  322. expect(service.overrideOf(session)).toBe('ask')
  323. expect(session.snapshotEvents().at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } })
  324. })
  325. it('rejects a policy outside the closed vocabulary before appending', () => {
  326. const append = vi.fn()
  327. const session = { append } as unknown as Session
  328. expect(() => { setApprovalPolicy(session, 'sometimes' as Parameters<typeof setApprovalPolicy>[1]) })
  329. .toThrow('approval policy must be one of "ask" or "never"')
  330. expect(append).not.toHaveBeenCalled()
  331. })
  332. it('defaults a schema-less construction to ask (the ?? narrows the optional TYPE)', async () => {
  333. // Direct construction bypasses the plugin schema (the SystemPrompt-test
  334. // precedent for covering a defaulted Config field's type-narrowing ??).
  335. const ctx = new Context()
  336. const service = new ApprovalService(ctx, {})
  337. const { agent } = sessionAgent('sess-bare-config')
  338. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  339. await expect(service.request({ agent, toolName: 'echo' })).resolves.toBe('allowed-once')
  340. })
  341. it('contains an answerer that throws SYNCHRONOUSLY as unavailable', async () => {
  342. const ctx = new Context()
  343. await ctx.plugin(ApprovalService)
  344. const { agent } = sessionAgent('sess-syncthrow')
  345. ctx.on('approval/request', () => { throw new Error('sync bug') })
  346. await expect(ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('unavailable')
  347. })
  348. it('a never config rejects deterministically without consulting any answerer', async () => {
  349. const ctx = new Context()
  350. await ctx.plugin(ApprovalService, { policy: 'never' })
  351. const consulted = vi.fn()
  352. ctx.on('approval/request', (_req, next) => { consulted(); return next() })
  353. const { agent, session } = sessionAgent('sess-gate-1')
  354. await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
  355. expect(consulted).not.toHaveBeenCalled()
  356. // The audit pair still lands on the session log.
  357. expect(session.snapshotEvents().filter(e => e.type === 'approval/asked')).toHaveLength(1)
  358. expect(session.snapshotEvents().filter(e => e.type === 'approval/decided')).toHaveLength(1)
  359. })
  360. it('the gate decides FIRST even against an answerer registered before the service (prepend)', async () => {
  361. const ctx = new Context()
  362. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  363. await ctx.plugin(ApprovalService, { policy: 'never' })
  364. const { agent } = sessionAgent('sess-gate-2')
  365. await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
  366. })
  367. it('never is unbypassable even by an answerer PREPENDED after the service mounts', async () => {
  368. // Cordis prepend unshifts ahead of every existing listener, including any gate LISTENER the
  369. // service could register — which is exactly why the 'never' decision lives inside request()
  370. // instead. This eager grant would bypass a listener-based gate and therefore must never run.
  371. const ctx = new Context()
  372. await ctx.plugin(ApprovalService, { policy: 'never' })
  373. const consulted = vi.fn()
  374. ctx.on('approval/request', () => { consulted(); return Promise.resolve<ApprovalOutcome>('allowed-once') }, { prepend: true })
  375. const { agent, appended } = fakeAgent()
  376. await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected')
  377. expect(consulted).not.toHaveBeenCalled()
  378. expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
  379. })
  380. it('a session override outranks the configured default, in both directions', async () => {
  381. const ctx = new Context()
  382. await ctx.plugin(ApprovalService, { policy: 'never' })
  383. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  384. const { agent, session } = sessionAgent('sess-gate-3')
  385. expect(ctx.approval.overrideOf(session)).toBeUndefined()
  386. setApprovalPolicy(session, 'ask')
  387. expect(ctx.approval.overrideOf(session)).toBe('ask')
  388. await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('allowed-once')
  389. setApprovalPolicy(session, 'never')
  390. await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
  391. })
  392. it('queues a live policy switch for the next model step', async () => {
  393. const ctx = new Context()
  394. await ctx.plugin(ApprovalService)
  395. const { agent, session } = sessionAgent('sess-policy-notice')
  396. const inject = vi.fn<Agent['inject']>()
  397. const liveAgent = { ...agent, inject } as Agent
  398. ctx.approval.setPolicy(liveAgent, 'never')
  399. ctx.approval.setPolicy(liveAgent, 'never')
  400. expect(ctx.approval.overrideOf(session)).toBe('never')
  401. expect(inject).toHaveBeenCalledOnce()
  402. expect(inject.mock.calls[0]?.[0]).toMatchObject({
  403. content: [{
  404. type: 'text',
  405. text: 'The approval policy changed from "ask" to "never" (changed by the user).',
  406. }],
  407. source: { kind: 'plugin', plugin: 'user-approval' },
  408. })
  409. })
  410. it('contributes the complete current ask or never policy as cache-safe context', async () => {
  411. const ctx = new Context()
  412. await ctx.plugin(SystemPrompt)
  413. await ctx.plugin(ApprovalService)
  414. const askAgent = sessionAgent('sess-sect-ask').agent
  415. const { agent: neverAgent, session } = sessionAgent('sess-sect-never')
  416. setApprovalPolicy(session, 'never')
  417. const contextFor = async (context: object) =>
  418. (await ctx.systemPrompt.assemble(context)).contexts.find(entry => entry.name === 'approval:policy')?.text
  419. expect(await contextFor({ agent: askAgent })).toBe(ASK_SENTENCE)
  420. expect(await contextFor({ agent: neverAgent })).toBe(NEVER_SENTENCE)
  421. // A bare assemble (no agent) has no session to state.
  422. expect(await contextFor({})).toBe('')
  423. })
  424. it('reflects the latest durable switch in cache-safe context and stays byte-stable while unchanged', async () => {
  425. const ctx = new Context()
  426. await ctx.plugin(SystemPrompt)
  427. await ctx.plugin(ApprovalService)
  428. const { agent, session } = sessionAgent('sess-context-switch')
  429. const contextFor = async () =>
  430. (await ctx.systemPrompt.assemble({ agent })).contexts.find(entry => entry.name === 'approval:policy')?.text
  431. expect(await contextFor()).toBe(ASK_SENTENCE)
  432. expect(await contextFor()).toBe(ASK_SENTENCE)
  433. setApprovalPolicy(session, 'never')
  434. setApprovalPolicy(session, 'ask')
  435. setApprovalPolicy(session, 'never')
  436. expect(await contextFor()).toBe(NEVER_SENTENCE)
  437. expect(await contextFor()).toBe(NEVER_SENTENCE)
  438. })
  439. it('disposes the runtime-context contribution with the service', async () => {
  440. const ctx = new Context()
  441. await ctx.plugin(SystemPrompt)
  442. const fiber = await ctx.plugin(ApprovalService)
  443. const { agent } = sessionAgent('sess-hmr-service-live')
  444. const contextFor = async () =>
  445. (await ctx.systemPrompt.assemble({ agent })).contexts.find(context => context.name === 'approval:policy')
  446. expect(await contextFor()).toBeDefined()
  447. await fiber.dispose()
  448. expect(await contextFor()).toBeUndefined()
  449. })
  450. })