agent.spec.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  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. // A bare context stands in for the agent scope: registry tests never
  13. // register through it, they only need the field present.
  14. ctx: new Context(),
  15. send() {},
  16. steer() {},
  17. inject() {},
  18. cancel() {},
  19. whenIdle() { return Promise.resolve() },
  20. }
  21. }
  22. describe('AgentRegistry', () => {
  23. it('registers agents and emits created/disposed events', async () => {
  24. const ctx = new Context()
  25. await ctx.plugin(AgentRegistry)
  26. const created: string[] = []
  27. const disposed: string[] = []
  28. ctx.on('agent/created', agent => void created.push(agent.id))
  29. ctx.on('agent/disposed', agent => void disposed.push(agent.id))
  30. const agent = stubAgent('a1')
  31. const dispose = ctx.agents.register(agent)
  32. expect(created).toEqual(['a1'])
  33. expect(ctx.agents.get(AgentId('a1'))).toBe(agent)
  34. expect(ctx.agents.list()).toEqual([agent])
  35. await dispose()
  36. expect(disposed).toEqual(['a1'])
  37. expect(ctx.agents.get(AgentId('a1'))).toBeUndefined()
  38. })
  39. it('rejects duplicate ids and unregisters on fiber dispose (HMR safety)', async () => {
  40. const ctx = new Context()
  41. await ctx.plugin(AgentRegistry)
  42. ctx.agents.register(stubAgent('main'))
  43. expect(() => ctx.agents.register(stubAgent('main'))).toThrow('already registered')
  44. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  45. inner.agents.register(stubAgent('scoped'))
  46. }, { inject: ['agents'] }))
  47. expect(ctx.agents.list().map(a => a.id)).toEqual(['main', 'scoped'])
  48. await fiber.dispose()
  49. expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
  50. })
  51. it('rolls back the agent entry when an agent/created listener throws (P1-1)', async () => {
  52. const ctx = new Context()
  53. await ctx.plugin(AgentRegistry)
  54. let threw = false
  55. ctx.on('agent/created', () => {
  56. if (!threw) { threw = true; throw new Error('boom created listener') }
  57. })
  58. // The throwing emit must roll the entry back, not leak it.
  59. expect(() => ctx.agents.register(stubAgent('main'))).toThrow('boom created listener')
  60. expect(ctx.agents.get(AgentId('main'))).toBeUndefined() // rolled back, not leaked
  61. // A subsequent listener-free register of the SAME id succeeds and is
  62. // tracked exactly once (the duplicate-id check is not wedged).
  63. const dispose = ctx.agents.register(stubAgent('main'))
  64. expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
  65. await dispose()
  66. expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
  67. })
  68. it('splits insertion from announcement and makes the detach exact/idempotent', async () => {
  69. const ctx = new Context()
  70. await ctx.plugin(AgentRegistry)
  71. const created: Agent[] = []
  72. const disposed: Agent[] = []
  73. ctx.on('agent/created', agent => void created.push(agent))
  74. ctx.on('agent/disposed', agent => void disposed.push(agent))
  75. const first = stubAgent('split')
  76. const detachFirst = ctx.agents.enter(first)
  77. expect(ctx.agents.get(first.id)).toBe(first)
  78. expect(created).toEqual([])
  79. ctx.agents.announce(first)
  80. expect(created).toEqual([first])
  81. detachFirst()
  82. detachFirst()
  83. expect(disposed).toEqual([first])
  84. const replacement = stubAgent('split')
  85. const detachReplacement = ctx.agents.enter(replacement)
  86. // A stale repeated detach cannot remove the replacement.
  87. detachFirst()
  88. expect(ctx.agents.get(replacement.id)).toBe(replacement)
  89. expect(() => { ctx.agents.announce(first) }).toThrow(/not live/)
  90. detachReplacement()
  91. // The replacement was inserted but never announced, so rollback produces
  92. // no disposed-without-created notification.
  93. expect(disposed).toEqual([first])
  94. })
  95. })
  96. describe('AgentRegistry factory seam', () => {
  97. /** A stub AgentFactory that records calls and returns a stub agent. */
  98. function stubFactory() {
  99. const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
  100. const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
  101. async createAgent(options) {
  102. calls.create.push(options)
  103. return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
  104. },
  105. resume(options) {
  106. calls.resume.push(options)
  107. return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
  108. },
  109. }
  110. return { factory, calls }
  111. }
  112. it('create()/resume() throw when no factory is registered', async () => {
  113. const ctx = new Context()
  114. await ctx.plugin(AgentRegistry)
  115. await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
  116. await expect(ctx.agents.resume({ agentId: AgentId('a'), resumeSessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
  117. })
  118. it('setFactory registers a factory; create/resume delegate to it', async () => {
  119. const ctx = new Context()
  120. await ctx.plugin(AgentRegistry)
  121. const { factory, calls } = stubFactory()
  122. ctx.agents.setFactory(factory)
  123. const created = await ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
  124. expect(created.agent.id).toBe('c1')
  125. expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }])
  126. const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') })
  127. expect(resumed.agent.id).toBe('r1')
  128. expect(calls.resume).toEqual([{ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }])
  129. })
  130. it('setFactory rejects a second factory', async () => {
  131. const ctx = new Context()
  132. await ctx.plugin(AgentRegistry)
  133. ctx.agents.setFactory(stubFactory().factory)
  134. expect(() => ctx.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
  135. })
  136. it('disposing the setFactory fiber clears the factory (HMR safety)', async () => {
  137. const ctx = new Context()
  138. await ctx.plugin(AgentRegistry)
  139. let dispose!: () => Promise<void> | void
  140. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  141. dispose = inner.agents.setFactory(stubFactory().factory)
  142. }, { inject: ['agents'] }))
  143. await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).resolves.toBeDefined()
  144. void dispose
  145. await fiber.dispose()
  146. // factory slot cleared → create throws again
  147. await expect(ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).rejects.toThrow(/no agent factory/)
  148. })
  149. })