import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmService, { createUserMessage, LlmError } from '@deepseek-ai/dsh-llm' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx } function fail(message: string, code: string): () => never { return () => { throw new LlmError(message, code) } } describe('agent/request-error', () => { it('does not offer middleware failures to request recovery', async () => { const adapter = new MockAdapter([textResponse('unused')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('request-error-narrow'), { provider: 'mock', model: 'mock' }) let recoveries = 0 ctx.on('agent/request', () => { throw new LlmError('middleware failed', 'MIDDLEWARE') }) ctx.on('agent/request-error', async () => { recoveries += 1 }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await agent.whenIdle() expect(recoveries).toBe(0) expect(adapter.requests).toHaveLength(0) }) it('lets each failed request return a retry action before its turn closes', async () => { const adapter = new MockAdapter([ fail('busy', 'RATE_LIMIT'), fail('unavailable', 'SERVICE_UNAVAILABLE'), textResponse('ok'), ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('request-error-retry'), { provider: 'mock', model: 'mock' }) const seen: { turn: number step: number failure: LlmFailure priorFailures: readonly LlmFailure[] retryPolicy: ResolvedRetryPolicy | undefined }[] = [] const statuses: string[] = [] const settledTurns: number[] = [] ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) ctx.on('agent/settled', (subject, turn) => { if (subject === agent) settledTurns.push(turn) }) ctx.on('agent/request-error', async ( subject, turn, step, _error, failure, priorFailures, retryPolicy, ) => { expect(subject).toBe(agent) expect(agent.session.events.at(-1)).toMatchObject({ type: 'step/end', data: { turn, step }, }) seen.push({ turn, step, failure, priorFailures, retryPolicy }) return { kind: 'retry' } }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await agent.whenIdle() expect(seen.map(item => ({ turn: item.turn, step: item.step, code: item.failure.code, }))).toEqual([ { turn: 1, step: 1, code: 'RATE_LIMIT', }, { turn: 2, step: 1, code: 'SERVICE_UNAVAILABLE', }, ]) expect(agent.session.events.filter(event => event.type === 'turn/start').map(event => event.data.trigger)) .toEqual([ { kind: 'message', source: { kind: 'user' } }, { kind: 'retry' }, { kind: 'retry' }, ]) expect(seen.map(item => item.priorFailures.map(failure => failure.code))) .toEqual([[], ['RATE_LIMIT']]) expect(seen.map(item => item.retryPolicy)).toEqual([ expect.objectContaining({ mode: 'normal' }), expect.objectContaining({ mode: 'normal' }), ]) expect(statuses).toEqual(['running', 'idle']) expect(settledTurns).toEqual([3]) }) it('lets cancellation win over a retry action', async () => { const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request-error', async (subject) => { subject.cancel({ kind: 'user' }) return { kind: 'retry' } }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await agent.whenIdle() expect(adapter.requests).toHaveLength(1) expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, }) }) it('does not retry when the recovery listener fails before returning its action', async () => { const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('request-error-recovery-failed'), { provider: 'mock', model: 'mock', }) ctx.on('agent/request-error', async () => { throw new Error('recovery failed') }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await agent.whenIdle() expect(adapter.requests).toHaveLength(1) expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } }, }) }) })