agent.spec.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { AgentId } from '@deepseek-ai/dsh-agent'
  4. import LlmService from '@deepseek-ai/dsh-llm'
  5. import SessionStore from '@deepseek-ai/dsh-session'
  6. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  7. import ToolRegistry from '@deepseek-ai/dsh-tools'
  8. import AgentRegistry from '@deepseek-ai/dsh-agent'
  9. import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
  10. import { MockAdapter, textResponse } from './mock-adapter.ts'
  11. async function harness(adapter: MockAdapter) {
  12. const ctx = new Context()
  13. await ctx.plugin(LlmService)
  14. await ctx.plugin(SessionStore)
  15. await ctx.plugin(SystemPrompt)
  16. await ctx.plugin(ToolRegistry)
  17. await ctx.plugin(AgentRegistry)
  18. await ctx.plugin(AgentLoop, { agents: [] })
  19. ctx.llm.registerAdapter(['mock'], adapter)
  20. return ctx
  21. }
  22. function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
  23. return new Promise((resolve) => {
  24. const dispose = ctx.on('agent/status', (subject, status) => {
  25. if (subject === agent && status === 'idle') {
  26. dispose()
  27. resolve()
  28. }
  29. })
  30. })
  31. }
  32. function send(agent: LoopAgent, text: string) {
  33. agent.send([{ type: 'text', text }])
  34. }
  35. describe('LoopAgent', () => {
  36. it('send() throws after disposal', async () => {
  37. const adapter = new MockAdapter(['hang'])
  38. const ctx = await harness(adapter)
  39. let agent!: LoopAgent
  40. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  41. agent = inner.agentLoop.create('scoped', { model: 'mock' })
  42. }, { inject: ['agentLoop'] }))
  43. send(agent, 'go')
  44. await new Promise(r => setTimeout(r, 30))
  45. await fiber.dispose()
  46. await agent.done
  47. expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
  48. })
  49. it('steer() throws after disposal', async () => {
  50. const adapter = new MockAdapter(['hang'])
  51. const ctx = await harness(adapter)
  52. let agent!: LoopAgent
  53. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  54. agent = inner.agentLoop.create('scoped', { model: 'mock' })
  55. }, { inject: ['agentLoop'] }))
  56. send(agent, 'go')
  57. await new Promise(r => setTimeout(r, 30))
  58. await fiber.dispose()
  59. await agent.done
  60. expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
  61. })
  62. it('inject() throws after disposal', async () => {
  63. const adapter = new MockAdapter(['hang'])
  64. const ctx = await harness(adapter)
  65. let agent!: LoopAgent
  66. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  67. agent = inner.agentLoop.create('scoped', { model: 'mock' })
  68. }, { inject: ['agentLoop'] }))
  69. send(agent, 'go')
  70. await new Promise(r => setTimeout(r, 30))
  71. await fiber.dispose()
  72. await agent.done
  73. expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
  74. })
  75. it('steer() when idle falls through to send() and starts a turn', async () => {
  76. const adapter = new MockAdapter([textResponse('ok')])
  77. const ctx = await harness(adapter)
  78. const agent = ctx.agentLoop.create('a1', { model: 'mock' })
  79. // steer while idle delegates to send
  80. agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
  81. await waitForIdle(ctx, agent)
  82. // The message was recorded as a user-level message (send path)
  83. expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
  84. expect(adapter.requests).toHaveLength(1)
  85. })
  86. it('disposer is idempotent (double-stop)', async () => {
  87. // Create a bare LoopAgent and call start() directly to get the disposer.
  88. // Then call it twice — the second call hits the early-return branch.
  89. const ctx = new Context()
  90. await ctx.plugin(SessionStore)
  91. const session = ctx.sessions.create('test')
  92. const agent = new LoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
  93. // Start the loop to get the disposer; the agent waits for messages
  94. // (idle, never-resolving cancel), so it will stay idle.
  95. const dispose = agent.start()
  96. // First dispose
  97. dispose()
  98. expect(agent.status).toBe('disposed')
  99. // Second dispose — idempotent, no throw
  100. expect(() => { dispose() }).not.toThrow()
  101. expect(agent.status).toBe('disposed')
  102. })
  103. it('setting the same status does not emit agent/status again', async () => {
  104. const adapter = new MockAdapter([textResponse('ok')])
  105. const ctx = await harness(adapter)
  106. const agent = ctx.agentLoop.create('a1', { model: 'mock' })
  107. const statuses: string[] = []
  108. ctx.on('agent/status', (subject, status) => {
  109. if (subject === agent) statuses.push(status)
  110. })
  111. send(agent, 'hi')
  112. await waitForIdle(ctx, agent)
  113. // After the turn, agent is idle. Send again to trigger another attempt
  114. // to go idle — but it's already idle, so no emission.
  115. const idleTransitionCount = statuses.filter(s => s === 'idle').length
  116. expect(idleTransitionCount).toBe(1) // only the final transition from running
  117. })
  118. it('abort() resolves reason to "aborted" when no reason provided', async () => {
  119. const adapter = new MockAdapter(['hang'])
  120. const ctx = await harness(adapter)
  121. const agent = ctx.agentLoop.create('a1', { model: 'mock' })
  122. const reasons: { kind: string; reason?: string }[] = []
  123. ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
  124. send(agent, 'go')
  125. await new Promise(r => setTimeout(r, 30))
  126. agent.abort() // no reason string
  127. await waitForIdle(ctx, agent)
  128. expect(reasons[0]).toMatchObject({ kind: 'aborted', reason: 'aborted' })
  129. })
  130. })