scope-lifecycle.spec.ts 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125
  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('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', 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. 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-prefix', 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-prefix')?.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-prefix')?.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-prefix')?.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 = await ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
  198. const b = await 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-prefix')?.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-prefix', 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('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. // The mid-setup unload races publication: create either rejects or its
  500. // published agent is torn straight back down — both leave no state.
  501. await ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' })
  502. .then(() => undefined, () => undefined)
  503. await unloading
  504. expect(ctx.agents.get(SessionId('config-scope-race')) === undefined).toBe(true)
  505. expect(ctx.sessions.list().length).toBe(sessionsBefore)
  506. await ctx.fiber.dispose()
  507. })
  508. it('create leaves no lifecycle state when session preparation fails', async () => {
  509. const ctx = await harness()
  510. const id = SessionId('config-prepare-failure')
  511. await expect(ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: 'relative' }))
  512. .rejects.toThrow(/absolute path/)
  513. const replacement = await ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: '/recovered' })
  514. expect(ctx.agents.get(id)).toBe(replacement)
  515. await replacement.whenIdle()
  516. await ctx.fiber.dispose()
  517. })
  518. it('factory unload awaits provisional cleanup when scope preparation throws', async () => {
  519. const { ctx, loopFiber } = await harnessWithLoop()
  520. let triggered = false
  521. ctx.on('internal/plugin', (fiber) => {
  522. if (triggered || fiber.name !== 'scope') return
  523. triggered = true
  524. void loopFiber.dispose()
  525. throw new Error('scope preparation failed')
  526. })
  527. await expect(ctx.agents.create({
  528. sessionId: SessionId('factory-scope-throw-s'),
  529. agentOptions: { provider: 'mock', model: 'mock' },
  530. })).rejects.toThrow('scope preparation failed')
  531. await loopFiber.dispose()
  532. expect(ctx.agents.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
  533. expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
  534. await ctx.fiber.dispose()
  535. })
  536. it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => {
  537. const { ctx, loopFiber } = await harnessWithLoop()
  538. const loop = ctx.agentLoop
  539. const sessionId = SessionId('factory-live')
  540. const handle = await ctx.agents.create({
  541. sessionId: SessionId('factory-live-s'),
  542. agentOptions: { provider: 'mock', model: 'mock' },
  543. })
  544. await loopFiber.dispose()
  545. expect(handle.agent.status).toBe('idle')
  546. expect(ctx.agents.get(sessionId)).toBeUndefined()
  547. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  548. expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.lifecycle(${sessionId})`)).toEqual([])
  549. // The consumer handle shares the provider's completed quiescence boundary.
  550. await handle.dispose()
  551. await expect(loop.createAgent(ctx, {
  552. sessionId: SessionId('factory-inactive-s'),
  553. })).rejects.toThrow(/agent loop is not active|inactive context/)
  554. await ctx.fiber.dispose()
  555. })
  556. it('keeps AgentLoop dependencies available when the caller injects only agents', async () => {
  557. const ctx = await harness()
  558. let creating!: ReturnType<typeof ctx.agents.create>
  559. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  560. creating = inner.agents.create({
  561. sessionId: SessionId('dependency-origin-s'),
  562. agentOptions: { provider: 'mock', model: 'mock' },
  563. setup: (agentCtx) => {
  564. agentCtx.tools.register(defineContentToolFixture({
  565. name: 'dependency-origin-tool',
  566. description: 'proves AgentLoop dependency origin',
  567. parameters: {},
  568. execute: () => Promise.resolve(text('ok')),
  569. }))
  570. agentCtx.systemPrompt.section({
  571. name: 'dependency-origin-section',
  572. order: 1,
  573. text: 'factory dependency API',
  574. })
  575. },
  576. })
  577. }, { inject: ['agents'] }))
  578. const handle = await creating
  579. const assembly = await ctx.systemPrompt.assemble(assembleContextFor(handle.agent))
  580. expect(assembly.tools.map(tool => tool.name)).toContain('dependency-origin-tool')
  581. expect(assembly.sections.map(section => section.name)).toContain('dependency-origin-section')
  582. await handle.dispose()
  583. await owner.dispose()
  584. await ctx.fiber.dispose()
  585. })
  586. it('keeps both entries and the scope live through a reentrant session/created teardown', async () => {
  587. const ctx = await harness()
  588. let ownerCtx!: Context
  589. let creating!: ReturnType<typeof ctx.agents.create>
  590. const lifecycle: string[] = []
  591. ctx.on('session/created', (session) => {
  592. if (session.id !== SessionId('session-created-barrier-s')) return
  593. lifecycle.push('session-created:dispose')
  594. disposeCurrentLifecycle(ownerCtx)
  595. })
  596. ctx.on('session/created', (session) => {
  597. if (session.id !== SessionId('session-created-barrier-s')) return
  598. const agent = ctx.agents.get(SessionId('session-created-barrier-s'))!
  599. expect(ctx.sessions.get(session.id)).toBe(session)
  600. expect(agent.session).toBe(session)
  601. agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
  602. lifecycle.push('session-created:observer')
  603. })
  604. ctx.on('agent/created', () => void lifecycle.push('agent-created'))
  605. ctx.on('agent/disposed', () => void lifecycle.push('agent-disposed'))
  606. ctx.on('session/disposed', (session) => {
  607. if (session.id === SessionId('session-created-barrier-s')) lifecycle.push('session-disposed')
  608. })
  609. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  610. ownerCtx = inner
  611. creating = inner.agents.create({
  612. sessionId: SessionId('session-created-barrier-s'),
  613. agentOptions: { provider: 'mock', model: 'mock' },
  614. })
  615. }, { inject: ['agents'] }))
  616. await expect(creating).rejects.toThrow(/owner disposed during setup/)
  617. await owner.dispose()
  618. expect(lifecycle).toEqual([
  619. 'session-created:dispose',
  620. 'session-created:observer',
  621. 'scope-disposed',
  622. 'session-disposed',
  623. ])
  624. expect(ctx.agents.get(SessionId('session-created-barrier-s'))).toBeUndefined()
  625. expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined()
  626. await ctx.fiber.dispose()
  627. })
  628. it('keeps both entries and the scope live through a reentrant agent/created teardown', async () => {
  629. const ctx = await harness()
  630. let ownerCtx!: Context
  631. let creating!: ReturnType<typeof ctx.agents.create>
  632. const lifecycle: string[] = []
  633. ctx.on('session/created', (session) => {
  634. if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created')
  635. })
  636. ctx.on('agent/created', ({ agent }) => {
  637. if (agent.id !== SessionId('agent-created-barrier-s')) return
  638. lifecycle.push('agent-created:dispose')
  639. disposeCurrentLifecycle(ownerCtx)
  640. })
  641. ctx.on('agent/created', ({ agent }) => {
  642. if (agent.id !== SessionId('agent-created-barrier-s')) return
  643. expect(ctx.agents.get(agent.id)).toBe(agent)
  644. expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
  645. agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
  646. lifecycle.push('agent-created:observer')
  647. })
  648. ctx.on('agent/disposed', ({ agent }) => {
  649. if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed')
  650. })
  651. ctx.on('session/disposed', (session) => {
  652. if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed')
  653. })
  654. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  655. ownerCtx = inner
  656. creating = inner.agents.create({
  657. sessionId: SessionId('agent-created-barrier-s'),
  658. agentOptions: { provider: 'mock', model: 'mock' },
  659. })
  660. }, { inject: ['agents'] }))
  661. await expect(creating).rejects.toThrow(/owner disposed during setup/)
  662. await owner.dispose()
  663. expect(lifecycle).toEqual([
  664. 'session-created',
  665. 'agent-created:dispose',
  666. 'agent-created:observer',
  667. 'scope-disposed',
  668. 'agent-disposed',
  669. 'session-disposed',
  670. ])
  671. expect(ctx.agents.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
  672. expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
  673. await ctx.fiber.dispose()
  674. })
  675. it('rechecks caller liveness after creation listeners before unlocking the driver', async () => {
  676. const ctx = await harness()
  677. const starts: string[] = []
  678. let ownerCtx!: Context
  679. let creating!: ReturnType<typeof ctx.agents.create>
  680. ctx.on('agent/session-start', ({ agent }) => void starts.push(agent.id))
  681. ctx.on('agent/created', ({ agent }) => {
  682. if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx)
  683. })
  684. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  685. ownerCtx = inner
  686. creating = inner.agents.create({
  687. sessionId: SessionId('listener-dispose-s'),
  688. agentOptions: { provider: 'mock', model: 'mock' },
  689. })
  690. }, { inject: ['agents'] }))
  691. await expect(creating).rejects.toThrow(/owner disposed during setup/)
  692. await owner.dispose()
  693. expect(starts).toEqual([])
  694. expect(ctx.agents.get(SessionId('listener-dispose-s')) === undefined).toBe(true)
  695. expect(ctx.sessions.get(SessionId('listener-dispose-s')) === undefined).toBe(true)
  696. await ctx.fiber.dispose()
  697. })
  698. it('rechecks caller liveness after session-start before starting the driver', async () => {
  699. const ctx = await harness()
  700. let ownerCtx!: Context
  701. let creating!: ReturnType<typeof ctx.agents.create>
  702. let announced!: Agent
  703. const statuses: string[] = []
  704. let scopeDisposed = false
  705. let observerSawLive = false
  706. ctx.on('agent/status', ({ agent, status }) => {
  707. if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status)
  708. })
  709. ctx.on('agent/session-start', ({ agent }) => {
  710. if (agent.id !== SessionId('session-start-dispose-s')) return
  711. announced = agent
  712. disposeCurrentLifecycle(ownerCtx)
  713. })
  714. ctx.on('agent/session-start', ({ agent }) => {
  715. if (agent.id !== SessionId('session-start-dispose-s')) return
  716. expect(ctx.agents.get(agent.id)).toBe(agent)
  717. expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
  718. agent.ctx.effect(() => () => { scopeDisposed = true })
  719. observerSawLive = true
  720. })
  721. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  722. ownerCtx = inner
  723. creating = inner.agents.create({
  724. sessionId: SessionId('session-start-dispose-s'),
  725. agentOptions: { provider: 'mock', model: 'mock' },
  726. })
  727. }, { inject: ['agents'] }))
  728. await expect(creating).rejects.toThrow(/owner disposed during setup/)
  729. await owner.dispose()
  730. expect(announced.status).toBe('idle')
  731. expect(statuses).toEqual([])
  732. expect(observerSawLive).toBe(true)
  733. expect(scopeDisposed).toBe(true)
  734. expect(announced.session.snapshotEvents()).toEqual([])
  735. expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined()
  736. expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined()
  737. await ctx.fiber.dispose()
  738. })
  739. it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => {
  740. const ctx = await harness()
  741. const published: string[] = []
  742. ctx.on('session/created', () => void published.push('session/created'))
  743. ctx.on('agent/created', () => void published.push('agent/created'))
  744. ctx.on('agent/session-start', () => void published.push('agent/session-start'))
  745. await expect(ctx.agents.create({
  746. sessionId: SessionId('bad-s'),
  747. agentOptions: { provider: 'mock', model: 'mock' },
  748. setup: async () => {
  749. await Promise.resolve()
  750. throw new Error('boom setup')
  751. },
  752. })).rejects.toThrow('boom setup')
  753. // Nothing leaked: no agent, no session, and the ids are reusable.
  754. expect(published).toEqual([])
  755. expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined()
  756. expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
  757. const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } })
  758. await retry.dispose()
  759. })
  760. it('rejects an exotic durable seed before publishing either object', async () => {
  761. const ctx = await harness()
  762. const published: string[] = []
  763. ctx.on('session/created', () => { published.push('session') })
  764. ctx.on('agent/created', () => { published.push('agent') })
  765. class ExoticData { readonly value = 'not durable JSON' }
  766. const seed = [{
  767. seq: 0,
  768. type: 'test/exotic-seed',
  769. data: new ExoticData(),
  770. }] as unknown as SessionEvent[]
  771. await expect(ctx.agents.create({
  772. sessionId: SessionId('exotic-seed-session'),
  773. agentOptions: { provider: 'mock', model: 'mock' },
  774. seed,
  775. })).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/)
  776. expect(published).toEqual([])
  777. expect(ctx.agents.get(SessionId('exotic-seed-session'))).toBeUndefined()
  778. expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined()
  779. const retry = await ctx.agents.create({
  780. sessionId: SessionId('exotic-seed-session'),
  781. agentOptions: { provider: 'mock', model: 'mock' },
  782. })
  783. await retry.dispose()
  784. })
  785. it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
  786. const ctx = await harness()
  787. let boom = true
  788. const disposed: string[] = []
  789. ctx.on('agent/disposed', ({ agent }) => void disposed.push(agent.id))
  790. ctx.on('session/created', () => {
  791. if (boom) { boom = false; throw new Error('boom created') }
  792. })
  793. await expect(ctx.agents.create({
  794. sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' },
  795. })).rejects.toThrow('boom created')
  796. expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined()
  797. expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
  798. expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge
  799. // The rollback also disposed the scope fiber: re-creating works cleanly.
  800. const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } })
  801. expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
  802. await retry.dispose()
  803. })
  804. it('pairs session and agent announcements when agent creation aborts publication', async () => {
  805. const ctx = await harness()
  806. const lifecycle: string[] = []
  807. ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) })
  808. ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) })
  809. ctx.on('agent/created', ({ agent }) => {
  810. lifecycle.push(`agent-created:${agent.id}`)
  811. throw new Error('agent observer failed')
  812. })
  813. ctx.on('agent/disposed', ({ agent }) => { lifecycle.push(`agent-disposed:${agent.id}`) })
  814. await expect(ctx.agents.create({
  815. sessionId: SessionId('partial-session'),
  816. agentOptions: { provider: 'mock', model: 'mock' },
  817. })).rejects.toThrow('agent observer failed')
  818. expect(lifecycle).toEqual([
  819. 'session-created:partial-session',
  820. 'agent-created:partial-session',
  821. 'agent-disposed:partial-session',
  822. 'session-disposed:partial-session',
  823. ])
  824. expect(ctx.agents.get(SessionId('partial-session'))).toBeUndefined()
  825. expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined()
  826. })
  827. it('the config create helper rolls back when publication throws', async () => {
  828. const ctx = await harness()
  829. const sessionsBefore = ctx.sessions.list().length
  830. let boom = true
  831. ctx.on('session/created', () => {
  832. if (boom) {
  833. boom = false
  834. throw new Error('config publish failed')
  835. }
  836. })
  837. await expect(ctx.agentLoop.create(SessionId('config-bad'), { provider: 'mock', model: 'mock' }))
  838. .rejects.toThrow('config publish failed')
  839. await expect.poll(() => ctx.agents.get(SessionId('config-bad')) === undefined).toBe(true)
  840. await expect.poll(() => ctx.sessions.list().length).toBe(sessionsBefore)
  841. })
  842. it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
  843. const ctx = await harness()
  844. const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
  845. await handle.dispose()
  846. expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
  847. })
  848. it('agentEvents fuses carrier and subject for custom drivers', async () => {
  849. const ctx = await harness()
  850. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  851. const other = await ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' })
  852. const heard: string[] = []
  853. agent.ctx.on('agent/error', ({ agent: subject, turn }) => void heard.push(`${subject.id}:${turn}`))
  854. agentEvents(ctx, other).emit('agent/error', { turn: 1, step: 0, error: new Error('not for a1') })
  855. agentEvents(ctx, agent).emit('agent/error', { turn: 2, step: 0, error: new Error('for a1') })
  856. expect(heard).toEqual(['a1:2'])
  857. })
  858. it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
  859. const ctx = await harness()
  860. let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
  861. const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
  862. handle = await inner.agents.create({ sessionId: SessionId('o1-s'), agentOptions: { provider: 'mock', model: 'mock' } })
  863. }, { inject: ['agents'] }))
  864. const { agent } = handle
  865. const order: string[] = []
  866. ctx.on('session/event', (_s, event) => {
  867. if (event.type === 'turn/end') order.push('turn-end')
  868. })
  869. ctx.on('agent/disposed', () => {
  870. order.push(`disposed(listed=${ctx.agents.get(SessionId('o1-s')) !== undefined})`)
  871. order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
  872. })
  873. // Open a turn so disposal must drain real work before registry removal.
  874. // Waiting for turn/start avoids pre-step disposal dropping the queued prompt
  875. // before a turn opens.
  876. const turnOpen = new Promise<void>((resolve) => {
  877. const off = ctx.on('session/event', (_s, event) => {
  878. if (event.type === 'turn/start') { off(); resolve() }
  879. })
  880. })
  881. agent.followup(createUserMessage({ content: text('work'), source: { kind: 'user' } }))
  882. await turnOpen
  883. await owner.dispose()
  884. expect(order).toEqual([
  885. 'turn-end',
  886. 'disposed(listed=false)',
  887. 'session-still-stored=true',
  888. ])
  889. expect(ctx.sessions.get(SessionId('o1-s'))).toBeUndefined()
  890. })
  891. it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => {
  892. const ctx = await harness()
  893. let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
  894. const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
  895. handle = await inner.agents.create({ sessionId: SessionId('h1-s'), agentOptions: { provider: 'mock', model: 'mock' } })
  896. }, { inject: ['agents'] }))
  897. const teardownDone: string[] = []
  898. ctx.on('agent/disposed', () => void teardownDone.push('unregistered'))
  899. // Owner unload begins FIRST (invokes the raw cordis wrapper)…
  900. const unload = owner.dispose()
  901. // …and a concurrent handle.dispose() must not resolve before the chain
  902. // actually finished (the raw wrapper returns undefined on a repeat call).
  903. await handle.dispose()
  904. expect(teardownDone).toContain('unregistered')
  905. expect(ctx.agents.get(SessionId('h1-s'))).toBeUndefined()
  906. expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined()
  907. await unload
  908. })
  909. it('successful handle disposal retires its caller ownership effect', async () => {
  910. const ctx = await harness()
  911. const sessionId = SessionId('retired-owner-effect')
  912. const handle = await ctx.agents.create({
  913. sessionId,
  914. agentOptions: { provider: 'mock', model: 'mock' },
  915. })
  916. expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.lifecycle(${sessionId})`)
  917. await handle.dispose()
  918. expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.lifecycle(${sessionId})`)).toEqual([])
  919. await ctx.fiber.dispose()
  920. })
  921. it('owner unload after handle-first teardown follows the same in-flight boundary', async () => {
  922. const ctx = await harness()
  923. const gate = Promise.withResolvers<undefined>()
  924. const cleanupStarted = Promise.withResolvers<undefined>()
  925. let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
  926. const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
  927. handle = await inner.agents.create({
  928. sessionId: SessionId('manual-first-s'),
  929. agentOptions: { provider: 'mock', model: 'mock' },
  930. setup(agentCtx) {
  931. agentCtx.effect(() => async () => {
  932. cleanupStarted.resolve(undefined)
  933. await gate.promise
  934. })
  935. },
  936. })
  937. }, { inject: ['agents'] }))
  938. const disposing = handle.dispose()
  939. await cleanupStarted.promise
  940. let ownerSettled = false
  941. const unloading = owner.dispose().then(() => { ownerSettled = true })
  942. await Promise.resolve()
  943. expect(ownerSettled).toBe(false)
  944. gate.resolve(undefined)
  945. await Promise.all([disposing, unloading])
  946. expect(ctx.agents.get(SessionId('manual-first-s'))).toBeUndefined()
  947. expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined()
  948. await ctx.fiber.dispose()
  949. })
  950. it('reopens ids after the prior private scope finishes quiescing', async () => {
  951. const ctx = await harness()
  952. const gate = Promise.withResolvers<undefined>()
  953. const cleanupStarted = Promise.withResolvers<undefined>()
  954. const sessionId = SessionId('quiescent-reuse')
  955. const first = await ctx.agents.create({
  956. sessionId,
  957. agentOptions: { provider: 'mock', model: 'mock' },
  958. setup(agentCtx) {
  959. agentCtx.effect(() => async () => {
  960. cleanupStarted.resolve(undefined)
  961. await gate.promise
  962. })
  963. },
  964. })
  965. const disposing = first.dispose()
  966. await cleanupStarted.promise
  967. expect(ctx.agents.get(sessionId)).toBe(first.agent)
  968. expect(ctx.sessions.get(sessionId)).toBe(first.agent.session)
  969. gate.resolve(undefined)
  970. await disposing
  971. expect(ctx.agents.get(sessionId)).toBeUndefined()
  972. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  973. const replacement = await ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
  974. expect(ctx.agents.get(sessionId)).toBe(replacement.agent)
  975. expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session)
  976. await replacement.dispose()
  977. await ctx.fiber.dispose()
  978. })
  979. it('drains a run re-entered by cancel\'s own idle transition before removing the scope', async () => {
  980. // Automation shaped like goal-round-driver: the running→idle transition that
  981. // disposal's cancel produces immediately queues a follow-up prompt. The
  982. // teardown must drain that replacement run to true quiescence instead of
  983. // awaiting only the first captured done and unwinding under a live run.
  984. const adapter = new MockAdapter([textResponse('one'), textResponse('never awaited')])
  985. const ctx = await harness(adapter)
  986. const handle = await ctx.agents.create({
  987. sessionId: SessionId('drain-reentered-run'),
  988. agentOptions: { provider: 'mock', model: 'mock' },
  989. })
  990. const agent = handle.agent
  991. let reentered = false
  992. ctx.on('agent/status', ({ agent: subject, status }) => {
  993. if (subject !== agent || status !== 'idle' || reentered) return
  994. reentered = true
  995. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'reentrant' }], source: { kind: 'user' } }))
  996. })
  997. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  998. await waitForIdle(ctx, agent)
  999. expect(reentered).toBe(true)
  1000. // Idle again: the reentrant batch was already claimed and settled (its
  1001. // prompt was blocked by nothing, so it ran) — arm a SECOND reentry that
  1002. // fires from the disposal cancel's idle transition itself.
  1003. reentered = false
  1004. await handle.dispose()
  1005. // The reentrant run either never started or was drained: the registries
  1006. // are empty and nothing still drives the detached session.
  1007. expect(ctx.agents.get(agent.id)).toBeUndefined()
  1008. expect(ctx.sessions.get(agent.id)).toBeUndefined()
  1009. const eventsAfter = agent.session.snapshotEvents().length
  1010. await new Promise(resolve => setTimeout(resolve, 30))
  1011. expect(agent.session.snapshotEvents().length).toBe(eventsAfter)
  1012. await ctx.fiber.dispose()
  1013. })
  1014. })