scope-lifecycle.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LlmService from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { SessionId } 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 * as concreteAgentModule from '../src/agent.ts'
  12. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  13. import { MockAdapter, textResponse } from './mock-adapter.ts'
  14. async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])) {
  15. const ctx = new Context()
  16. await ctx.plugin(LlmService)
  17. await ctx.plugin(SessionStore)
  18. await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' })
  19. await ctx.plugin(ToolRegistry)
  20. await ctx.plugin(AgentRegistry)
  21. await ctx.plugin(AgentLoop, { agents: [] })
  22. ctx.llm.registerAdapter(['mock'], adapter)
  23. return ctx
  24. }
  25. function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
  26. return new Promise((resolve) => {
  27. const dispose = ctx.on('agent/status', (subject, status) => {
  28. if (subject === agent && status === 'idle') {
  29. dispose()
  30. resolve()
  31. }
  32. })
  33. })
  34. }
  35. const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
  36. describe('agent scope lifecycle', () => {
  37. it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
  38. const ctx = await harness()
  39. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  40. expect(scopeOf(agent.ctx)).toBe(agent)
  41. expect(agent.ctx.agent).toBe(agent)
  42. // The root accessor default: a plain context answers undefined, not a throw.
  43. expect(ctx.agent).toBeUndefined()
  44. await ctx.agents.get(AgentId('a1'))?.whenIdle()
  45. })
  46. it('scoped registrations live in the agent world and die with the agent', async () => {
  47. const ctx = await harness()
  48. const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
  49. const { agent } = handle
  50. agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
  51. agent.ctx.tools.register({
  52. name: 'mine', description: 'scoped', parameters: {},
  53. execute: () => Promise.resolve(text('ran')),
  54. })
  55. const scopedAssembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
  56. expect(scopedAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You run tests.')
  57. expect(scopedAssembly.tools.map(t => t.name)).toContain('mine')
  58. // Other assemblies are untouched.
  59. const globalAssembly = await ctx.systemPrompt.assemble()
  60. expect(globalAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.')
  61. expect(globalAssembly.tools.map(t => t.name)).not.toContain('mine')
  62. await handle.dispose()
  63. // The scoped world unwound with the agent: nothing leaked into the registries.
  64. expect(ctx.tools.get('mine', agent)).toBeUndefined()
  65. const after = await ctx.systemPrompt.assemble(assembleContextFor(agent))
  66. expect(after.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.')
  67. })
  68. it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => {
  69. const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')]))
  70. const a = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
  71. const b = ctx.agentLoop.create(AgentId('b'), { model: 'mock' })
  72. const heard: string[] = []
  73. a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`))
  74. a.ctx.on('session/event', (_s, event) => {
  75. if (event.type === 'user/message') heard.push('a-sees:user-message')
  76. })
  77. b.send(text('for b'))
  78. await waitForIdle(ctx, b)
  79. expect(heard).toEqual([]) // nothing of b's leaked into a's scope
  80. a.send(text('for a'))
  81. await waitForIdle(ctx, a)
  82. expect(heard).toContain('a-sees:a:running')
  83. expect(heard).toContain('a-sees:user-message')
  84. })
  85. it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => {
  86. const ctx = await harness()
  87. const order: string[] = []
  88. ctx.on('agent/session-start', (agent) => {
  89. order.push('session-start')
  90. // The scoped section is already registered by the time session-start fires.
  91. void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => {
  92. order.push(`persona:${assembly.sections.find(s => s.name === 'deployment:persona')?.text}`)
  93. })
  94. })
  95. const handle = await ctx.agents.create({
  96. agentId: AgentId('child'),
  97. sessionId: SessionId('child-s'),
  98. agentOptions: { model: 'mock' },
  99. setup: async (agentCtx) => {
  100. order.push('setup')
  101. await Promise.resolve()
  102. agentCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the child.' })
  103. },
  104. })
  105. await new Promise(resolve => setTimeout(resolve, 0))
  106. expect(order).toEqual(['setup', 'session-start', 'persona:You are the child.'])
  107. await handle.dispose()
  108. })
  109. it('keeps both identities unpublished until async setup completes, then announces in order', async () => {
  110. const ctx = await harness()
  111. const gate = Promise.withResolvers<undefined>()
  112. const setupStarted = Promise.withResolvers<undefined>()
  113. const order: string[] = []
  114. ctx.on('session/created', (session) => {
  115. expect(ctx.sessions.get(session.id)).toBe(session)
  116. expect(ctx.agents.get(AgentId('atomic'))?.session).toBe(session)
  117. order.push('session/created')
  118. })
  119. ctx.on('agent/created', () => void order.push('agent/created'))
  120. ctx.on('agent/session-start', () => void order.push('agent/session-start'))
  121. const acceptedOptions = { model: 'mock' }
  122. const creating = ctx.agents.create({
  123. agentId: AgentId('atomic'),
  124. sessionId: SessionId('atomic-s'),
  125. agentOptions: acceptedOptions,
  126. setup: async (agentCtx) => {
  127. expect(agentCtx.agent?.id).toBe(AgentId('atomic'))
  128. agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
  129. agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
  130. order.push('setup:start')
  131. setupStarted.resolve(undefined)
  132. await gate.promise
  133. order.push('setup:end')
  134. },
  135. })
  136. await setupStarted.promise
  137. expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined()
  138. expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined()
  139. expect(order).toEqual(['setup:start'])
  140. acceptedOptions.model = 'mutated while setup was pending'
  141. gate.resolve(undefined)
  142. const handle = await creating
  143. expect(handle.agent.options.model).toBe('mock')
  144. expect(order).toEqual([
  145. 'setup:start',
  146. 'setup:end',
  147. 'session/created',
  148. 'setup-listener:session/created',
  149. 'agent/created',
  150. 'setup-listener:agent/created',
  151. 'agent/session-start',
  152. ])
  153. await handle.dispose()
  154. })
  155. it('reserves agent and session ids across concurrent async setup', async () => {
  156. const ctx = await harness()
  157. const gate = Promise.withResolvers<undefined>()
  158. const first = ctx.agents.create({
  159. agentId: AgentId('reserved'),
  160. sessionId: SessionId('reserved-s'),
  161. agentOptions: { model: 'mock' },
  162. setup: () => gate.promise,
  163. })
  164. await expect(ctx.agents.create({
  165. agentId: AgentId('reserved'),
  166. sessionId: SessionId('other-s'),
  167. agentOptions: { model: 'mock' },
  168. })).rejects.toThrow(/already registered/)
  169. await expect(ctx.agents.create({
  170. agentId: AgentId('other'),
  171. sessionId: SessionId('reserved-s'),
  172. agentOptions: { model: 'mock' },
  173. })).rejects.toThrow(/already exists/)
  174. expect(ctx.agents.list()).toEqual([])
  175. expect(ctx.sessions.list()).toEqual([])
  176. gate.resolve(undefined)
  177. const handle = await first
  178. await handle.dispose()
  179. })
  180. it('structurally rejects every driving verb during setup', async () => {
  181. const ctx = await harness()
  182. const handle = await ctx.agents.create({
  183. agentId: AgentId('no-drive'),
  184. sessionId: SessionId('no-drive-s'),
  185. agentOptions: { model: 'mock' },
  186. setup: (agentCtx) => {
  187. const agent = agentCtx.agent!
  188. // Even JavaScript or a cast to the exported concrete class cannot name
  189. // a public start method. Driver startup is behind a module-private
  190. // symbol used only by AgentLoop after rollback-covered publication.
  191. expect(Reflect.get(agent as ReactLoopAgent, 'start')).toBeUndefined()
  192. expect(Reflect.get(concreteAgentModule, 'enableAgentDrive')).toBeUndefined()
  193. expect(Reflect.get(concreteAgentModule, 'startAgentDriver')).toBeUndefined()
  194. expect(() => concreteAgentModule.prepareReactLoopAgent(
  195. agentCtx, agent.id, agent.options, agent.session,
  196. )).toThrow(/already has a concrete agent driver/)
  197. expect(Reflect.get(agent as ReactLoopAgent, 'inbox')).toBeUndefined()
  198. expect(() => { agent.send(text('queued too soon')) }).toThrow(/cannot send before creation setup completes/)
  199. expect(() => { agent.steer(text('steered too soon')) }).toThrow(/cannot steer before creation setup completes/)
  200. expect(() => { agent.inject(text('injected too soon')) }).toThrow(/cannot inject before creation setup completes/)
  201. expect(() => { agent.cancel('cancel too soon') }).toThrow(/cannot cancel before creation setup completes/)
  202. expect(agent.session.events).toEqual([])
  203. },
  204. })
  205. expect(handle.agent.session.events).toEqual([])
  206. await handle.dispose()
  207. })
  208. it('owner unload aborts a pending setup and publishes nothing', async () => {
  209. const ctx = await harness()
  210. const gate = Promise.withResolvers<undefined>()
  211. const setupStarted = Promise.withResolvers<undefined>()
  212. const published: string[] = []
  213. ctx.on('session/created', () => void published.push('session/created'))
  214. ctx.on('agent/created', () => void published.push('agent/created'))
  215. let creating!: ReturnType<typeof ctx.agents.create>
  216. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  217. creating = inner.agents.create({
  218. agentId: AgentId('owner-race'),
  219. sessionId: SessionId('owner-race-s'),
  220. agentOptions: { model: 'mock' },
  221. setup: async () => {
  222. setupStarted.resolve(undefined)
  223. await gate.promise
  224. },
  225. })
  226. }, { inject: ['agents'] }))
  227. await setupStarted.promise
  228. await owner.dispose()
  229. await expect(creating).rejects.toThrow(/owner disposed during setup/)
  230. expect(published).toEqual([])
  231. expect(ctx.agents.get(AgentId('owner-race'))).toBeUndefined()
  232. expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined()
  233. // Let the losing callback settle; Promise.race already observes it.
  234. gate.resolve(undefined)
  235. await Promise.resolve()
  236. // The other ordering in the same race: setup resolves first (its reaction
  237. // is queued), then owner disposal flips active before that continuation can
  238. // publish. The post-race active check must still reject.
  239. const gate2 = Promise.withResolvers<undefined>()
  240. const setupStarted2 = Promise.withResolvers<undefined>()
  241. let creating2!: ReturnType<typeof ctx.agents.create>
  242. const owner2 = await ctx.plugin(Object.assign((inner: Context) => {
  243. creating2 = inner.agents.create({
  244. agentId: AgentId('owner-race-2'),
  245. sessionId: SessionId('owner-race-s-2'),
  246. agentOptions: { model: 'mock' },
  247. setup: async () => {
  248. setupStarted2.resolve(undefined)
  249. await gate2.promise
  250. },
  251. })
  252. }, { inject: ['agents'] }))
  253. await setupStarted2.promise
  254. gate2.resolve(undefined)
  255. const unload2 = owner2.dispose()
  256. await expect(creating2).rejects.toThrow(/owner disposed during setup/)
  257. await unload2
  258. expect(ctx.agents.get(AgentId('owner-race-2'))).toBeUndefined()
  259. expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined()
  260. })
  261. it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => {
  262. const ctx = await harness()
  263. const published: string[] = []
  264. ctx.on('session/created', () => void published.push('session/created'))
  265. ctx.on('agent/created', () => void published.push('agent/created'))
  266. ctx.on('agent/session-start', () => void published.push('agent/session-start'))
  267. await expect(ctx.agents.create({
  268. agentId: AgentId('bad'),
  269. sessionId: SessionId('bad-s'),
  270. agentOptions: { model: 'mock' },
  271. setup: async () => {
  272. await Promise.resolve()
  273. throw new Error('boom setup')
  274. },
  275. })).rejects.toThrow('boom setup')
  276. // Nothing leaked: no agent, no session, and the ids are reusable.
  277. expect(published).toEqual([])
  278. expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
  279. expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
  280. const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
  281. await retry.dispose()
  282. })
  283. it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
  284. const ctx = await harness()
  285. let boom = true
  286. const disposed: string[] = []
  287. ctx.on('agent/disposed', agent => void disposed.push(agent.id))
  288. ctx.on('session/created', () => {
  289. if (boom) { boom = false; throw new Error('boom created') }
  290. })
  291. await expect(ctx.agents.create({
  292. agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' },
  293. })).rejects.toThrow('boom created')
  294. expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
  295. expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
  296. expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge
  297. // The rollback also disposed the scope fiber: re-creating works cleanly.
  298. const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
  299. expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
  300. await retry.dispose()
  301. })
  302. it('the synchronous config helper rolls back when publication throws', async () => {
  303. const ctx = await harness()
  304. const sessionsBefore = ctx.sessions.list().length
  305. let boom = true
  306. ctx.on('session/created', () => {
  307. if (boom) {
  308. boom = false
  309. throw new Error('config publish failed')
  310. }
  311. })
  312. expect(() => ctx.agentLoop.create(AgentId('config-bad'), { model: 'mock' }))
  313. .toThrow('config publish failed')
  314. expect(ctx.agents.get(AgentId('config-bad'))).toBeUndefined()
  315. expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
  316. })
  317. it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
  318. const ctx = await harness()
  319. const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
  320. await handle.dispose()
  321. expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
  322. })
  323. it('agentEvents fuses carrier and subject for custom drivers', async () => {
  324. const ctx = await harness()
  325. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  326. const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
  327. const heard: string[] = []
  328. agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`))
  329. agentEvents(ctx, other).emit('agent/error', 1, 0, new Error('not for a1'))
  330. agentEvents(ctx, agent).emit('agent/error', 2, 0, new Error('for a1'))
  331. expect(heard).toEqual(['a1:2'])
  332. })
  333. it('a listener may drive the agent through its declared `this` (the carrier is method-transparent)', async () => {
  334. // ds-review-bot regression: agent/* listeners are typed
  335. // `this: Scoped<Agent>`, and ReactLoopAgent's send/steer/cancel read the
  336. // native-private #carrier — a proxy-receiver carrier made
  337. // `this.send(...)` throw TypeError. The carrier binds methods to the real
  338. // agent, so driving through the event `this` is a working supported shape.
  339. const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
  340. const ctx = await harness(adapter)
  341. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  342. let followUpSent = false
  343. ctx.on('agent/session-start', function (this: Agent) {
  344. // Deliberately through `this`, not the args subject.
  345. this.send(text('driven through this'))
  346. followUpSent = true
  347. })
  348. const second = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
  349. expect(followUpSent).toBe(true)
  350. await second.whenIdle()
  351. // The send actually reached the loop: the prompt ran a turn.
  352. expect(second.session.events.some(e => e.type === 'turn/start')).toBe(true)
  353. await agent.whenIdle()
  354. })
  355. it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
  356. const ctx = await harness()
  357. let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
  358. const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
  359. handle = await inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } })
  360. }, { inject: ['agents'] }))
  361. const { agent } = handle
  362. const order: string[] = []
  363. ctx.on('session/event', (_s, event) => {
  364. if (event.type === 'turn/end') order.push('turn-end')
  365. })
  366. ctx.on('agent/disposed', () => {
  367. order.push(`disposed(listed=${ctx.agents.get(AgentId('o1')) !== undefined})`)
  368. order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
  369. })
  370. // Open a turn so the drain has real work: the loop must finish it BEFORE
  371. // the registry entry goes away (the agent/disposed contract: "its fiber
  372. // and any in-flight turn have been torn down"). Wait for the turn to be
  373. // OPEN in the log — a dispose landing in the pre-step window would drop
  374. // the queued prompt without ever opening a turn.
  375. const turnOpen = new Promise<void>((resolve) => {
  376. const off = ctx.on('session/event', (_s, event) => {
  377. if (event.type === 'turn/start') { off(); resolve() }
  378. })
  379. })
  380. agent.send(text('work'))
  381. await turnOpen
  382. await owner.dispose()
  383. expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true'])
  384. expect(ctx.sessions.get(SessionId('o1-s'))).toBeUndefined()
  385. })
  386. it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => {
  387. const ctx = await harness()
  388. let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
  389. const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
  390. handle = await inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } })
  391. }, { inject: ['agents'] }))
  392. const teardownDone: string[] = []
  393. ctx.on('agent/disposed', () => void teardownDone.push('unregistered'))
  394. // Owner unload begins FIRST (invokes the raw cordis wrapper)…
  395. const unload = owner.dispose()
  396. // …and a concurrent handle.dispose() must not resolve before the chain
  397. // actually finished (the raw wrapper returns undefined on a repeat call).
  398. await handle.dispose()
  399. expect(teardownDone).toContain('unregistered')
  400. expect(ctx.agents.get(AgentId('h1'))).toBeUndefined()
  401. expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined()
  402. await unload
  403. })
  404. })