request-error.spec.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import AgentRegistry from '@deepseek-ai/dsh-agent'
  4. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  5. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  6. import LlmRuntime, { createUserMessage, LlmError } from '@deepseek-ai/dsh-llm'
  7. import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
  8. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  9. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  10. import ToolRuntime from '@deepseek-ai/dsh-tools'
  11. import { MockAdapter, textResponse } from './mock-adapter.ts'
  12. async function harness(adapter: MockAdapter): Promise<Context> {
  13. const ctx = new Context()
  14. await ctx.plugin(LlmRuntime)
  15. await ctx.plugin(SessionStore)
  16. await ctx.plugin(SessionProjectionRegistry)
  17. await ctx.plugin(SystemPrompt)
  18. await ctx.plugin(ToolRuntime)
  19. await ctx.plugin(AgentRegistry)
  20. await ctx.plugin(AgentLoop, { agents: [] })
  21. ctx.llm.registerAdapter(['mock'], adapter)
  22. return ctx
  23. }
  24. function fail(message: string, code: string): () => never {
  25. return () => {
  26. throw new LlmError(message, code)
  27. }
  28. }
  29. describe('agent/request-error', () => {
  30. it('does not offer middleware failures to request recovery', async () => {
  31. const adapter = new MockAdapter([textResponse('unused')])
  32. const ctx = await harness(adapter)
  33. const agent = await ctx.agentLoop.create(SessionId('request-error-narrow'), { provider: 'mock', model: 'mock' })
  34. let recoveries = 0
  35. ctx.on('agent/request', () => {
  36. throw new LlmError('middleware failed', 'MIDDLEWARE')
  37. })
  38. ctx.on('agent/request-error', async () => {
  39. recoveries += 1
  40. })
  41. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  42. await agent.whenIdle()
  43. expect(recoveries).toBe(0)
  44. expect(adapter.requests).toHaveLength(0)
  45. })
  46. it('lets each failed request return a retry action before its turn closes', async () => {
  47. const adapter = new MockAdapter([
  48. fail('busy', 'RATE_LIMIT'),
  49. fail('unavailable', 'SERVICE_UNAVAILABLE'),
  50. textResponse('ok'),
  51. ])
  52. const ctx = await harness(adapter)
  53. const agent = await ctx.agentLoop.create(SessionId('request-error-retry'), { provider: 'mock', model: 'mock' })
  54. const seen: {
  55. turn: number
  56. step: number
  57. failure: LlmFailure
  58. retryPolicy: ResolvedRetryPolicy | undefined
  59. }[] = []
  60. const statuses: string[] = []
  61. ctx.on('agent/status', ({ agent: subject, status }) => {
  62. if (subject === agent) statuses.push(status)
  63. })
  64. ctx.on('agent/request-error', async ({ agent: subject, turn, step, failure, retryPolicy }) => {
  65. expect(subject).toBe(agent)
  66. seen.push({ turn, step, failure, retryPolicy })
  67. return { kind: 'retry' }
  68. })
  69. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  70. await agent.whenIdle()
  71. expect(seen.map(item => ({
  72. turn: item.turn,
  73. step: item.step,
  74. code: item.failure.code,
  75. }))).toEqual([
  76. {
  77. turn: 1,
  78. step: 1,
  79. code: 'RATE_LIMIT',
  80. },
  81. {
  82. turn: 1,
  83. step: 1,
  84. code: 'SERVICE_UNAVAILABLE',
  85. },
  86. ])
  87. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1)
  88. expect(seen.map(item => item.retryPolicy)).toEqual([
  89. expect.objectContaining({ mode: 'normal' }),
  90. expect.objectContaining({ mode: 'normal' }),
  91. ])
  92. expect(statuses).toEqual(['running', 'idle'])
  93. expect(agent.session.snapshotEvents().flatMap(event =>
  94. event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
  95. })
  96. it('lets cancellation win over a retry action', async () => {
  97. const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')])
  98. const ctx = await harness(adapter)
  99. const agent = await ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' })
  100. ctx.on('agent/request-error', async ({ agent: subject }) => {
  101. subject.cancel({ kind: 'user' })
  102. return { kind: 'retry' }
  103. })
  104. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  105. await agent.whenIdle()
  106. expect(adapter.requests).toHaveLength(1)
  107. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1)
  108. expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')).toMatchObject({
  109. type: 'turn/end',
  110. data: { reason: { kind: 'aborted', reason: { kind: 'user' } } },
  111. })
  112. })
  113. it('does not retry when the recovery listener fails before returning its action', async () => {
  114. const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')])
  115. const ctx = await harness(adapter)
  116. const agent = await ctx.agentLoop.create(SessionId('request-error-recovery-failed'), {
  117. provider: 'mock',
  118. model: 'mock',
  119. })
  120. ctx.on('agent/request-error', async () => {
  121. throw new Error('recovery failed')
  122. })
  123. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  124. await agent.whenIdle()
  125. expect(adapter.requests).toHaveLength(1)
  126. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1)
  127. expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')).toMatchObject({
  128. type: 'turn/end',
  129. data: { reason: { kind: 'error' } },
  130. })
  131. })
  132. })