scope-lifecycle.spec.ts 46 KB

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