agent.spec.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  4. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  5. import LlmService from '@deepseek-ai/dsh-llm'
  6. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  7. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  8. import ToolRegistry from '@deepseek-ai/dsh-tools'
  9. import { MockAdapter, textResponse } from './mock-adapter.ts'
  10. async function harness(adapter: MockAdapter): Promise<Context> {
  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 send(agent: Agent, text: string): void {
  22. agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
  23. }
  24. describe('Agent', () => {
  25. it('idle inject() appends context without opening a turn or requesting a flush', async () => {
  26. const adapter = new MockAdapter([textResponse('ok')])
  27. const ctx = await harness(adapter)
  28. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  29. let flushes = 0
  30. ctx.on('session/flush', () => { flushes += 1 })
  31. agent.inject({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } })
  32. expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
  33. expect(agent.status).toBe('idle')
  34. expect(adapter.requests).toHaveLength(0)
  35. await agent.whenIdle()
  36. expect(flushes).toBe(0)
  37. })
  38. it('inject() preserves an explicitly empty plugin source', async () => {
  39. const ctx = await harness(new MockAdapter([textResponse('ok')]))
  40. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  41. agent.inject({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } })
  42. const injected = agent.session.events.at(-1)
  43. expect(injected?.type === 'user/message' && injected.data.source)
  44. .toEqual({ kind: 'plugin', plugin: '' })
  45. })
  46. it('idle inject() rejects invalid input before append', async () => {
  47. const ctx = await harness(new MockAdapter([textResponse('ok')]))
  48. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  49. expect(() => {
  50. agent.inject({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } })
  51. }).toThrow(/non-JSON-serializable/)
  52. expect(agent.session.events).toHaveLength(0)
  53. })
  54. it('steer() while idle becomes a woken prompt turn', async () => {
  55. const adapter = new MockAdapter([textResponse('ok')])
  56. const ctx = await harness(adapter)
  57. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  58. agent.steer({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } })
  59. await agent.whenIdle()
  60. expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true)
  61. expect(adapter.requests).toHaveLength(1)
  62. })
  63. it('emits one running and idle transition for one completed turn', async () => {
  64. const ctx = await harness(new MockAdapter([textResponse('ok')]))
  65. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  66. const statuses: string[] = []
  67. ctx.on('agent/status', (subject, status) => {
  68. if (subject === agent) statuses.push(status)
  69. })
  70. send(agent, 'hi')
  71. await agent.whenIdle()
  72. expect(statuses).toEqual(['running', 'idle'])
  73. })
  74. it('awaits the turn-end checkpoint before claiming the next queued turn', async () => {
  75. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  76. const ctx = await harness(adapter)
  77. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  78. const firstFlush = Promise.withResolvers<undefined>()
  79. const flushedTurns: number[] = []
  80. ctx.on('session/flush', async (session) => {
  81. const turnEnd = session.events.findLast(event => event.type === 'turn/end')
  82. flushedTurns.push(turnEnd?.data.turn ?? 0)
  83. if (turnEnd?.data.turn === 1) await firstFlush.promise
  84. })
  85. send(agent, 'first')
  86. send(agent, 'second')
  87. await vi.waitFor(() => { expect(flushedTurns).toEqual([1]) })
  88. expect(adapter.requests).toHaveLength(1)
  89. firstFlush.resolve(undefined)
  90. await agent.whenIdle()
  91. expect(adapter.requests).toHaveLength(2)
  92. expect(flushedTurns).toEqual([1, 2])
  93. })
  94. it('keeps whenIdle pending through the final turn checkpoint', async () => {
  95. const ctx = await harness(new MockAdapter([textResponse('done')]))
  96. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  97. const flush = Promise.withResolvers<undefined>()
  98. let flushStarted = false
  99. ctx.on('session/flush', () => {
  100. flushStarted = true
  101. return flush.promise
  102. })
  103. send(agent, 'go')
  104. await vi.waitFor(() => { expect(flushStarted).toBe(true) })
  105. let idleSettled = false
  106. const idle = agent.whenIdle().then(() => { idleSettled = true })
  107. await Promise.resolve()
  108. expect(idleSettled).toBe(false)
  109. flush.resolve(undefined)
  110. await idle
  111. expect(agent.status).toBe('idle')
  112. })
  113. it('reports a rejected turn-end checkpoint and continues queued work', async () => {
  114. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  115. const ctx = await harness(adapter)
  116. const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  117. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  118. const failure = new Error('disk unavailable')
  119. const errors: { turn: number; step: number; error: unknown }[] = []
  120. let flushes = 0
  121. ctx.on('session/flush', () => {
  122. flushes += 1
  123. if (flushes === 1) throw failure
  124. })
  125. ctx.on('agent/error', (subject, turn, step, error) => {
  126. if (subject === agent) errors.push({ turn, step, error })
  127. })
  128. send(agent, 'first')
  129. send(agent, 'second')
  130. await agent.whenIdle()
  131. expect(adapter.requests).toHaveLength(2)
  132. expect(flushes).toBe(2)
  133. expect(errors).toEqual([{ turn: 1, step: 1, error: failure }])
  134. expect(warning).toHaveBeenCalledWith(expect.stringContaining('session/flush failed at turn 1: disk unavailable'))
  135. warning.mockRestore()
  136. })
  137. it('whenIdle() resolves immediately without active work', async () => {
  138. const ctx = await harness(new MockAdapter([textResponse('ok')]))
  139. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  140. await agent.whenIdle()
  141. expect(agent.status).toBe('idle')
  142. })
  143. it('whenIdle() waits for active work until explicit cancellation', async () => {
  144. const ctx = await harness(new MockAdapter(['hang']))
  145. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  146. send(agent, 'queued')
  147. let settled = false
  148. const idle = agent.whenIdle().then(() => { settled = true })
  149. await Promise.resolve()
  150. expect(settled).toBe(false)
  151. agent.cancel({ kind: 'user' })
  152. await idle
  153. expect(agent.status).toBe('idle')
  154. })
  155. it('contains a throwing status listener on both transitions', async () => {
  156. const ctx = await harness(new MockAdapter([textResponse('ok')]))
  157. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  158. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  159. ctx.on('agent/status', (_subject, status) => {
  160. throw new Error(`bad ${status} listener`)
  161. })
  162. send(agent, 'go')
  163. await agent.whenIdle()
  164. expect(agent.status).toBe('idle')
  165. expect(warn).toHaveBeenCalledWith(
  166. expect.stringContaining('agent event "agent/status" listener threw'),
  167. )
  168. })
  169. })