agent.spec.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  4. import AgentRegistry, { Agent, AgentId } from '@deepseek-ai/dsh-agent'
  5. function stubAgent(rawId: string): Agent {
  6. const id = AgentId(rawId)
  7. return {
  8. id,
  9. options: {},
  10. session: new Session(SessionId(`${id}-session`)),
  11. status: 'idle',
  12. send() {},
  13. steer() {},
  14. inject() {},
  15. cancel() {},
  16. whenIdle() { return Promise.resolve() },
  17. }
  18. }
  19. describe('AgentRegistry', () => {
  20. it('registers agents and emits created/disposed events', async () => {
  21. const ctx = new Context()
  22. await ctx.plugin(AgentRegistry)
  23. const created: string[] = []
  24. const disposed: string[] = []
  25. ctx.on('agent/created', agent => void created.push(agent.id))
  26. ctx.on('agent/disposed', agent => void disposed.push(agent.id))
  27. const agent = stubAgent('a1')
  28. const dispose = ctx.agents.register(agent)
  29. expect(created).toEqual(['a1'])
  30. expect(ctx.agents.get(AgentId('a1'))).toBe(agent)
  31. expect(ctx.agents.list()).toEqual([agent])
  32. dispose()
  33. expect(disposed).toEqual(['a1'])
  34. expect(ctx.agents.get(AgentId('a1'))).toBeUndefined()
  35. })
  36. it('rejects duplicate ids and unregisters on fiber dispose (HMR safety)', async () => {
  37. const ctx = new Context()
  38. await ctx.plugin(AgentRegistry)
  39. ctx.agents.register(stubAgent('main'))
  40. expect(() => ctx.agents.register(stubAgent('main'))).toThrow('already registered')
  41. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  42. inner.agents.register(stubAgent('scoped'))
  43. }, { inject: ['agents'] }))
  44. expect(ctx.agents.list().map(a => a.id)).toEqual(['main', 'scoped'])
  45. await fiber.dispose()
  46. expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
  47. })
  48. it('rolls back the agent entry when an agent/created listener throws (P1-1)', async () => {
  49. const ctx = new Context()
  50. await ctx.plugin(AgentRegistry)
  51. let threw = false
  52. ctx.on('agent/created', () => {
  53. if (!threw) { threw = true; throw new Error('boom created listener') }
  54. })
  55. // The throwing emit must roll the entry back, not leak it.
  56. expect(() => ctx.agents.register(stubAgent('main'))).toThrow('boom created listener')
  57. expect(ctx.agents.get(AgentId('main'))).toBeUndefined() // rolled back, not leaked
  58. // A subsequent listener-free register of the SAME id succeeds and is
  59. // tracked exactly once (the duplicate-id check is not wedged).
  60. const dispose = ctx.agents.register(stubAgent('main'))
  61. expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
  62. dispose()
  63. expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
  64. })
  65. })
  66. describe('AgentRegistry factory seam', () => {
  67. /** A stub AgentFactory that records calls and returns a stub agent. */
  68. function stubFactory() {
  69. const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
  70. const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
  71. createAgent(options) {
  72. calls.create.push(options)
  73. return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
  74. },
  75. resume(options) {
  76. calls.resume.push(options)
  77. return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
  78. },
  79. }
  80. return { factory, calls }
  81. }
  82. it('create()/resume() throw when no factory is registered', async () => {
  83. const ctx = new Context()
  84. await ctx.plugin(AgentRegistry)
  85. expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).toThrow(/no agent factory/)
  86. await expect(ctx.agents.resume({ agentId: AgentId('a'), resumeSessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
  87. })
  88. it('setFactory registers a factory; create/resume delegate to it', async () => {
  89. const ctx = new Context()
  90. await ctx.plugin(AgentRegistry)
  91. const { factory, calls } = stubFactory()
  92. ctx.agents.setFactory(factory)
  93. const created = ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
  94. expect(created.agent.id).toBe('c1')
  95. expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }])
  96. const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') })
  97. expect(resumed.agent.id).toBe('r1')
  98. expect(calls.resume).toEqual([{ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }])
  99. })
  100. it('setFactory rejects a second factory', async () => {
  101. const ctx = new Context()
  102. await ctx.plugin(AgentRegistry)
  103. ctx.agents.setFactory(stubFactory().factory)
  104. expect(() => ctx.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
  105. })
  106. it('disposing the setFactory fiber clears the factory (HMR safety)', async () => {
  107. const ctx = new Context()
  108. await ctx.plugin(AgentRegistry)
  109. let dispose!: () => void
  110. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  111. dispose = inner.agents.setFactory(stubFactory().factory)
  112. }, { inject: ['agents'] }))
  113. expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).not.toThrow()
  114. void dispose
  115. await fiber.dispose()
  116. // factory slot cleared → create throws again
  117. expect(() => ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).toThrow(/no agent factory/)
  118. })
  119. })