scope-lifecycle.spec.ts 45 KB

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