scope-lifecycle.spec.ts 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095
  1. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  2. import { describe, expect, it } from 'vitest'
  3. import { Context, symbols, type EffectMeta, type Fiber } from '@deepseek-ai/cordis'
  4. import LlmRuntime from '@deepseek-ai/dsh-llm'
  5. import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  6. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  7. import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  8. import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
  9. import type { Agent } from '@deepseek-ai/dsh-agent'
  10. import { scopeOf } from '@deepseek-ai/dsh-scope'
  11. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  12. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  13. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  14. import { MockAdapter, textResponse } from './mock-adapter.ts'
  15. async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<{ ctx: Context; loopFiber: Fiber }> {
  16. const ctx = new Context()
  17. await ctx.plugin(LlmRuntime)
  18. await ctx.plugin(SessionStore)
  19. await ctx.plugin(SessionProjectionRegistry)
  20. await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' })
  21. await ctx.plugin(ToolRuntime)
  22. await ctx.plugin(AgentRegistry)
  23. const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
  24. ctx.llm.registerAdapter(['mock'], adapter)
  25. return { ctx, loopFiber }
  26. }
  27. async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<Context> {
  28. return (await harnessWithLoop(adapter)).ctx
  29. }
  30. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  31. return new Promise((resolve) => {
  32. const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
  33. if (subject === agent && status === 'idle') {
  34. dispose()
  35. resolve()
  36. }
  37. })
  38. })
  39. }
  40. const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
  41. /** Throw an arbitrary callback value to exercise the public unknown-error boundary. */
  42. function throwUnknown(value: unknown): never {
  43. throw value
  44. }
  45. /** Invoke the exact lifecycle effect to exercise same-stack reentrant teardown. */
  46. function disposeCurrentLifecycle(ownerCtx: Context): void {
  47. const lifecycle = [...ownerCtx.fiber._disposables]
  48. .find((dispose) => {
  49. const effect = (dispose as typeof dispose & { [symbols.effect]?: EffectMeta })[symbols.effect]
  50. return effect?.label.startsWith('agentLoop.lifecycle(') === true
  51. })
  52. if (lifecycle === undefined) throw new Error('agent lifecycle effect not found')
  53. void lifecycle()
  54. }
  55. describe('agent scope lifecycle', () => {
  56. it('rejects an already-aborted creation signal before publishing either object', async () => {
  57. const ctx = await harness()
  58. const reason = new Error('cancelled before creation')
  59. const controller = new AbortController()
  60. controller.abort(reason)
  61. await expect(ctx.agents.create({
  62. sessionId: SessionId('pre-aborted-s'),
  63. signal: controller.signal,
  64. })).rejects.toBe(reason)
  65. expect(ctx.agents.get(SessionId('pre-aborted-s'))).toBeUndefined()
  66. expect(ctx.sessions.get(SessionId('pre-aborted-s'))).toBeUndefined()
  67. const valueController = new AbortController()
  68. valueController.abort('plain cancellation reason')
  69. await expect(ctx.agents.create({
  70. sessionId: SessionId('pre-aborted-value-s'),
  71. signal: valueController.signal,
  72. })).rejects.toMatchObject({
  73. message: 'agent "pre-aborted-value-s" creation aborted',
  74. cause: 'plain cancellation reason',
  75. })
  76. expect(ctx.agents.get(SessionId('pre-aborted-value-s'))).toBeUndefined()
  77. expect(ctx.sessions.get(SessionId('pre-aborted-value-s'))).toBeUndefined()
  78. await ctx.fiber.dispose()
  79. })
  80. it('joins cleanup when an abort lands reentrantly during scope preparation', async () => {
  81. const ctx = await harness()
  82. const reason = new Error('cancelled while preparing')
  83. const controller = new AbortController()
  84. let aborted = false
  85. ctx.on('internal/plugin', (fiber) => {
  86. if (aborted || fiber.name !== 'scope') return
  87. aborted = true
  88. controller.abort(reason)
  89. })
  90. await expect(ctx.agents.create({
  91. sessionId: SessionId('prepare-abort-s'),
  92. signal: controller.signal,
  93. })).rejects.toBe(reason)
  94. expect(ctx.agents.get(SessionId('prepare-abort-s'))).toBeUndefined()
  95. expect(ctx.sessions.get(SessionId('prepare-abort-s'))).toBeUndefined()
  96. await ctx.fiber.dispose()
  97. })
  98. it('normalizes non-Error create failures for rollback while rethrowing the original value', async () => {
  99. const ctx = await harness()
  100. let thrown: unknown
  101. ctx.on('session/created', () => {
  102. if (thrown === undefined) return
  103. const value = thrown
  104. thrown = undefined
  105. throwUnknown(value)
  106. })
  107. const createFailure = { source: 'create' }
  108. thrown = createFailure
  109. let createCaught: unknown
  110. try {
  111. ctx.agentLoop.create(SessionId('unknown-create'))
  112. } catch (error: unknown) {
  113. createCaught = error
  114. }
  115. expect(createCaught).toBe(createFailure)
  116. const ownedFailure = { source: 'createAgent' }
  117. thrown = ownedFailure
  118. await expect(ctx.agents.create({
  119. sessionId: SessionId('unknown-owned-create-s'),
  120. })).rejects.toBe(ownedFailure)
  121. expect(ctx.agents.get(SessionId('unknown-create'))).toBeUndefined()
  122. expect(ctx.agents.get(SessionId('unknown-owned-create-s'))).toBeUndefined()
  123. await ctx.fiber.dispose()
  124. })
  125. it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
  126. const ctx = await harness()
  127. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  128. expect(scopeOf(agent.ctx)).toBe(agent)
  129. expect(agent.ctx.agent).toBe(agent)
  130. // The root accessor default: a plain context answers undefined, not a throw.
  131. expect(ctx.agent).toBeUndefined()
  132. await agent.whenIdle()
  133. })
  134. it('records agents created through an agent context as non-root runtime children', async () => {
  135. const ctx = await harness()
  136. const root = await ctx.agents.create({
  137. sessionId: SessionId('runtime-root'),
  138. agentOptions: { model: 'mock' },
  139. })
  140. const child = await root.agent.ctx.agents.create({
  141. sessionId: SessionId('runtime-child'),
  142. agentOptions: { model: 'mock' },
  143. })
  144. expect(ctx.agents.list()).toEqual([root.agent, child.agent])
  145. expect(ctx.agents.roots()).toEqual([root.agent])
  146. await child.dispose()
  147. await root.dispose()
  148. })
  149. it('scoped registrations live in the agent world and die with the agent', async () => {
  150. const ctx = await harness()
  151. const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
  152. const { agent } = handle
  153. agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
  154. agent.ctx.tools.register(defineContentToolFixture({
  155. name: 'mine', description: 'scoped', parameters: {},
  156. execute: () => Promise.resolve(text('ran')),
  157. }))
  158. const scopedAssembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
  159. expect(scopedAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You run tests.')
  160. expect(scopedAssembly.tools.map(t => t.name)).toContain('mine')
  161. // Other assemblies are untouched.
  162. const globalAssembly = await ctx.systemPrompt.assemble()
  163. expect(globalAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.')
  164. expect(globalAssembly.tools.map(t => t.name)).not.toContain('mine')
  165. await handle.dispose()
  166. // The scoped world unwound with the agent: nothing leaked into the registries.
  167. expect(ctx.tools.get('mine', agent)).toBeUndefined()
  168. const after = await ctx.systemPrompt.assemble(assembleContextFor(agent))
  169. expect(after.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.')
  170. })
  171. it('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 = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
  174. const b = 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')?.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', 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('synchronous 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. ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' })
  476. await unloading
  477. expect(ctx.agents.get(SessionId('config-scope-race')) === undefined).toBe(true)
  478. expect(ctx.sessions.list().length).toBe(sessionsBefore)
  479. await ctx.fiber.dispose()
  480. })
  481. it('synchronous create leaves no lifecycle state when session preparation fails', async () => {
  482. const ctx = await harness()
  483. const id = SessionId('config-prepare-failure')
  484. expect(() => ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: 'relative' }))
  485. .toThrow(/absolute path/)
  486. const replacement = ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: '/recovered' })
  487. expect(ctx.agents.get(id)).toBe(replacement)
  488. await replacement.whenIdle()
  489. await ctx.fiber.dispose()
  490. })
  491. it('factory unload awaits provisional cleanup when scope preparation throws', async () => {
  492. const { ctx, loopFiber } = await harnessWithLoop()
  493. let triggered = false
  494. ctx.on('internal/plugin', (fiber) => {
  495. if (triggered || fiber.name !== 'scope') return
  496. triggered = true
  497. void loopFiber.dispose()
  498. throw new Error('scope preparation failed')
  499. })
  500. await expect(ctx.agents.create({
  501. sessionId: SessionId('factory-scope-throw-s'),
  502. agentOptions: { provider: 'mock', model: 'mock' },
  503. })).rejects.toThrow('scope preparation failed')
  504. await loopFiber.dispose()
  505. expect(ctx.agents.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
  506. expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
  507. await ctx.fiber.dispose()
  508. })
  509. it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => {
  510. const { ctx, loopFiber } = await harnessWithLoop()
  511. const loop = ctx.agentLoop
  512. const sessionId = SessionId('factory-live')
  513. const handle = await ctx.agents.create({
  514. sessionId: SessionId('factory-live-s'),
  515. agentOptions: { provider: 'mock', model: 'mock' },
  516. })
  517. await loopFiber.dispose()
  518. expect(handle.agent.status).toBe('idle')
  519. expect(ctx.agents.get(sessionId)).toBeUndefined()
  520. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  521. expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.lifecycle(${sessionId})`)).toEqual([])
  522. // The consumer handle shares the provider's completed quiescence boundary.
  523. await handle.dispose()
  524. await expect(loop.createAgent(ctx, {
  525. sessionId: SessionId('factory-inactive-s'),
  526. })).rejects.toThrow(/agent loop is not active|inactive context/)
  527. await ctx.fiber.dispose()
  528. })
  529. it('keeps AgentLoop dependencies available when the caller injects only agents', async () => {
  530. const ctx = await harness()
  531. let creating!: ReturnType<typeof ctx.agents.create>
  532. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  533. creating = inner.agents.create({
  534. sessionId: SessionId('dependency-origin-s'),
  535. agentOptions: { provider: 'mock', model: 'mock' },
  536. setup: (agentCtx) => {
  537. agentCtx.tools.register(defineContentToolFixture({
  538. name: 'dependency-origin-tool',
  539. description: 'proves AgentLoop dependency origin',
  540. parameters: {},
  541. execute: () => Promise.resolve(text('ok')),
  542. }))
  543. agentCtx.systemPrompt.section({
  544. name: 'dependency-origin-section',
  545. order: 1,
  546. text: 'factory dependency API',
  547. })
  548. },
  549. })
  550. }, { inject: ['agents'] }))
  551. const handle = await creating
  552. const assembly = await ctx.systemPrompt.assemble(assembleContextFor(handle.agent))
  553. expect(assembly.tools.map(tool => tool.name)).toContain('dependency-origin-tool')
  554. expect(assembly.sections.map(section => section.name)).toContain('dependency-origin-section')
  555. await handle.dispose()
  556. await owner.dispose()
  557. await ctx.fiber.dispose()
  558. })
  559. it('keeps both entries and the scope live through a reentrant session/created teardown', async () => {
  560. const ctx = await harness()
  561. let ownerCtx!: Context
  562. let creating!: ReturnType<typeof ctx.agents.create>
  563. const lifecycle: string[] = []
  564. ctx.on('session/created', (session) => {
  565. if (session.id !== SessionId('session-created-barrier-s')) return
  566. lifecycle.push('session-created:dispose')
  567. disposeCurrentLifecycle(ownerCtx)
  568. })
  569. ctx.on('session/created', (session) => {
  570. if (session.id !== SessionId('session-created-barrier-s')) return
  571. const agent = ctx.agents.get(SessionId('session-created-barrier-s'))!
  572. expect(ctx.sessions.get(session.id)).toBe(session)
  573. expect(agent.session).toBe(session)
  574. agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
  575. lifecycle.push('session-created:observer')
  576. })
  577. ctx.on('agent/created', () => void lifecycle.push('agent-created'))
  578. ctx.on('agent/disposed', () => void lifecycle.push('agent-disposed'))
  579. ctx.on('session/disposed', (session) => {
  580. if (session.id === SessionId('session-created-barrier-s')) lifecycle.push('session-disposed')
  581. })
  582. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  583. ownerCtx = inner
  584. creating = inner.agents.create({
  585. sessionId: SessionId('session-created-barrier-s'),
  586. agentOptions: { provider: 'mock', model: 'mock' },
  587. })
  588. }, { inject: ['agents'] }))
  589. await expect(creating).rejects.toThrow(/owner disposed during setup/)
  590. await owner.dispose()
  591. expect(lifecycle).toEqual([
  592. 'session-created:dispose',
  593. 'session-created:observer',
  594. 'scope-disposed',
  595. 'session-disposed',
  596. ])
  597. expect(ctx.agents.get(SessionId('session-created-barrier-s'))).toBeUndefined()
  598. expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined()
  599. await ctx.fiber.dispose()
  600. })
  601. it('keeps both entries and the scope live through a reentrant agent/created teardown', async () => {
  602. const ctx = await harness()
  603. let ownerCtx!: Context
  604. let creating!: ReturnType<typeof ctx.agents.create>
  605. const lifecycle: string[] = []
  606. ctx.on('session/created', (session) => {
  607. if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created')
  608. })
  609. ctx.on('agent/created', ({ agent }) => {
  610. if (agent.id !== SessionId('agent-created-barrier-s')) return
  611. lifecycle.push('agent-created:dispose')
  612. disposeCurrentLifecycle(ownerCtx)
  613. })
  614. ctx.on('agent/created', ({ agent }) => {
  615. if (agent.id !== SessionId('agent-created-barrier-s')) return
  616. expect(ctx.agents.get(agent.id)).toBe(agent)
  617. expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
  618. agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
  619. lifecycle.push('agent-created:observer')
  620. })
  621. ctx.on('agent/disposed', ({ agent }) => {
  622. if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed')
  623. })
  624. ctx.on('session/disposed', (session) => {
  625. if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed')
  626. })
  627. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  628. ownerCtx = inner
  629. creating = inner.agents.create({
  630. sessionId: SessionId('agent-created-barrier-s'),
  631. agentOptions: { provider: 'mock', model: 'mock' },
  632. })
  633. }, { inject: ['agents'] }))
  634. await expect(creating).rejects.toThrow(/owner disposed during setup/)
  635. await owner.dispose()
  636. expect(lifecycle).toEqual([
  637. 'session-created',
  638. 'agent-created:dispose',
  639. 'agent-created:observer',
  640. 'scope-disposed',
  641. 'agent-disposed',
  642. 'session-disposed',
  643. ])
  644. expect(ctx.agents.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
  645. expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
  646. await ctx.fiber.dispose()
  647. })
  648. it('rechecks caller liveness after creation listeners before unlocking the driver', async () => {
  649. const ctx = await harness()
  650. const starts: string[] = []
  651. let ownerCtx!: Context
  652. let creating!: ReturnType<typeof ctx.agents.create>
  653. ctx.on('agent/session-start', ({ agent }) => void starts.push(agent.id))
  654. ctx.on('agent/created', ({ agent }) => {
  655. if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx)
  656. })
  657. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  658. ownerCtx = inner
  659. creating = inner.agents.create({
  660. sessionId: SessionId('listener-dispose-s'),
  661. agentOptions: { provider: 'mock', model: 'mock' },
  662. })
  663. }, { inject: ['agents'] }))
  664. await expect(creating).rejects.toThrow(/owner disposed during setup/)
  665. await owner.dispose()
  666. expect(starts).toEqual([])
  667. expect(ctx.agents.get(SessionId('listener-dispose-s')) === undefined).toBe(true)
  668. expect(ctx.sessions.get(SessionId('listener-dispose-s')) === undefined).toBe(true)
  669. await ctx.fiber.dispose()
  670. })
  671. it('rechecks caller liveness after session-start before starting the driver', async () => {
  672. const ctx = await harness()
  673. let ownerCtx!: Context
  674. let creating!: ReturnType<typeof ctx.agents.create>
  675. let announced!: Agent
  676. const statuses: string[] = []
  677. let scopeDisposed = false
  678. let observerSawLive = false
  679. ctx.on('agent/status', ({ agent, status }) => {
  680. if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status)
  681. })
  682. ctx.on('agent/session-start', ({ agent }) => {
  683. if (agent.id !== SessionId('session-start-dispose-s')) return
  684. announced = agent
  685. disposeCurrentLifecycle(ownerCtx)
  686. })
  687. ctx.on('agent/session-start', ({ agent }) => {
  688. if (agent.id !== SessionId('session-start-dispose-s')) return
  689. expect(ctx.agents.get(agent.id)).toBe(agent)
  690. expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
  691. agent.ctx.effect(() => () => { scopeDisposed = true })
  692. observerSawLive = true
  693. })
  694. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  695. ownerCtx = inner
  696. creating = inner.agents.create({
  697. sessionId: SessionId('session-start-dispose-s'),
  698. agentOptions: { provider: 'mock', model: 'mock' },
  699. })
  700. }, { inject: ['agents'] }))
  701. await expect(creating).rejects.toThrow(/owner disposed during setup/)
  702. await owner.dispose()
  703. expect(announced.status).toBe('idle')
  704. expect(statuses).toEqual([])
  705. expect(observerSawLive).toBe(true)
  706. expect(scopeDisposed).toBe(true)
  707. expect(announced.session.events).toEqual([])
  708. expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined()
  709. expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined()
  710. await ctx.fiber.dispose()
  711. })
  712. it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => {
  713. const ctx = await harness()
  714. const published: string[] = []
  715. ctx.on('session/created', () => void published.push('session/created'))
  716. ctx.on('agent/created', () => void published.push('agent/created'))
  717. ctx.on('agent/session-start', () => void published.push('agent/session-start'))
  718. await expect(ctx.agents.create({
  719. sessionId: SessionId('bad-s'),
  720. agentOptions: { provider: 'mock', model: 'mock' },
  721. setup: async () => {
  722. await Promise.resolve()
  723. throw new Error('boom setup')
  724. },
  725. })).rejects.toThrow('boom setup')
  726. // Nothing leaked: no agent, no session, and the ids are reusable.
  727. expect(published).toEqual([])
  728. expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined()
  729. expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
  730. const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } })
  731. await retry.dispose()
  732. })
  733. it('rejects an exotic durable seed before publishing either object', async () => {
  734. const ctx = await harness()
  735. const published: string[] = []
  736. ctx.on('session/created', () => { published.push('session') })
  737. ctx.on('agent/created', () => { published.push('agent') })
  738. class ExoticData { readonly value = 'not durable JSON' }
  739. const seed = [{
  740. seq: 0,
  741. type: 'test/exotic-seed',
  742. data: new ExoticData(),
  743. }] as unknown as SessionEvent[]
  744. await expect(ctx.agents.create({
  745. sessionId: SessionId('exotic-seed-session'),
  746. agentOptions: { provider: 'mock', model: 'mock' },
  747. seed,
  748. })).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/)
  749. expect(published).toEqual([])
  750. expect(ctx.agents.get(SessionId('exotic-seed-session'))).toBeUndefined()
  751. expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined()
  752. const retry = await ctx.agents.create({
  753. sessionId: SessionId('exotic-seed-session'),
  754. agentOptions: { provider: 'mock', model: 'mock' },
  755. })
  756. await retry.dispose()
  757. })
  758. it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
  759. const ctx = await harness()
  760. let boom = true
  761. const disposed: string[] = []
  762. ctx.on('agent/disposed', ({ agent }) => void disposed.push(agent.id))
  763. ctx.on('session/created', () => {
  764. if (boom) { boom = false; throw new Error('boom created') }
  765. })
  766. await expect(ctx.agents.create({
  767. sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' },
  768. })).rejects.toThrow('boom created')
  769. expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined()
  770. expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
  771. expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge
  772. // The rollback also disposed the scope fiber: re-creating works cleanly.
  773. const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } })
  774. expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
  775. await retry.dispose()
  776. })
  777. it('pairs session and agent announcements when agent creation aborts publication', async () => {
  778. const ctx = await harness()
  779. const lifecycle: string[] = []
  780. ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) })
  781. ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) })
  782. ctx.on('agent/created', ({ agent }) => {
  783. lifecycle.push(`agent-created:${agent.id}`)
  784. throw new Error('agent observer failed')
  785. })
  786. ctx.on('agent/disposed', ({ agent }) => { lifecycle.push(`agent-disposed:${agent.id}`) })
  787. await expect(ctx.agents.create({
  788. sessionId: SessionId('partial-session'),
  789. agentOptions: { provider: 'mock', model: 'mock' },
  790. })).rejects.toThrow('agent observer failed')
  791. expect(lifecycle).toEqual([
  792. 'session-created:partial-session',
  793. 'agent-created:partial-session',
  794. 'agent-disposed:partial-session',
  795. 'session-disposed:partial-session',
  796. ])
  797. expect(ctx.agents.get(SessionId('partial-session'))).toBeUndefined()
  798. expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined()
  799. })
  800. it('the synchronous config helper rolls back when publication throws', async () => {
  801. const ctx = await harness()
  802. const sessionsBefore = ctx.sessions.list().length
  803. let boom = true
  804. ctx.on('session/created', () => {
  805. if (boom) {
  806. boom = false
  807. throw new Error('config publish failed')
  808. }
  809. })
  810. expect(() => ctx.agentLoop.create(SessionId('config-bad'), { provider: 'mock', model: 'mock' }))
  811. .toThrow('config publish failed')
  812. await expect.poll(() => ctx.agents.get(SessionId('config-bad')) === undefined).toBe(true)
  813. await expect.poll(() => ctx.sessions.list().length).toBe(sessionsBefore)
  814. })
  815. it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
  816. const ctx = await harness()
  817. const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
  818. await handle.dispose()
  819. expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
  820. })
  821. it('agentEvents fuses carrier and subject for custom drivers', async () => {
  822. const ctx = await harness()
  823. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  824. const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' })
  825. const heard: string[] = []
  826. agent.ctx.on('agent/error', ({ agent: subject, turn }) => void heard.push(`${subject.id}:${turn}`))
  827. agentEvents(ctx, other).emit('agent/error', { turn: 1, step: 0, error: new Error('not for a1') })
  828. agentEvents(ctx, agent).emit('agent/error', { turn: 2, step: 0, error: new Error('for a1') })
  829. expect(heard).toEqual(['a1:2'])
  830. })
  831. it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
  832. const ctx = await harness()
  833. let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
  834. const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
  835. handle = await inner.agents.create({ sessionId: SessionId('o1-s'), agentOptions: { provider: 'mock', model: 'mock' } })
  836. }, { inject: ['agents'] }))
  837. const { agent } = handle
  838. const order: string[] = []
  839. ctx.on('session/event', (_s, event) => {
  840. if (event.type === 'turn/end') order.push('turn-end')
  841. })
  842. ctx.on('agent/disposed', () => {
  843. order.push(`disposed(listed=${ctx.agents.get(SessionId('o1-s')) !== undefined})`)
  844. order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
  845. })
  846. // Open a turn so disposal must drain real work before registry removal.
  847. // Waiting for turn/start avoids pre-step disposal dropping the queued prompt
  848. // before a turn opens.
  849. const turnOpen = new Promise<void>((resolve) => {
  850. const off = ctx.on('session/event', (_s, event) => {
  851. if (event.type === 'turn/start') { off(); resolve() }
  852. })
  853. })
  854. agent.followup(createUserMessage({ content: text('work'), source: { kind: 'user' } }))
  855. await turnOpen
  856. await owner.dispose()
  857. expect(order).toEqual([
  858. 'turn-end',
  859. 'disposed(listed=false)',
  860. 'session-still-stored=true',
  861. ])
  862. expect(ctx.sessions.get(SessionId('o1-s'))).toBeUndefined()
  863. })
  864. it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => {
  865. const ctx = await harness()
  866. let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
  867. const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
  868. handle = await inner.agents.create({ sessionId: SessionId('h1-s'), agentOptions: { provider: 'mock', model: 'mock' } })
  869. }, { inject: ['agents'] }))
  870. const teardownDone: string[] = []
  871. ctx.on('agent/disposed', () => void teardownDone.push('unregistered'))
  872. // Owner unload begins FIRST (invokes the raw cordis wrapper)…
  873. const unload = owner.dispose()
  874. // …and a concurrent handle.dispose() must not resolve before the chain
  875. // actually finished (the raw wrapper returns undefined on a repeat call).
  876. await handle.dispose()
  877. expect(teardownDone).toContain('unregistered')
  878. expect(ctx.agents.get(SessionId('h1-s'))).toBeUndefined()
  879. expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined()
  880. await unload
  881. })
  882. it('successful handle disposal retires its caller ownership effect', async () => {
  883. const ctx = await harness()
  884. const sessionId = SessionId('retired-owner-effect')
  885. const handle = await ctx.agents.create({
  886. sessionId,
  887. agentOptions: { provider: 'mock', model: 'mock' },
  888. })
  889. expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.lifecycle(${sessionId})`)
  890. await handle.dispose()
  891. expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.lifecycle(${sessionId})`)).toEqual([])
  892. await ctx.fiber.dispose()
  893. })
  894. it('owner unload after handle-first teardown follows the same in-flight boundary', async () => {
  895. const ctx = await harness()
  896. const gate = Promise.withResolvers<undefined>()
  897. const cleanupStarted = Promise.withResolvers<undefined>()
  898. let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
  899. const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
  900. handle = await inner.agents.create({
  901. sessionId: SessionId('manual-first-s'),
  902. agentOptions: { provider: 'mock', model: 'mock' },
  903. setup(agentCtx) {
  904. agentCtx.effect(() => async () => {
  905. cleanupStarted.resolve(undefined)
  906. await gate.promise
  907. })
  908. },
  909. })
  910. }, { inject: ['agents'] }))
  911. const disposing = handle.dispose()
  912. await cleanupStarted.promise
  913. let ownerSettled = false
  914. const unloading = owner.dispose().then(() => { ownerSettled = true })
  915. await Promise.resolve()
  916. expect(ownerSettled).toBe(false)
  917. gate.resolve(undefined)
  918. await Promise.all([disposing, unloading])
  919. expect(ctx.agents.get(SessionId('manual-first-s'))).toBeUndefined()
  920. expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined()
  921. await ctx.fiber.dispose()
  922. })
  923. it('reopens ids after the prior private scope finishes quiescing', async () => {
  924. const ctx = await harness()
  925. const gate = Promise.withResolvers<undefined>()
  926. const cleanupStarted = Promise.withResolvers<undefined>()
  927. const sessionId = SessionId('quiescent-reuse')
  928. const first = await ctx.agents.create({
  929. sessionId,
  930. agentOptions: { provider: 'mock', model: 'mock' },
  931. setup(agentCtx) {
  932. agentCtx.effect(() => async () => {
  933. cleanupStarted.resolve(undefined)
  934. await gate.promise
  935. })
  936. },
  937. })
  938. const disposing = first.dispose()
  939. await cleanupStarted.promise
  940. expect(ctx.agents.get(sessionId)).toBe(first.agent)
  941. expect(ctx.sessions.get(sessionId)).toBe(first.agent.session)
  942. gate.resolve(undefined)
  943. await disposing
  944. expect(ctx.agents.get(sessionId)).toBeUndefined()
  945. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  946. const replacement = await ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
  947. expect(ctx.agents.get(sessionId)).toBe(replacement.agent)
  948. expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session)
  949. await replacement.dispose()
  950. await ctx.fiber.dispose()
  951. })
  952. it('drains a run re-entered by cancel\'s own idle transition before removing the scope', async () => {
  953. // Automation shaped like goal-round-driver: the running→idle transition that
  954. // disposal's cancel produces immediately queues a follow-up prompt. The
  955. // teardown must drain that replacement run to true quiescence instead of
  956. // awaiting only the first captured done and unwinding under a live run.
  957. const adapter = new MockAdapter([textResponse('one'), textResponse('never awaited')])
  958. const ctx = await harness(adapter)
  959. const handle = await ctx.agents.create({
  960. sessionId: SessionId('drain-reentered-run'),
  961. agentOptions: { provider: 'mock', model: 'mock' },
  962. })
  963. const agent = handle.agent
  964. let reentered = false
  965. ctx.on('agent/status', ({ agent: subject, status }) => {
  966. if (subject !== agent || status !== 'idle' || reentered) return
  967. reentered = true
  968. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'reentrant' }], source: { kind: 'user' } }))
  969. })
  970. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  971. await waitForIdle(ctx, agent)
  972. expect(reentered).toBe(true)
  973. // Idle again: the reentrant batch was already claimed and settled (its
  974. // prompt was blocked by nothing, so it ran) — arm a SECOND reentry that
  975. // fires from the disposal cancel's idle transition itself.
  976. reentered = false
  977. await handle.dispose()
  978. // The reentrant run either never started or was drained: the registries
  979. // are empty and nothing still drives the detached session.
  980. expect(ctx.agents.get(agent.id)).toBeUndefined()
  981. expect(ctx.sessions.get(agent.id)).toBeUndefined()
  982. const eventsAfter = agent.session.events.length
  983. await new Promise(resolve => setTimeout(resolve, 30))
  984. expect(agent.session.events.length).toBe(eventsAfter)
  985. await ctx.fiber.dispose()
  986. })
  987. })