scope-lifecycle.spec.ts 49 KB

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