scope-lifecycle.spec.ts 45 KB

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