1
0

agent.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. import { describe, expect, expectTypeOf, it } from 'vitest'
  2. import { Context, Service, symbols } from '@deepseek-ai/cordis'
  3. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  4. import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
  5. import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
  6. import type {
  7. Agent,
  8. AgentCancelCause,
  9. AgentFactory,
  10. AgentStatus,
  11. CreateAgentOptions,
  12. ResumeAgentOptions,
  13. } from '@deepseek-ai/dsh-agent'
  14. function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
  15. const id = SessionId(rawId)
  16. const session = overrides.session ?? Session.create(id)
  17. const ctx = overrides.ctx ?? new Context()
  18. const agent: Agent = {
  19. id,
  20. options: {},
  21. session,
  22. inbox: {
  23. nextTurn: [], nextStep: [],
  24. } as never,
  25. status: 'idle',
  26. ctx,
  27. send: () => {},
  28. followup: () => {},
  29. steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
  30. inject: () => {},
  31. cancel() {},
  32. runMaintenance: task => task(new AbortController().signal),
  33. whenIdle: () => Promise.resolve(),
  34. ...overrides,
  35. }
  36. return agent
  37. }
  38. describe('AgentRegistry', () => {
  39. it('contributes Agent lookup and scoped Context providers while Typert is live', async () => {
  40. const ctx = new Context()
  41. const agentFiber = ctx.plugin(AgentRegistry)
  42. await agentFiber
  43. await ctx.plugin(TypertRegistry)
  44. const agent = stubAgent('remote-agent')
  45. const disposeAgent = await ctx.agents.register(agent)
  46. const lookup = ctx.typert.lookups.get('agent')
  47. expect(lookup).toMatchObject({
  48. parameter: 'agent',
  49. wire: 'agentId',
  50. hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent',
  51. wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
  52. })
  53. expect(lookup?.resolve(agent.id)).toBe(agent)
  54. const context = ctx.typert.contexts.getHost('agent')
  55. expect(context?.resolve(agent.id)).toBe(agent.ctx)
  56. await disposeAgent()
  57. expect(lookup?.resolve(agent.id)).toBeUndefined()
  58. await agentFiber.dispose()
  59. expect(ctx.typert.lookups.get('agent')).toBeUndefined()
  60. expect(ctx.typert.contexts.getHost('agent')).toBeUndefined()
  61. })
  62. it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
  63. const ctx = new Context()
  64. await ctx.plugin(AgentRegistry)
  65. const lifecycle: string[] = []
  66. ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
  67. ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
  68. const agent = stubAgent('a1')
  69. const dispose = await ctx.agents.register(agent)
  70. expect(ctx.agents.get(agent.id)).toBe(agent)
  71. expect(ctx.agents.list()).toEqual([agent])
  72. expect(ctx.agents.roots()).toEqual([agent])
  73. await expect(Promise.resolve(ctx.agents.register(stubAgent('a1')))).rejects.toThrow(/already registered/)
  74. await dispose()
  75. expect(ctx.agents.get(agent.id)).toBeUndefined()
  76. expect(lifecycle).toEqual(['created:a1', 'disposed:a1'])
  77. })
  78. it('rejects an agent whose registry and session identities differ', async () => {
  79. const ctx = new Context()
  80. await ctx.plugin(AgentRegistry)
  81. const agent = stubAgent('agent-id', { session: Session.create(SessionId('session-id')) })
  82. expect(() => ctx.agents.enter(agent, undefined))
  83. .toThrow('agent id "agent-id" does not match session id "session-id"')
  84. expect(ctx.agents.list()).toEqual([])
  85. })
  86. it('tracks runtime creator ownership separately from registry order', async () => {
  87. const ctx = new Context()
  88. await ctx.plugin(AgentRegistry)
  89. const root = stubAgent('root')
  90. const child = stubAgent('child')
  91. const detachRoot = ctx.agents.enter(root, undefined)
  92. await ctx.agents.announce(root, 'startup')
  93. const detachChild = ctx.agents.enter(child, root)
  94. await ctx.agents.announce(child, 'startup')
  95. expect(ctx.agents.list()).toEqual([root, child])
  96. expect(ctx.agents.roots()).toEqual([root])
  97. expect(ctx.agents.isOwnedBy(child.id, root)).toBe(true)
  98. expect(ctx.agents.isOwnedBy(root.id, root)).toBe(false)
  99. expect(ctx.agents.isOwnedBy(SessionId('missing'), root)).toBe(false)
  100. detachChild()
  101. expect(ctx.agents.isOwnedBy(child.id, root)).toBe(false)
  102. detachRoot()
  103. })
  104. it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => {
  105. const ctx = new Context()
  106. await ctx.plugin(AgentRegistry)
  107. const lifecycle: string[] = []
  108. ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
  109. ctx.on('agent/created', () => { throw new Error('creation veto') })
  110. ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
  111. await expect(Promise.resolve(ctx.agents.register(stubAgent('vetoed')))).rejects.toThrow('creation veto')
  112. expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined()
  113. expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed'])
  114. })
  115. it('rolls back asynchronous creation rejection and contains every disposal-listener failure', async () => {
  116. const ctx = new Context()
  117. await ctx.plugin(AgentRegistry)
  118. const warnings: string[] = []
  119. const heard: string[] = []
  120. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  121. ctx.on('agent/created', () => Promise.reject(new Error('created async')))
  122. ctx.on('agent/created', () => { heard.push('unreachable') })
  123. ctx.on('agent/disposed', () => { throw new Error('disposed sync') })
  124. ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never)
  125. ctx.on('agent/disposed', ({ agent }) => void heard.push(agent.id))
  126. await expect(Promise.resolve(ctx.agents.register(stubAgent('contained')))).rejects.toThrow('created async')
  127. await Promise.resolve()
  128. expect(heard).toEqual(['contained'])
  129. expect(ctx.agents.list()).toEqual([])
  130. expect(warnings).toEqual([
  131. 'agent "contained": agent/disposed listener threw: Error: disposed sync',
  132. 'agent "contained": agent/disposed listener rejected: Error: disposed async',
  133. ])
  134. })
  135. it('awaits each creation listener before the next listener and registration completion', async () => {
  136. const ctx = new Context()
  137. await ctx.plugin(AgentRegistry)
  138. const entered = Promise.withResolvers<undefined>()
  139. const release = Promise.withResolvers<undefined>()
  140. const order: string[] = []
  141. ctx.on('agent/created', async () => {
  142. order.push('first:start')
  143. entered.resolve(undefined)
  144. await release.promise
  145. order.push('first:end')
  146. })
  147. ctx.on('agent/created', () => { order.push('second') })
  148. const agent = stubAgent('serial')
  149. const registration = ctx.agents.register(agent)
  150. const ready = Promise.resolve(registration).then(() => { order.push('ready') })
  151. try {
  152. await entered.promise
  153. expect(order).toEqual(['first:start'])
  154. await expect(ctx.agents.announce(agent, 'startup')).rejects.toThrow('already announced')
  155. release.resolve(undefined)
  156. await ready
  157. expect(order).toEqual(['first:start', 'first:end', 'second', 'ready'])
  158. } finally {
  159. release.resolve(undefined)
  160. await registration()
  161. await ctx.fiber.dispose()
  162. }
  163. })
  164. it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => {
  165. const ctx = new Context()
  166. await ctx.plugin(AgentRegistry)
  167. const lifecycle: string[] = []
  168. ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
  169. ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
  170. const first = stubAgent('split')
  171. const detachFirst = ctx.agents.enter(first, undefined)
  172. expect(lifecycle).toEqual([])
  173. await ctx.agents.announce(first, 'startup')
  174. await expect(ctx.agents.announce(first, 'startup')).rejects.toThrow(/already announced/)
  175. detachFirst()
  176. detachFirst()
  177. const replacement = stubAgent('split')
  178. const detachReplacement = ctx.agents.enter(replacement, undefined)
  179. detachFirst()
  180. expect(ctx.agents.get(replacement.id)).toBe(replacement)
  181. await expect(ctx.agents.announce(first, 'startup')).rejects.toThrow(/not live/)
  182. detachReplacement()
  183. expect(lifecycle).toEqual(['created:split', 'disposed:split'])
  184. })
  185. it('defers detach requested by a creation listener until that dispatch unwinds', async () => {
  186. const ctx = new Context()
  187. await ctx.plugin(AgentRegistry)
  188. const order: string[] = []
  189. const agent = stubAgent('reentrant')
  190. ctx.on('agent/created', () => {
  191. order.push(`first:${ctx.agents.get(agent.id) === agent}`)
  192. detach()
  193. order.push(`after-detach:${ctx.agents.get(agent.id) === agent}`)
  194. })
  195. ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`))
  196. ctx.on('agent/disposed', () => void order.push('disposed'))
  197. const detach = ctx.agents.enter(agent, undefined)
  198. await ctx.agents.announce(agent, 'startup')
  199. expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed'])
  200. expect(ctx.agents.get(agent.id)).toBeUndefined()
  201. })
  202. })
  203. describe('agentEvents()', () => {
  204. it('contains each synchronous throw and returned-promise rejection', async () => {
  205. const ctx = new Context()
  206. const warnings: string[] = []
  207. const heard: string[] = []
  208. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  209. const agent = stubAgent('event')
  210. ctx.on('agent/status', () => { throw new Error('sync listener') })
  211. ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never)
  212. ctx.on('agent/status', ({ status }) => void heard.push(status))
  213. agentEvents(ctx, agent).emit('agent/status', { status: 'running' })
  214. await Promise.resolve()
  215. expect(heard).toEqual(['running'])
  216. expect(warnings).toEqual([
  217. 'agent event "agent/status" listener threw: Error: sync listener',
  218. 'agent event "agent/status" listener rejected: Error: async listener',
  219. ])
  220. })
  221. it('dispatches serial listeners with the fused agent subject', async () => {
  222. const ctx = new Context()
  223. const agent = stubAgent('serial-event')
  224. const signal = new AbortController().signal
  225. const heard: Array<{ agent: Agent; turn: number; signal: AbortSignal }> = []
  226. ctx.on('agent/turn-stopping', async ({ agent: subject, turn, signal: receivedSignal }) => {
  227. await Promise.resolve()
  228. heard.push({ agent: subject, turn, signal: receivedSignal })
  229. })
  230. await agentEvents(ctx, agent).serial('agent/turn-stopping', { turn: 3, signal })
  231. expect(heard).toEqual([{ agent, turn: 3, signal }])
  232. })
  233. it('injects the fused subject even when the payload carries a conflicting agent field', async () => {
  234. const ctx = new Context()
  235. const agent = stubAgent('fused-subject')
  236. const other = stubAgent('payload-agent')
  237. const heard: Agent[] = []
  238. ctx.on('agent/status', ({ agent: subject }) => void heard.push(subject))
  239. // A structurally acceptable payload may carry an extra `agent` field; the
  240. // dispatcher's injected subject must win over it.
  241. const payload: { status: AgentStatus; agent: Agent } = { status: 'running', agent: other }
  242. agentEvents(ctx, agent).emit('agent/status', payload)
  243. expect(heard).toEqual([agent])
  244. })
  245. })
  246. describe('explicit cancellation contract', () => {
  247. it('exposes the closed typed cancellation cause at the Agent seam', () => {
  248. expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause>()
  249. })
  250. })
  251. describe('AgentRegistry factory seam', () => {
  252. function stubFactory() {
  253. const calls: {
  254. create: Array<{ ownerCtx: Context; options: CreateAgentOptions }>
  255. resume: Array<{ ownerCtx: Context; options: ResumeAgentOptions }>
  256. } = { create: [], resume: [] }
  257. const factory: AgentFactory = {
  258. async createAgent(ownerCtx, options) {
  259. calls.create.push({ ownerCtx, options })
  260. return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() }
  261. },
  262. async resume(ownerCtx, options) {
  263. calls.resume.push({ ownerCtx, options })
  264. return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() }
  265. },
  266. }
  267. return { factory, calls }
  268. }
  269. it('requires a factory and delegates through the calling context', async () => {
  270. const ctx = new Context()
  271. await ctx.plugin(AgentRegistry)
  272. await expect(ctx.agents.create({ sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
  273. const { factory, calls } = stubFactory()
  274. ctx.agents.setFactory(factory)
  275. let callerFiber: Context['fiber'] | undefined
  276. await ctx.plugin(Object.assign(async (inner: Context) => {
  277. callerFiber = inner.fiber
  278. await inner.agents.create({ sessionId: SessionId('create-s') })
  279. await inner.agents.resume({ resumeSessionId: SessionId('resume-s') })
  280. }, { inject: ['agents'] }))
  281. expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber)
  282. expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber)
  283. expect(calls.create[0]?.options.parentAgent).toBeUndefined()
  284. expect(calls.resume[0]?.options.parentAgent).toBeUndefined()
  285. })
  286. it('keeps the runtime parent in options separately from the caller context', async () => {
  287. const ctx = new Context()
  288. await ctx.plugin(AgentRegistry)
  289. const { factory, calls } = stubFactory()
  290. ctx.agents.setFactory(factory)
  291. const parent = stubAgent('parent')
  292. const unregister = await ctx.agents.register(parent)
  293. await ctx.agents.create({ sessionId: SessionId('child'), parentAgent: parent })
  294. expect(calls.create[0]?.options.parentAgent).toBe(parent)
  295. await unregister()
  296. })
  297. it('rejects a second factory and clears the slot with its owner (HMR)', async () => {
  298. const ctx = new Context()
  299. await ctx.plugin(AgentRegistry)
  300. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  301. inner.agents.setFactory(stubFactory().factory)
  302. expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
  303. }, { inject: ['agents'] }))
  304. await expect(ctx.agents.create({ sessionId: SessionId('before-s') })).resolves.toBeDefined()
  305. await owner.dispose()
  306. await expect(ctx.agents.create({ sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/)
  307. })
  308. it('canonicalizes an already traced Service before tracing it for the caller', async () => {
  309. const ctx = new Context()
  310. await ctx.plugin(AgentRegistry)
  311. const states = new WeakMap<object, string[]>()
  312. class TracedFactory extends Service implements AgentFactory {
  313. constructor(inner: Context) {
  314. super(inner, 'tracedFactory')
  315. states.set(this, [])
  316. }
  317. private calls(): string[] {
  318. const original = (this as unknown as { [symbols.original]?: TracedFactory })[symbols.original] ?? this
  319. const calls = states.get(original)
  320. if (calls === undefined) throw new Error('factory receiver was not canonicalized')
  321. return calls
  322. }
  323. async createAgent(_ownerCtx: Context, options: CreateAgentOptions) {
  324. this.calls().push('create')
  325. return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() }
  326. }
  327. async resume(_ownerCtx: Context, options: ResumeAgentOptions) {
  328. this.calls().push('resume')
  329. return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() }
  330. }
  331. }
  332. await ctx.plugin(TracedFactory)
  333. const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory
  334. ctx.agents.setFactory(traced)
  335. await ctx.agents.create({ sessionId: SessionId('create-s') })
  336. await ctx.agents.resume({ resumeSessionId: SessionId('resume-s') })
  337. const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original]
  338. expect(states.get(raw!)).toEqual(['create', 'resume'])
  339. })
  340. })