scope-lifecycle.spec.ts 45 KB

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