scope-lifecycle.spec.ts 44 KB

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