scope-lifecycle.spec.ts 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073
  1. import { describe, expect, it } from 'vitest'
  2. import { Context, symbols, type EffectMeta, type Fiber } from 'cordis'
  3. import LlmService from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  5. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  6. import ToolRegistry from '@deepseek-ai/dsh-tools'
  7. import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
  8. import type { Agent } from '@deepseek-ai/dsh-agent'
  9. import { scopeOf } from '@deepseek-ai/dsh-scope'
  10. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  11. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  12. import { MockAdapter, textResponse } from './mock-adapter.ts'
  13. async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<{ ctx: Context; loopFiber: Fiber }> {
  14. const ctx = new Context()
  15. await ctx.plugin(LlmService)
  16. await ctx.plugin(SessionStore)
  17. await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' })
  18. await ctx.plugin(ToolRegistry)
  19. await ctx.plugin(AgentRegistry)
  20. const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
  21. ctx.llm.registerAdapter(['mock'], adapter)
  22. return { ctx, loopFiber }
  23. }
  24. async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<Context> {
  25. return (await harnessWithLoop(adapter)).ctx
  26. }
  27. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  28. return new Promise((resolve) => {
  29. const dispose = ctx.on('agent/status', (subject, status) => {
  30. if (subject === agent && status === 'idle') {
  31. dispose()
  32. resolve()
  33. }
  34. })
  35. })
  36. }
  37. const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
  38. /** Throw an arbitrary callback value to exercise the public unknown-error boundary. */
  39. function throwUnknown(value: unknown): never {
  40. throw value
  41. }
  42. /** Invoke the exact lifecycle effect to exercise same-stack reentrant teardown. */
  43. function disposeCurrentLifecycle(ownerCtx: Context): void {
  44. const lifecycle = [...ownerCtx.fiber._disposables]
  45. .find((dispose) => {
  46. const effect = (dispose as typeof dispose & { [symbols.effect]?: EffectMeta })[symbols.effect]
  47. return effect?.label.startsWith('agentLoop.lifecycle(') === true
  48. })
  49. if (lifecycle === undefined) throw new Error('agent lifecycle effect not found')
  50. void lifecycle()
  51. }
  52. describe('agent scope lifecycle', () => {
  53. it('rejects an already-aborted creation signal before publishing either object', async () => {
  54. const ctx = await harness()
  55. const reason = new Error('cancelled before creation')
  56. const controller = new AbortController()
  57. controller.abort(reason)
  58. await expect(ctx.agents.create({
  59. sessionId: SessionId('pre-aborted-s'),
  60. signal: controller.signal,
  61. })).rejects.toBe(reason)
  62. expect(ctx.agents.get(SessionId('pre-aborted-s'))).toBeUndefined()
  63. expect(ctx.sessions.get(SessionId('pre-aborted-s'))).toBeUndefined()
  64. const valueController = new AbortController()
  65. valueController.abort('plain cancellation reason')
  66. await expect(ctx.agents.create({
  67. sessionId: SessionId('pre-aborted-value-s'),
  68. signal: valueController.signal,
  69. })).rejects.toMatchObject({
  70. message: 'agent "pre-aborted-value-s" creation aborted',
  71. cause: 'plain cancellation reason',
  72. })
  73. expect(ctx.agents.get(SessionId('pre-aborted-value-s'))).toBeUndefined()
  74. expect(ctx.sessions.get(SessionId('pre-aborted-value-s'))).toBeUndefined()
  75. await ctx.fiber.dispose()
  76. })
  77. it('joins cleanup when an abort lands reentrantly during scope preparation', async () => {
  78. const ctx = await harness()
  79. const reason = new Error('cancelled while preparing')
  80. const controller = new AbortController()
  81. let aborted = false
  82. ctx.on('internal/plugin', (fiber) => {
  83. if (aborted || fiber.name !== 'scope') return
  84. aborted = true
  85. controller.abort(reason)
  86. })
  87. await expect(ctx.agents.create({
  88. sessionId: SessionId('prepare-abort-s'),
  89. signal: controller.signal,
  90. })).rejects.toBe(reason)
  91. expect(ctx.agents.get(SessionId('prepare-abort-s'))).toBeUndefined()
  92. expect(ctx.sessions.get(SessionId('prepare-abort-s'))).toBeUndefined()
  93. await ctx.fiber.dispose()
  94. })
  95. it('normalizes non-Error create failures for rollback while rethrowing the original value', async () => {
  96. const ctx = await harness()
  97. let thrown: unknown
  98. ctx.on('session/created', () => {
  99. if (thrown === undefined) return
  100. const value = thrown
  101. thrown = undefined
  102. throwUnknown(value)
  103. })
  104. const createFailure = { source: 'create' }
  105. thrown = createFailure
  106. let createCaught: unknown
  107. try {
  108. ctx.agentLoop.create(SessionId('unknown-create'))
  109. } catch (error: unknown) {
  110. createCaught = error
  111. }
  112. expect(createCaught).toBe(createFailure)
  113. const ownedFailure = { source: 'createAgent' }
  114. thrown = ownedFailure
  115. await expect(ctx.agents.create({
  116. sessionId: SessionId('unknown-owned-create-s'),
  117. })).rejects.toBe(ownedFailure)
  118. expect(ctx.agents.get(SessionId('unknown-create'))).toBeUndefined()
  119. expect(ctx.agents.get(SessionId('unknown-owned-create-s'))).toBeUndefined()
  120. await ctx.fiber.dispose()
  121. })
  122. it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
  123. const ctx = await harness()
  124. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  125. expect(scopeOf(agent.ctx)).toBe(agent)
  126. expect(agent.ctx.agent).toBe(agent)
  127. // The root accessor default: a plain context answers undefined, not a throw.
  128. expect(ctx.agent).toBeUndefined()
  129. await ctx.agents.get(SessionId('a1'))?.whenIdle()
  130. })
  131. it('records agents created through an agent context as non-root runtime children', async () => {
  132. const ctx = await harness()
  133. const root = await ctx.agents.create({
  134. sessionId: SessionId('runtime-root'),
  135. agentOptions: { model: 'mock' },
  136. })
  137. const child = await root.agent.ctx.agents.create({
  138. sessionId: SessionId('runtime-child'),
  139. agentOptions: { model: 'mock' },
  140. })
  141. expect(ctx.agents.list()).toEqual([root.agent, child.agent])
  142. expect(ctx.agents.roots()).toEqual([root.agent])
  143. await child.dispose()
  144. await root.dispose()
  145. })
  146. it('scoped registrations live in the agent world and die with the agent', async () => {
  147. const ctx = await harness()
  148. const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
  149. const { agent } = handle
  150. agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
  151. agent.ctx.tools.register({
  152. name: 'mine', description: 'scoped', parameters: {},
  153. execute: () => Promise.resolve(text('ran')),
  154. })
  155. const scopedAssembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
  156. expect(scopedAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You run tests.')
  157. expect(scopedAssembly.tools.map(t => t.name)).toContain('mine')
  158. // Other assemblies are untouched.
  159. const globalAssembly = await ctx.systemPrompt.assemble()
  160. expect(globalAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.')
  161. expect(globalAssembly.tools.map(t => t.name)).not.toContain('mine')
  162. await handle.dispose()
  163. // The scoped world unwound with the agent: nothing leaked into the registries.
  164. expect(ctx.tools.get('mine', agent)).toBeUndefined()
  165. const after = await ctx.systemPrompt.assemble(assembleContextFor(agent))
  166. expect(after.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.')
  167. })
  168. it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => {
  169. const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')]))
  170. const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
  171. const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' })
  172. const heard: string[] = []
  173. a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`))
  174. a.ctx.on('session/event', (_s, event) => {
  175. if (event.type === 'user/message') heard.push('a-sees:user-message')
  176. })
  177. b.send(text('for b'))
  178. await waitForIdle(ctx, b)
  179. expect(heard).toEqual([]) // nothing of b's leaked into a's scope
  180. a.send(text('for a'))
  181. await waitForIdle(ctx, a)
  182. expect(heard).toContain('a-sees:a:running')
  183. expect(heard).toContain('a-sees:user-message')
  184. })
  185. it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => {
  186. const ctx = await harness()
  187. const order: string[] = []
  188. ctx.on('agent/session-start', (agent) => {
  189. order.push('session-start')
  190. // The scoped section is already registered by the time session-start fires.
  191. void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => {
  192. order.push(`persona:${assembly.sections.find(s => s.name === 'deployment:persona')?.text}`)
  193. })
  194. })
  195. const handle = await ctx.agents.create({
  196. sessionId: SessionId('child-s'),
  197. agentOptions: { provider: 'mock', model: 'mock' },
  198. setup: async (agentCtx) => {
  199. order.push('setup')
  200. await Promise.resolve()
  201. agentCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the child.' })
  202. },
  203. })
  204. await new Promise(resolve => setTimeout(resolve, 0))
  205. expect(order).toEqual(['setup', 'session-start', 'persona:You are the child.'])
  206. await handle.dispose()
  207. })
  208. it('keeps both objects unpublished until async setup completes, then announces in order', async () => {
  209. const ctx = await harness()
  210. const gate = Promise.withResolvers<undefined>()
  211. const setupStarted = Promise.withResolvers<undefined>()
  212. const order: string[] = []
  213. ctx.on('session/created', (session) => {
  214. expect(ctx.sessions.get(session.id)).toBe(session)
  215. expect(ctx.agents.get(session.id)?.session).toBe(session)
  216. order.push('session/created')
  217. })
  218. ctx.on('agent/created', () => void order.push('agent/created'))
  219. ctx.on('agent/session-start', () => void order.push('agent/session-start'))
  220. const acceptedOptions = { provider: 'mock', model: 'mock' }
  221. const creating = ctx.agents.create({
  222. sessionId: SessionId('atomic'),
  223. agentOptions: acceptedOptions,
  224. setup: async (agentCtx) => {
  225. expect(agentCtx.agent?.id).toBe(SessionId('atomic'))
  226. agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
  227. agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
  228. order.push('setup:start')
  229. setupStarted.resolve(undefined)
  230. await gate.promise
  231. order.push('setup:end')
  232. },
  233. })
  234. await setupStarted.promise
  235. expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined()
  236. expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined()
  237. expect(order).toEqual(['setup:start'])
  238. gate.resolve(undefined)
  239. const handle = await creating
  240. expect(handle.agent.options).toBe(acceptedOptions)
  241. expect(order).toEqual([
  242. 'setup:start',
  243. 'setup:end',
  244. 'session/created',
  245. 'setup-listener:session/created',
  246. 'agent/created',
  247. 'setup-listener:agent/created',
  248. 'agent/session-start',
  249. ])
  250. await handle.dispose()
  251. })
  252. it('lets the final enter arbitrate unsupported concurrent same-id creation and rolls the loser back', async () => {
  253. const ctx = await harness()
  254. const gate = Promise.withResolvers<undefined>()
  255. const bothStarted = Promise.withResolvers<undefined>()
  256. let started = 0
  257. const setup = async (): Promise<void> => {
  258. started += 1
  259. if (started === 2) bothStarted.resolve(undefined)
  260. await gate.promise
  261. }
  262. const sessionId = SessionId('concurrent-final-enter')
  263. const first = ctx.agents.create({
  264. sessionId,
  265. agentOptions: { provider: 'mock', model: 'mock' },
  266. setup,
  267. })
  268. const second = ctx.agents.create({
  269. sessionId,
  270. agentOptions: { provider: 'mock', model: 'mock' },
  271. setup,
  272. })
  273. await bothStarted.promise
  274. expect(ctx.agents.list()).toEqual([])
  275. expect(ctx.sessions.list()).toEqual([])
  276. gate.resolve(undefined)
  277. const outcomes = await Promise.allSettled([first, second])
  278. const fulfilled = outcomes.filter((outcome): outcome is PromiseFulfilledResult<Awaited<typeof first>> => outcome.status === 'fulfilled')
  279. const rejected = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')
  280. expect(fulfilled).toHaveLength(1)
  281. expect(rejected).toHaveLength(1)
  282. expect(String(rejected[0]!.reason)).toMatch(/already exists/)
  283. expect(ctx.agents.list()).toEqual([fulfilled[0]!.value.agent])
  284. expect(ctx.sessions.list()).toEqual([fulfilled[0]!.value.agent.session])
  285. await fulfilled[0]!.value.dispose()
  286. expect(ctx.agents.list()).toEqual([])
  287. expect(ctx.sessions.list()).toEqual([])
  288. })
  289. it('uses signal only for creation: aborts pending setup but not a returned live handle', async () => {
  290. const ctx = await harness()
  291. const pendingController = new AbortController()
  292. const setupStarted = Promise.withResolvers<undefined>()
  293. const pending = ctx.agents.create({
  294. sessionId: SessionId('signal-pending-s'),
  295. agentOptions: { provider: 'mock', model: 'mock' },
  296. signal: pendingController.signal,
  297. setup: async () => {
  298. setupStarted.resolve(undefined)
  299. await new Promise<never>(() => {})
  300. },
  301. })
  302. await setupStarted.promise
  303. pendingController.abort(new Error('cancel pending creation'))
  304. await expect(pending).rejects.toThrow('cancel pending creation')
  305. expect(ctx.agents.get(SessionId('signal-pending-s'))).toBeUndefined()
  306. expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined()
  307. const liveController = new AbortController()
  308. const live = await ctx.agents.create({
  309. sessionId: SessionId('signal-live-s'),
  310. agentOptions: { provider: 'mock', model: 'mock' },
  311. signal: liveController.signal,
  312. })
  313. liveController.abort(new Error('too late'))
  314. await Promise.resolve()
  315. expect(ctx.agents.get(live.agent.id)).toBe(live.agent)
  316. expect(live.agent.status).toBe('idle')
  317. await live.dispose()
  318. })
  319. it('owner unload aborts a pending setup and publishes nothing', async () => {
  320. const ctx = await harness()
  321. const gate = Promise.withResolvers<undefined>()
  322. const setupStarted = Promise.withResolvers<undefined>()
  323. const published: string[] = []
  324. ctx.on('session/created', () => void published.push('session/created'))
  325. ctx.on('agent/created', () => void published.push('agent/created'))
  326. let creating!: ReturnType<typeof ctx.agents.create>
  327. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  328. creating = inner.agents.create({
  329. sessionId: SessionId('owner-race-s'),
  330. agentOptions: { provider: 'mock', model: 'mock' },
  331. setup: async () => {
  332. setupStarted.resolve(undefined)
  333. await gate.promise
  334. },
  335. })
  336. }, { inject: ['agents'] }))
  337. await setupStarted.promise
  338. await owner.dispose()
  339. await expect(creating).rejects.toThrow(/owner disposed during setup/)
  340. expect(published).toEqual([])
  341. expect(ctx.agents.get(SessionId('owner-race-s'))).toBeUndefined()
  342. expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined()
  343. // Let the losing callback settle; Promise.race already observes it.
  344. gate.resolve(undefined)
  345. await Promise.resolve()
  346. // The other ordering in the same race: setup resolves first (its reaction
  347. // is queued), then owner disposal flips active before that continuation can
  348. // publish. The post-race active check must still reject.
  349. const gate2 = Promise.withResolvers<undefined>()
  350. const setupStarted2 = Promise.withResolvers<undefined>()
  351. let creating2!: ReturnType<typeof ctx.agents.create>
  352. const owner2 = await ctx.plugin(Object.assign((inner: Context) => {
  353. creating2 = inner.agents.create({
  354. sessionId: SessionId('owner-race-s-2'),
  355. agentOptions: { provider: 'mock', model: 'mock' },
  356. setup: async () => {
  357. setupStarted2.resolve(undefined)
  358. await gate2.promise
  359. },
  360. })
  361. }, { inject: ['agents'] }))
  362. await setupStarted2.promise
  363. gate2.resolve(undefined)
  364. const unload2 = owner2.dispose()
  365. await expect(creating2).rejects.toThrow(/owner disposed during setup/)
  366. await unload2
  367. expect(ctx.agents.get(SessionId('owner-race-s-2'))).toBeUndefined()
  368. expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined()
  369. })
  370. it('an AgentLoop unload aborts pending setup, awaits cleanup, and releases both ids', async () => {
  371. const { ctx, loopFiber } = await harnessWithLoop()
  372. const gate = Promise.withResolvers<undefined>()
  373. const setupStarted = Promise.withResolvers<undefined>()
  374. const published: string[] = []
  375. ctx.on('session/created', () => void published.push('session/created'))
  376. ctx.on('agent/created', () => void published.push('agent/created'))
  377. const creating = ctx.agents.create({
  378. sessionId: SessionId('factory-setup-race-s'),
  379. agentOptions: { provider: 'mock', model: 'mock' },
  380. setup: async () => {
  381. setupStarted.resolve(undefined)
  382. await gate.promise
  383. },
  384. })
  385. await setupStarted.promise
  386. await loopFiber.dispose()
  387. await expect(creating).rejects.toThrow(/agent loop is not active/)
  388. expect(published).toEqual([])
  389. expect(ctx.agents.get(SessionId('factory-setup-race-s'))).toBeUndefined()
  390. expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined()
  391. gate.resolve(undefined)
  392. await ctx.fiber.dispose()
  393. })
  394. it('factory unload during scope minting skips setup and awaits provisional cleanup', async () => {
  395. const { ctx, loopFiber } = await harnessWithLoop()
  396. let unloaded = false
  397. let setupCalls = 0
  398. ctx.on('internal/plugin', (fiber) => {
  399. if (unloaded || fiber.name !== 'scope') return
  400. unloaded = true
  401. void loopFiber.dispose()
  402. })
  403. const creating = ctx.agents.create({
  404. sessionId: SessionId('factory-scope-race-s'),
  405. agentOptions: { provider: 'mock', model: 'mock' },
  406. setup: () => { setupCalls += 1 },
  407. })
  408. await expect(creating).rejects.toThrow(/agent loop is not active/)
  409. await loopFiber.dispose()
  410. expect(setupCalls).toBe(0)
  411. expect(ctx.agents.get(SessionId('factory-scope-race-s'))).toBeUndefined()
  412. expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined()
  413. await ctx.fiber.dispose()
  414. })
  415. it('caller unload during scope minting owns and drains the half-built child', async () => {
  416. const ctx = await harness()
  417. const gate = Promise.withResolvers<undefined>()
  418. const cleanupStarted = Promise.withResolvers<undefined>()
  419. let ownerFiber!: Fiber
  420. let ownerDisposal!: Promise<void>
  421. let scopeFiber: Fiber | undefined
  422. let creating!: ReturnType<typeof ctx.agents.create>
  423. ctx.on('internal/plugin', (fiber) => {
  424. if (fiber.name !== 'scope' || scopeFiber !== undefined) return
  425. scopeFiber = fiber
  426. fiber.ctx.effect(() => async () => {
  427. cleanupStarted.resolve(undefined)
  428. await gate.promise
  429. })
  430. ownerDisposal = ownerFiber.dispose()
  431. })
  432. const owner = ctx.plugin(Object.assign((inner: Context) => {
  433. ownerFiber = inner.fiber
  434. creating = inner.agents.create({
  435. sessionId: SessionId('caller-scope-race-s'),
  436. agentOptions: { provider: 'mock', model: 'mock' },
  437. })
  438. }, { inject: ['agents'] }))
  439. await cleanupStarted.promise
  440. let ownerSettled = false
  441. void ownerDisposal.then(() => { ownerSettled = true })
  442. await Promise.resolve()
  443. expect(ownerSettled).toBe(false)
  444. gate.resolve(undefined)
  445. await expect(creating).rejects.toThrow(/owner disposed during setup/)
  446. await ownerDisposal
  447. await owner
  448. expect(scopeFiber?.uid).toBeNull()
  449. expect(ctx.agents.get(SessionId('caller-scope-race-s'))).toBeUndefined()
  450. expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined()
  451. await owner.dispose()
  452. await ctx.fiber.dispose()
  453. })
  454. it('synchronous create rechecks provider liveness before its first publication edge', async () => {
  455. const { ctx, loopFiber } = await harnessWithLoop()
  456. const sessionsBefore = ctx.sessions.list().length
  457. let unloaded = false
  458. ctx.on('internal/plugin', (fiber) => {
  459. if (unloaded || fiber.name !== 'scope') return
  460. unloaded = true
  461. void loopFiber.dispose()
  462. })
  463. expect(() => ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' }))
  464. .toThrow(/agent loop is not active/)
  465. await loopFiber.dispose()
  466. expect(ctx.agents.get(SessionId('config-scope-race'))).toBeUndefined()
  467. expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
  468. await ctx.fiber.dispose()
  469. })
  470. it('synchronous create leaves no lifecycle state when session preparation fails', async () => {
  471. const ctx = await harness()
  472. const id = SessionId('config-prepare-failure')
  473. expect(() => ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: 'relative' }))
  474. .toThrow(/absolute path/)
  475. const replacement = ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: '/recovered' })
  476. expect(ctx.agents.get(id)).toBe(replacement)
  477. await replacement.whenIdle()
  478. await ctx.fiber.dispose()
  479. })
  480. it('factory unload awaits provisional cleanup when scope preparation throws', async () => {
  481. const { ctx, loopFiber } = await harnessWithLoop()
  482. let triggered = false
  483. ctx.on('internal/plugin', (fiber) => {
  484. if (triggered || fiber.name !== 'scope') return
  485. triggered = true
  486. void loopFiber.dispose()
  487. throw new Error('scope preparation failed')
  488. })
  489. await expect(ctx.agents.create({
  490. sessionId: SessionId('factory-scope-throw-s'),
  491. agentOptions: { provider: 'mock', model: 'mock' },
  492. })).rejects.toThrow('scope preparation failed')
  493. await loopFiber.dispose()
  494. expect(ctx.agents.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
  495. expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
  496. await ctx.fiber.dispose()
  497. })
  498. it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => {
  499. const { ctx, loopFiber } = await harnessWithLoop()
  500. const loop = ctx.agentLoop
  501. const sessionId = SessionId('factory-live')
  502. const handle = await ctx.agents.create({
  503. sessionId: SessionId('factory-live-s'),
  504. agentOptions: { provider: 'mock', model: 'mock' },
  505. })
  506. await loopFiber.dispose()
  507. expect(handle.agent.status).toBe('disposed')
  508. expect(ctx.agents.get(sessionId)).toBeUndefined()
  509. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  510. expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([])
  511. // The consumer handle shares the provider's completed quiescence boundary.
  512. await handle.dispose()
  513. await expect(loop.createAgent(ctx, {
  514. sessionId: SessionId('factory-inactive-s'),
  515. })).rejects.toThrow('agent loop is not active')
  516. await ctx.fiber.dispose()
  517. })
  518. it('keeps AgentLoop dependencies available when the caller injects only agents', async () => {
  519. const ctx = await harness()
  520. let creating!: ReturnType<typeof ctx.agents.create>
  521. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  522. creating = inner.agents.create({
  523. sessionId: SessionId('dependency-origin-s'),
  524. agentOptions: { provider: 'mock', model: 'mock' },
  525. setup: (agentCtx) => {
  526. agentCtx.tools.register({
  527. name: 'dependency-origin-tool',
  528. description: 'proves AgentLoop dependency origin',
  529. parameters: {},
  530. execute: () => Promise.resolve(text('ok')),
  531. })
  532. agentCtx.systemPrompt.section({
  533. name: 'dependency-origin-section',
  534. order: 1,
  535. text: 'factory dependency surface',
  536. })
  537. },
  538. })
  539. }, { inject: ['agents'] }))
  540. const handle = await creating
  541. const assembly = await ctx.systemPrompt.assemble(assembleContextFor(handle.agent))
  542. expect(assembly.tools.map(tool => tool.name)).toContain('dependency-origin-tool')
  543. expect(assembly.sections.map(section => section.name)).toContain('dependency-origin-section')
  544. await handle.dispose()
  545. await owner.dispose()
  546. await ctx.fiber.dispose()
  547. })
  548. it('keeps both entries and the scope live through a reentrant session/created teardown', async () => {
  549. const ctx = await harness()
  550. let ownerCtx!: Context
  551. let creating!: ReturnType<typeof ctx.agents.create>
  552. const lifecycle: string[] = []
  553. ctx.on('session/created', (session) => {
  554. if (session.id !== SessionId('session-created-barrier-s')) return
  555. lifecycle.push('session-created:dispose')
  556. disposeCurrentLifecycle(ownerCtx)
  557. })
  558. ctx.on('session/created', (session) => {
  559. if (session.id !== SessionId('session-created-barrier-s')) return
  560. const agent = ctx.agents.get(SessionId('session-created-barrier-s'))!
  561. expect(ctx.sessions.get(session.id)).toBe(session)
  562. expect(agent.session).toBe(session)
  563. agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
  564. lifecycle.push('session-created:observer')
  565. })
  566. ctx.on('agent/created', () => void lifecycle.push('agent-created'))
  567. ctx.on('agent/disposed', () => void lifecycle.push('agent-disposed'))
  568. ctx.on('session/disposed', (session) => {
  569. if (session.id === SessionId('session-created-barrier-s')) lifecycle.push('session-disposed')
  570. })
  571. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  572. ownerCtx = inner
  573. creating = inner.agents.create({
  574. sessionId: SessionId('session-created-barrier-s'),
  575. agentOptions: { provider: 'mock', model: 'mock' },
  576. })
  577. }, { inject: ['agents'] }))
  578. await expect(creating).rejects.toThrow(/lifecycle disposed/)
  579. await owner.dispose()
  580. expect(lifecycle).toEqual([
  581. 'session-created:dispose',
  582. 'session-created:observer',
  583. 'session-disposed',
  584. 'scope-disposed',
  585. ])
  586. expect(ctx.agents.get(SessionId('session-created-barrier-s'))).toBeUndefined()
  587. expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined()
  588. await ctx.fiber.dispose()
  589. })
  590. it('keeps both entries and the scope live through a reentrant agent/created teardown', async () => {
  591. const ctx = await harness()
  592. let ownerCtx!: Context
  593. let creating!: ReturnType<typeof ctx.agents.create>
  594. const lifecycle: string[] = []
  595. ctx.on('session/created', (session) => {
  596. if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created')
  597. })
  598. ctx.on('agent/created', (agent) => {
  599. if (agent.id !== SessionId('agent-created-barrier-s')) return
  600. lifecycle.push('agent-created:dispose')
  601. disposeCurrentLifecycle(ownerCtx)
  602. })
  603. ctx.on('agent/created', (agent) => {
  604. if (agent.id !== SessionId('agent-created-barrier-s')) return
  605. expect(ctx.agents.get(agent.id)).toBe(agent)
  606. expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
  607. agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
  608. lifecycle.push('agent-created:observer')
  609. })
  610. ctx.on('agent/disposed', (agent) => {
  611. if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed')
  612. })
  613. ctx.on('session/disposed', (session) => {
  614. if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed')
  615. })
  616. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  617. ownerCtx = inner
  618. creating = inner.agents.create({
  619. sessionId: SessionId('agent-created-barrier-s'),
  620. agentOptions: { provider: 'mock', model: 'mock' },
  621. })
  622. }, { inject: ['agents'] }))
  623. await expect(creating).rejects.toThrow(/lifecycle disposed/)
  624. await owner.dispose()
  625. expect(lifecycle).toEqual([
  626. 'session-created',
  627. 'agent-created:dispose',
  628. 'agent-created:observer',
  629. 'agent-disposed',
  630. 'session-disposed',
  631. 'scope-disposed',
  632. ])
  633. expect(ctx.agents.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
  634. expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
  635. await ctx.fiber.dispose()
  636. })
  637. it('rechecks caller liveness after creation listeners before unlocking the driver', async () => {
  638. const ctx = await harness()
  639. const starts: string[] = []
  640. let ownerCtx!: Context
  641. let creating!: ReturnType<typeof ctx.agents.create>
  642. ctx.on('agent/session-start', agent => void starts.push(agent.id))
  643. ctx.on('agent/created', (agent) => {
  644. if (agent.id === SessionId('listener-dispose-s')) void ownerCtx.fiber.dispose()
  645. })
  646. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  647. ownerCtx = inner
  648. creating = inner.agents.create({
  649. sessionId: SessionId('listener-dispose-s'),
  650. agentOptions: { provider: 'mock', model: 'mock' },
  651. })
  652. }, { inject: ['agents'] }))
  653. await expect(creating).rejects.toThrow(/owner disposed during setup/)
  654. await owner.dispose()
  655. expect(starts).toEqual([])
  656. expect(ctx.agents.get(SessionId('listener-dispose-s'))).toBeUndefined()
  657. expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined()
  658. await ctx.fiber.dispose()
  659. })
  660. it('rechecks caller liveness after session-start before starting the driver', async () => {
  661. const ctx = await harness()
  662. let ownerCtx!: Context
  663. let creating!: ReturnType<typeof ctx.agents.create>
  664. let announced!: Agent
  665. const statuses: string[] = []
  666. let scopeDisposed = false
  667. let observerSawLive = false
  668. ctx.on('agent/status', (agent, status) => {
  669. if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status)
  670. })
  671. ctx.on('agent/session-start', (agent) => {
  672. if (agent.id !== SessionId('session-start-dispose-s')) return
  673. announced = agent
  674. disposeCurrentLifecycle(ownerCtx)
  675. })
  676. ctx.on('agent/session-start', (agent) => {
  677. if (agent.id !== SessionId('session-start-dispose-s')) return
  678. expect(ctx.agents.get(agent.id)).toBe(agent)
  679. expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
  680. agent.ctx.effect(() => () => { scopeDisposed = true })
  681. observerSawLive = true
  682. })
  683. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  684. ownerCtx = inner
  685. creating = inner.agents.create({
  686. sessionId: SessionId('session-start-dispose-s'),
  687. agentOptions: { provider: 'mock', model: 'mock' },
  688. })
  689. }, { inject: ['agents'] }))
  690. await expect(creating).rejects.toThrow(/lifecycle disposed/)
  691. await owner.dispose()
  692. expect(announced.status).toBe('disposed')
  693. expect(statuses).toEqual(['disposed'])
  694. expect(observerSawLive).toBe(true)
  695. expect(scopeDisposed).toBe(true)
  696. expect(announced.session.events).toEqual([])
  697. expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined()
  698. expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined()
  699. await ctx.fiber.dispose()
  700. })
  701. it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => {
  702. const ctx = await harness()
  703. const published: string[] = []
  704. ctx.on('session/created', () => void published.push('session/created'))
  705. ctx.on('agent/created', () => void published.push('agent/created'))
  706. ctx.on('agent/session-start', () => void published.push('agent/session-start'))
  707. await expect(ctx.agents.create({
  708. sessionId: SessionId('bad-s'),
  709. agentOptions: { provider: 'mock', model: 'mock' },
  710. setup: async () => {
  711. await Promise.resolve()
  712. throw new Error('boom setup')
  713. },
  714. })).rejects.toThrow('boom setup')
  715. // Nothing leaked: no agent, no session, and the ids are reusable.
  716. expect(published).toEqual([])
  717. expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined()
  718. expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
  719. const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } })
  720. await retry.dispose()
  721. })
  722. it('rejects an exotic durable seed before publishing either object', async () => {
  723. const ctx = await harness()
  724. const published: string[] = []
  725. ctx.on('session/created', () => { published.push('session') })
  726. ctx.on('agent/created', () => { published.push('agent') })
  727. class ExoticData { readonly value = 'not durable JSON' }
  728. const seed = [{
  729. seq: 0,
  730. type: 'test/exotic-seed',
  731. data: new ExoticData(),
  732. }] as unknown as SessionEvent[]
  733. await expect(ctx.agents.create({
  734. sessionId: SessionId('exotic-seed-session'),
  735. agentOptions: { provider: 'mock', model: 'mock' },
  736. seed,
  737. })).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/)
  738. expect(published).toEqual([])
  739. expect(ctx.agents.get(SessionId('exotic-seed-session'))).toBeUndefined()
  740. expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined()
  741. const retry = await ctx.agents.create({
  742. sessionId: SessionId('exotic-seed-session'),
  743. agentOptions: { provider: 'mock', model: 'mock' },
  744. })
  745. await retry.dispose()
  746. })
  747. it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
  748. const ctx = await harness()
  749. let boom = true
  750. const disposed: string[] = []
  751. ctx.on('agent/disposed', agent => void disposed.push(agent.id))
  752. ctx.on('session/created', () => {
  753. if (boom) { boom = false; throw new Error('boom created') }
  754. })
  755. await expect(ctx.agents.create({
  756. sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' },
  757. })).rejects.toThrow('boom created')
  758. expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined()
  759. expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
  760. expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge
  761. // The rollback also disposed the scope fiber: re-creating works cleanly.
  762. const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } })
  763. expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
  764. await retry.dispose()
  765. })
  766. it('pairs session and agent announcements when agent creation aborts publication', async () => {
  767. const ctx = await harness()
  768. const lifecycle: string[] = []
  769. ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) })
  770. ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) })
  771. ctx.on('agent/created', (agent) => {
  772. lifecycle.push(`agent-created:${agent.id}`)
  773. throw new Error('agent observer failed')
  774. })
  775. ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) })
  776. await expect(ctx.agents.create({
  777. sessionId: SessionId('partial-session'),
  778. agentOptions: { provider: 'mock', model: 'mock' },
  779. })).rejects.toThrow('agent observer failed')
  780. expect(lifecycle).toEqual([
  781. 'session-created:partial-session',
  782. 'agent-created:partial-session',
  783. 'agent-disposed:partial-session',
  784. 'session-disposed:partial-session',
  785. ])
  786. expect(ctx.agents.get(SessionId('partial-session'))).toBeUndefined()
  787. expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined()
  788. })
  789. it('the synchronous config helper rolls back when publication throws', async () => {
  790. const ctx = await harness()
  791. const sessionsBefore = ctx.sessions.list().length
  792. let boom = true
  793. ctx.on('session/created', () => {
  794. if (boom) {
  795. boom = false
  796. throw new Error('config publish failed')
  797. }
  798. })
  799. expect(() => ctx.agentLoop.create(SessionId('config-bad'), { provider: 'mock', model: 'mock' }))
  800. .toThrow('config publish failed')
  801. expect(ctx.agents.get(SessionId('config-bad'))).toBeUndefined()
  802. expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
  803. })
  804. it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
  805. const ctx = await harness()
  806. const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
  807. await handle.dispose()
  808. expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
  809. })
  810. it('agentEvents fuses carrier and subject for custom drivers', async () => {
  811. const ctx = await harness()
  812. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  813. const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' })
  814. const heard: string[] = []
  815. agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`))
  816. agentEvents(ctx, other).emit('agent/error', 1, 0, new Error('not for a1'))
  817. agentEvents(ctx, agent).emit('agent/error', 2, 0, new Error('for a1'))
  818. expect(heard).toEqual(['a1:2'])
  819. })
  820. it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
  821. const ctx = await harness()
  822. let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
  823. const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
  824. handle = await inner.agents.create({ sessionId: SessionId('o1-s'), agentOptions: { provider: 'mock', model: 'mock' } })
  825. }, { inject: ['agents'] }))
  826. const { agent } = handle
  827. const order: string[] = []
  828. ctx.on('session/event', (_s, event) => {
  829. if (event.type === 'turn/end') order.push('turn-end')
  830. })
  831. ctx.on('agent/disposed', () => {
  832. order.push(`disposed(listed=${ctx.agents.get(SessionId('o1-s')) !== undefined})`)
  833. order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
  834. })
  835. // Open a turn so disposal must drain real work before registry removal.
  836. // Waiting for turn/start avoids pre-step disposal dropping the queued prompt
  837. // before a turn opens.
  838. const turnOpen = new Promise<void>((resolve) => {
  839. const off = ctx.on('session/event', (_s, event) => {
  840. if (event.type === 'turn/start') { off(); resolve() }
  841. })
  842. })
  843. agent.send(text('work'))
  844. await turnOpen
  845. await owner.dispose()
  846. expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true'])
  847. expect(ctx.sessions.get(SessionId('o1-s'))).toBeUndefined()
  848. })
  849. it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => {
  850. const ctx = await harness()
  851. let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
  852. const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
  853. handle = await inner.agents.create({ sessionId: SessionId('h1-s'), agentOptions: { provider: 'mock', model: 'mock' } })
  854. }, { inject: ['agents'] }))
  855. const teardownDone: string[] = []
  856. ctx.on('agent/disposed', () => void teardownDone.push('unregistered'))
  857. // Owner unload begins FIRST (invokes the raw cordis wrapper)…
  858. const unload = owner.dispose()
  859. // …and a concurrent handle.dispose() must not resolve before the chain
  860. // actually finished (the raw wrapper returns undefined on a repeat call).
  861. await handle.dispose()
  862. expect(teardownDone).toContain('unregistered')
  863. expect(ctx.agents.get(SessionId('h1-s'))).toBeUndefined()
  864. expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined()
  865. await unload
  866. })
  867. it('successful handle disposal retires its caller ownership effect', async () => {
  868. const ctx = await harness()
  869. const sessionId = SessionId('retired-owner-effect')
  870. const handle = await ctx.agents.create({
  871. sessionId,
  872. agentOptions: { provider: 'mock', model: 'mock' },
  873. })
  874. expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${sessionId})`)
  875. await handle.dispose()
  876. expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([])
  877. await ctx.fiber.dispose()
  878. })
  879. it('owner unload after handle-first teardown follows the same in-flight boundary', async () => {
  880. const ctx = await harness()
  881. const gate = Promise.withResolvers<undefined>()
  882. const cleanupStarted = Promise.withResolvers<undefined>()
  883. let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
  884. const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
  885. handle = await inner.agents.create({
  886. sessionId: SessionId('manual-first-s'),
  887. agentOptions: { provider: 'mock', model: 'mock' },
  888. setup(agentCtx) {
  889. agentCtx.effect(() => async () => {
  890. cleanupStarted.resolve(undefined)
  891. await gate.promise
  892. })
  893. },
  894. })
  895. }, { inject: ['agents'] }))
  896. const disposing = handle.dispose()
  897. await cleanupStarted.promise
  898. let ownerSettled = false
  899. const unloading = owner.dispose().then(() => { ownerSettled = true })
  900. await Promise.resolve()
  901. expect(ownerSettled).toBe(false)
  902. gate.resolve(undefined)
  903. await Promise.all([disposing, unloading])
  904. expect(ctx.agents.get(SessionId('manual-first-s'))).toBeUndefined()
  905. expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined()
  906. await ctx.fiber.dispose()
  907. })
  908. it('reopens ids after detach while the prior private scope finishes quiescing', async () => {
  909. const ctx = await harness()
  910. const gate = Promise.withResolvers<undefined>()
  911. const cleanupStarted = Promise.withResolvers<undefined>()
  912. const sessionDisposed = Promise.withResolvers<undefined>()
  913. const sessionId = SessionId('quiescent-reuse')
  914. ctx.on('session/disposed', (session) => {
  915. if (session.id === sessionId) sessionDisposed.resolve(undefined)
  916. })
  917. const first = await ctx.agents.create({
  918. sessionId,
  919. agentOptions: { provider: 'mock', model: 'mock' },
  920. setup(agentCtx) {
  921. agentCtx.effect(() => async () => {
  922. cleanupStarted.resolve(undefined)
  923. await gate.promise
  924. })
  925. },
  926. })
  927. const disposing = first.dispose()
  928. await Promise.all([sessionDisposed.promise, cleanupStarted.promise])
  929. expect(ctx.agents.get(sessionId)).toBeUndefined()
  930. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  931. const replacement = await ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
  932. expect(ctx.agents.get(sessionId)).toBe(replacement.agent)
  933. expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session)
  934. gate.resolve(undefined)
  935. await disposing
  936. await replacement.dispose()
  937. await ctx.fiber.dispose()
  938. })
  939. it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => {
  940. const ctx = await harness()
  941. const handle = await ctx.agents.create({
  942. sessionId: SessionId('idle-flush-s'),
  943. agentOptions: { provider: 'mock', model: 'mock' },
  944. })
  945. const gate = Promise.withResolvers<undefined>()
  946. let flushStarted = false
  947. ctx.on('session/flush', (session) => {
  948. if (session !== handle.agent.session) return
  949. flushStarted = true
  950. return gate.promise
  951. })
  952. handle.agent.inject(text('durable idle context'), { source: { kind: 'plugin', plugin: 'test' } })
  953. expect(flushStarted).toBe(true)
  954. let disposed = false
  955. const disposal = handle.dispose().then(() => { disposed = true })
  956. await new Promise(resolve => setTimeout(resolve, 0))
  957. expect(disposed).toBe(false)
  958. expect(ctx.agents.get(SessionId('idle-flush-s'))).toBe(handle.agent)
  959. expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBe(handle.agent.session)
  960. gate.resolve(undefined)
  961. await disposal
  962. expect(ctx.agents.get(SessionId('idle-flush-s'))).toBeUndefined()
  963. expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBeUndefined()
  964. })
  965. })