1
0

scope-lifecycle.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  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 type { ContentBlock } from '@deepseek-ai/dsh-llm'
  12. import { MockAdapter, textResponse } from './mock-adapter.ts'
  13. async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])) {
  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. await ctx.plugin(AgentLoop, { agents: [] })
  21. ctx.llm.registerAdapter(['mock'], adapter)
  22. return ctx
  23. }
  24. function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
  25. return new Promise((resolve) => {
  26. const dispose = ctx.on('agent/status', (subject, status) => {
  27. if (subject === agent && status === 'idle') {
  28. dispose()
  29. resolve()
  30. }
  31. })
  32. })
  33. }
  34. const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
  35. describe('agent scope lifecycle', () => {
  36. it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
  37. const ctx = await harness()
  38. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  39. expect(scopeOf(agent.ctx)).toBe(agent)
  40. expect(agent.ctx.agent).toBe(agent)
  41. // The root accessor default: a plain context answers undefined, not a throw.
  42. expect(ctx.agent).toBeUndefined()
  43. await ctx.agents.get(AgentId('a1'))?.whenIdle()
  44. })
  45. it('scoped registrations live in the agent world and die with the agent', async () => {
  46. const ctx = await harness()
  47. const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
  48. const { agent } = handle
  49. agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
  50. agent.ctx.tools.register({
  51. name: 'mine', description: 'scoped', parameters: {},
  52. execute: () => Promise.resolve(text('ran')),
  53. })
  54. const scopedAssembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
  55. expect(scopedAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You run tests.')
  56. expect(scopedAssembly.tools.map(t => t.name)).toContain('mine')
  57. // Other assemblies are untouched.
  58. const globalAssembly = await ctx.systemPrompt.assemble()
  59. expect(globalAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.')
  60. expect(globalAssembly.tools.map(t => t.name)).not.toContain('mine')
  61. await handle.dispose()
  62. // The scoped world unwound with the agent: nothing leaked into the registries.
  63. expect(ctx.tools.get('mine', agent)).toBeUndefined()
  64. const after = await ctx.systemPrompt.assemble(assembleContextFor(agent))
  65. expect(after.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.')
  66. })
  67. it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => {
  68. const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')]))
  69. const a = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
  70. const b = ctx.agentLoop.create(AgentId('b'), { model: 'mock' })
  71. const heard: string[] = []
  72. a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`))
  73. a.ctx.on('session/event', (_s, event) => {
  74. if (event.type === 'user/message') heard.push('a-sees:user-message')
  75. })
  76. b.send(text('for b'))
  77. await waitForIdle(ctx, b)
  78. expect(heard).toEqual([]) // nothing of b's leaked into a's scope
  79. a.send(text('for a'))
  80. await waitForIdle(ctx, a)
  81. expect(heard).toContain('a-sees:a:running')
  82. expect(heard).toContain('a-sees:user-message')
  83. })
  84. it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => {
  85. const ctx = await harness()
  86. const order: string[] = []
  87. ctx.on('agent/session-start', (agent) => {
  88. order.push('session-start')
  89. // The scoped section is already registered by the time session-start fires.
  90. void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => {
  91. order.push(`persona:${assembly.sections.find(s => s.name === 'deployment:persona')?.text}`)
  92. })
  93. })
  94. const handle = ctx.agents.create({
  95. agentId: AgentId('child'),
  96. sessionId: SessionId('child-s'),
  97. agentOptions: { model: 'mock' },
  98. setup: (agentCtx) => {
  99. order.push('setup')
  100. agentCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the child.' })
  101. },
  102. })
  103. await new Promise(resolve => setTimeout(resolve, 0))
  104. expect(order).toEqual(['setup', 'session-start', 'persona:You are the child.'])
  105. await handle.dispose()
  106. })
  107. it('a throwing setup unwinds the half-created agent completely', async () => {
  108. const ctx = await harness()
  109. expect(() => ctx.agents.create({
  110. agentId: AgentId('bad'),
  111. sessionId: SessionId('bad-s'),
  112. agentOptions: { model: 'mock' },
  113. setup: () => { throw new Error('boom setup') },
  114. })).toThrow('boom setup')
  115. // Nothing leaked: no agent, no session, and the ids are reusable.
  116. expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
  117. expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
  118. const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
  119. await retry.dispose()
  120. })
  121. it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
  122. const ctx = await harness()
  123. let boom = true
  124. ctx.on('session/created', () => {
  125. if (boom) { boom = false; throw new Error('boom created') }
  126. })
  127. expect(() => ctx.agents.create({
  128. agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' },
  129. })).toThrow('boom created')
  130. expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
  131. expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
  132. // The rollback also disposed the scope fiber: re-creating works cleanly.
  133. const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
  134. expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
  135. await retry.dispose()
  136. })
  137. it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
  138. const ctx = await harness()
  139. const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
  140. await handle.dispose()
  141. expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
  142. })
  143. it('agentEvents fuses carrier and subject for custom drivers', async () => {
  144. const ctx = await harness()
  145. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  146. const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
  147. const heard: string[] = []
  148. agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`))
  149. agentEvents(ctx, other).emit('agent/error', 1, 0, new Error('not for a1'))
  150. agentEvents(ctx, agent).emit('agent/error', 2, 0, new Error('for a1'))
  151. expect(heard).toEqual(['a1:2'])
  152. })
  153. it('a listener may drive the agent through its declared `this` (the carrier is method-transparent)', async () => {
  154. // ds-review-bot regression: agent/* listeners are typed
  155. // `this: Scoped<Agent>`, and ReactLoopAgent's send/steer/cancel read the
  156. // native-private #carrier — a proxy-receiver carrier made
  157. // `this.send(...)` throw TypeError. The carrier binds methods to the real
  158. // agent, so driving through the event `this` is a working supported shape.
  159. const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
  160. const ctx = await harness(adapter)
  161. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  162. let followUpSent = false
  163. ctx.on('agent/session-start', function (this: Agent) {
  164. // Deliberately through `this`, not the args subject.
  165. this.send(text('driven through this'))
  166. followUpSent = true
  167. })
  168. const second = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
  169. expect(followUpSent).toBe(true)
  170. await second.whenIdle()
  171. // The send actually reached the loop: the prompt ran a turn.
  172. expect(second.session.events.some(e => e.type === 'turn/start')).toBe(true)
  173. await agent.whenIdle()
  174. })
  175. it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
  176. const ctx = await harness()
  177. let handle!: ReturnType<typeof ctx.agents.create>
  178. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  179. handle = inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } })
  180. }, { inject: ['agents'] }))
  181. const { agent } = handle
  182. const order: string[] = []
  183. ctx.on('session/event', (_s, event) => {
  184. if (event.type === 'turn/end') order.push('turn-end')
  185. })
  186. ctx.on('agent/disposed', () => {
  187. order.push(`disposed(listed=${ctx.agents.get(AgentId('o1')) !== undefined})`)
  188. order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
  189. })
  190. // Open a turn so the drain has real work: the loop must finish it BEFORE
  191. // the registry entry goes away (the agent/disposed contract: "its fiber
  192. // and any in-flight turn have been torn down"). Wait for the turn to be
  193. // OPEN in the log — a dispose landing in the pre-step window would drop
  194. // the queued prompt without ever opening a turn.
  195. const turnOpen = new Promise<void>((resolve) => {
  196. const off = ctx.on('session/event', (_s, event) => {
  197. if (event.type === 'turn/start') { off(); resolve() }
  198. })
  199. })
  200. agent.send(text('work'))
  201. await turnOpen
  202. await owner.dispose()
  203. expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true'])
  204. expect(ctx.sessions.get(SessionId('o1-s'))).toBeUndefined()
  205. })
  206. it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => {
  207. const ctx = await harness()
  208. let handle!: ReturnType<typeof ctx.agents.create>
  209. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  210. handle = inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } })
  211. }, { inject: ['agents'] }))
  212. const teardownDone: string[] = []
  213. ctx.on('agent/disposed', () => void teardownDone.push('unregistered'))
  214. // Owner unload begins FIRST (invokes the raw cordis wrapper)…
  215. const unload = owner.dispose()
  216. // …and a concurrent handle.dispose() must not resolve before the chain
  217. // actually finished (the raw wrapper returns undefined on a repeat call).
  218. await handle.dispose()
  219. expect(teardownDone).toContain('unregistered')
  220. expect(ctx.agents.get(AgentId('h1'))).toBeUndefined()
  221. expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined()
  222. await unload
  223. })
  224. })