scope-lifecycle.spec.ts 46 KB

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