agent.spec.ts 9.3 KB

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