agent.spec.ts 5.5 KB

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