scope-lifecycle.spec.ts 44 KB

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