scope-lifecycle.spec.ts 41 KB

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