agent.spec.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  5. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  6. import LlmRuntime 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 ToolRuntime 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(LlmRuntime)
  14. await ctx.plugin(SessionStore)
  15. await ctx.plugin(SystemPrompt)
  16. await ctx.plugin(ToolRuntime)
  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('idle inject() durably stages context without opening a turn', async () => {
  27. const adapter = new MockAdapter([textResponse('ok')])
  28. const ctx = await harness(adapter)
  29. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  30. agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } }))
  31. expect(agent.session.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
  32. expect(agent.status).toBe('idle')
  33. expect(adapter.requests).toHaveLength(0)
  34. await agent.whenIdle()
  35. })
  36. it('inject() preserves an explicitly empty plugin source', async () => {
  37. const ctx = await harness(new MockAdapter([textResponse('ok')]))
  38. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  39. agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } }))
  40. const injected = agent.session.events.at(-1)
  41. expect(injected?.type === 'agent/inbox/spliced' && injected.data.inserted[0]?.source)
  42. .toEqual({ kind: 'plugin', plugin: '' })
  43. })
  44. it('emits exact inserted, claimed, and discarded inbox messages', async () => {
  45. const ctx = await harness(new MockAdapter([textResponse('ok')]))
  46. const agent = ctx.agentLoop.create(SessionId('inbox-events'), { provider: 'mock', model: 'mock' })
  47. const inserted: unknown[] = []
  48. const claimed: unknown[] = []
  49. const discarded: unknown[] = []
  50. const lifecycle: string[] = []
  51. ctx.on('session/event', (session, event) => {
  52. if (session === agent.session && event.type === 'turn/start') lifecycle.push('turn/start')
  53. })
  54. ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => {
  55. if (subject === agent) inserted.push({ message })
  56. })
  57. ctx.on('agent/inbox/claimed', ({ agent: subject, message, turn }) => {
  58. if (subject === agent) {
  59. lifecycle.push('agent/inbox/claimed')
  60. claimed.push({ message, turn })
  61. }
  62. })
  63. ctx.on('agent/inbox/discarded', ({ agent: subject, message }) => {
  64. if (subject === agent) discarded.push({ message })
  65. })
  66. const context = createUserMessage({
  67. content: [{ type: 'text', text: 'discard me' }],
  68. source: { kind: 'plugin', plugin: 'test' },
  69. })
  70. agent.inject(context)
  71. agent.inbox.remove(context.id)
  72. const prompt = createUserMessage({ content: [{ type: 'text', text: 'run' }], source: { kind: 'user' } })
  73. agent.followup(prompt)
  74. await agent.whenIdle()
  75. expect(inserted).toEqual([{ message: context }, { message: prompt }])
  76. expect(discarded).toEqual([{ message: context }])
  77. expect(claimed).toEqual([{ message: prompt, turn: 1 }])
  78. expect(lifecycle).toEqual(['turn/start', 'agent/inbox/claimed'])
  79. })
  80. it('idle inject() rejects invalid input before enqueue', async () => {
  81. const ctx = await harness(new MockAdapter([textResponse('ok')]))
  82. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  83. expect(() => {
  84. agent.inject(createUserMessage({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } }))
  85. }).toThrow(/non-JSON-serializable/)
  86. expect(agent.session.events).toHaveLength(0)
  87. })
  88. it('steer() while idle becomes a woken prompt turn', async () => {
  89. const adapter = new MockAdapter([textResponse('ok')])
  90. const ctx = await harness(adapter)
  91. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  92. agent.steer(createUserMessage({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } }))
  93. await agent.whenIdle()
  94. expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true)
  95. expect(adapter.requests).toHaveLength(1)
  96. })
  97. it('emits one running and idle transition for one completed turn', async () => {
  98. const ctx = await harness(new MockAdapter([textResponse('ok')]))
  99. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  100. const statuses: string[] = []
  101. ctx.on('agent/status', ({ agent: subject, status }) => {
  102. if (subject === agent) statuses.push(status)
  103. })
  104. send(agent, 'hi')
  105. await agent.whenIdle()
  106. expect(statuses).toEqual(['running', 'idle'])
  107. })
  108. it('whenIdle() resolves immediately without active work', async () => {
  109. const ctx = await harness(new MockAdapter([textResponse('ok')]))
  110. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  111. await agent.whenIdle()
  112. expect(agent.status).toBe('idle')
  113. })
  114. it('whenIdle() waits for active work until explicit cancellation', async () => {
  115. const ctx = await harness(new MockAdapter(['hang']))
  116. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  117. send(agent, 'queued')
  118. let settled = false
  119. const idle = agent.whenIdle().then(() => { settled = true })
  120. await Promise.resolve()
  121. expect(settled).toBe(false)
  122. agent.cancel({ kind: 'user' })
  123. await idle
  124. expect(agent.status).toBe('idle')
  125. })
  126. it('contains a throwing status listener on both transitions', async () => {
  127. const ctx = await harness(new MockAdapter([textResponse('ok')]))
  128. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  129. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  130. ctx.on('agent/status', ({ status }) => {
  131. throw new Error(`bad ${status} listener`)
  132. })
  133. send(agent, 'go')
  134. await agent.whenIdle()
  135. expect(agent.status).toBe('idle')
  136. expect(warn).toHaveBeenCalledWith(
  137. expect.stringContaining('agent event "agent/status" listener threw'),
  138. )
  139. })
  140. })