Files
deepseek-harness/packages/llm/llm-retry/tests/transport-recovery.spec.ts
Yichen Jiang fee12f1af0 refactor(llm-deepseek)!: rename the provider route to deepseek-official
The native adapter's route was named deepseek, colliding with pi-ai's
catalog provider of the same name, so the two DeepSeek paths could never
be mounted side by side. The web settings page needs both configurable at
once. Compositions, fixtures, goldens, scaffolding defaults, and docs all
move together (pre-release, no shim); TUI/session-query-spill/
missing-credential goldens re-recorded through their keyless refresh
modes because provider-name length shifts box padding and spill
truncation points.
2026-07-29 16:36:07 +08:00

245 lines
9.3 KiB
TypeScript

import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { MockLlmBehavior, MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as Retry from '../src/index.ts'
let context: Context | undefined
const servers: MockLlmServer[] = []
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
await Promise.all(servers.splice(0).map(server => server.close()))
})
async function start(
sequence: readonly MockLlmBehavior[],
options: Omit<Parameters<typeof startMockLlmServer>[0], 'sequence'> = {},
): Promise<MockLlmServer> {
const server = await startMockLlmServer({ sequence, ...options })
servers.push(server)
return server
}
async function harness(
baseURL: string,
options: { streamIdleTimeoutMs?: number; initialDelayMs?: number } = {},
): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'mock-key',
baseURL,
streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000,
retryPolicy: {
mode: 'normal',
maxRetries: 2,
backoff: {
initialDelayMs: options.initialDelayMs ?? 10,
maxDelayMs: options.initialDelayMs ?? 10,
jitterRatio: 0,
},
},
})
await ctx.plugin(Retry)
await ctx.plugin(AgentLoop, { agents: [] })
return ctx
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'idle') return
dispose()
resolve()
})
})
}
function sendAndWait(ctx: Context, agent: Agent): Promise<void> {
const idle = waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'recover through the provider boundary' }], source: { kind: 'user' } }))
return idle
}
function finalAssistantText(agent: Agent): string | undefined {
const message = agent.session.deriveMessages().at(-1)
if (message?.role !== 'assistant') return undefined
return message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
async function unusedPort(): Promise<number> {
const server = createServer()
await new Promise<void>((resolve) => { server.listen(0, '127.0.0.1', resolve) })
const port = (server.address() as AddressInfo).port
await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
return port
}
describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
it('recovers from a true refused connection after the endpoint starts during backoff', async () => {
const port = await unusedPort()
context = await harness(`http://127.0.0.1:${port}`, { initialDelayMs: 100 })
const agent = context.agentLoop.create(SessionId('wire-refused'), {
provider: 'deepseek-official',
model: 'mock-model',
})
let recoveryServer: Promise<MockLlmServer> | undefined
context.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'llm/retry' || event.data.retry !== 1) return
recoveryServer = start(['success'], { port, apiKey: 'mock-key', successText: 'connected after retry' })
})
await sendAndWait(context, agent)
const server = await recoveryServer
expect(server).toBeDefined()
expect(server?.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'step/start')
.map(event => [event.data.turn, event.data.step]))
.toEqual([[1, 1], [2, 1]])
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code))
.toEqual(['TRANSPORT'])
expect(finalAssistantText(agent)).toBe('connected after retry')
})
it.each([
['stream_disconnect', 0] as const,
['partial_disconnect', 2] as const,
])('retries %s without committing failed chunks', async (behavior, failedChunkCount) => {
const server = await start([behavior, 'success'], {
apiKey: 'mock-key',
partialText: 'discard me',
chunkSize: 100,
disconnectDelayMs: 20,
successText: 'recovered response',
})
context = await harness(server.baseURL)
const agent = context.agentLoop.create(SessionId(`wire-${behavior}`), {
provider: 'deepseek-official',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests).toHaveLength(2)
expect(server.requests[0]?.body).toEqual(server.requests[1]?.body)
expect(agent.session.events.filter(event =>
event.type === 'assistant/chunk' && event.data.turn === 1,
)).toHaveLength(failedChunkCount)
expect(agent.session.events.filter(event => event.type === 'assistant/message')
.map(event => [event.data.turn, event.data.step]))
.toEqual([[2, 1]])
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code))
.toEqual(['TRANSPORT'])
expect(finalAssistantText(agent)).toBe('recovered response')
})
it('retries a wire-valid content-less completion without committing an empty message', async () => {
const server = await start(['empty', 'success'], {
apiKey: 'mock-key',
successText: 'recovered from empty',
})
context = await harness(server.baseURL)
const agent = context.agentLoop.create(SessionId('wire-empty'), {
provider: 'deepseek-official',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests).toHaveLength(2)
expect(server.requests[0]?.body).toEqual(server.requests[1]?.body)
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code))
.toEqual(['EMPTY_RESPONSE'])
expect(agent.session.events.filter(event => event.type === 'assistant/message')
.map(event => [event.data.turn, event.data.step]))
.toEqual([[2, 1]])
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
})
expect(finalAssistantText(agent)).toBe('recovered from empty')
})
it('exposes a clean partial EOF as non-default-retryable STREAM_CLOSED', async () => {
const server = await start(['partial_eof', 'success'], {
apiKey: 'mock-key',
partialText: 'discarded clean eof',
chunkSize: 100,
})
context = await harness(server.baseURL)
const agent = context.agentLoop.create(SessionId('wire-partial-eof'), {
provider: 'deepseek-official',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests).toHaveLength(1)
expect(agent.session.events.filter(event =>
event.type === 'assistant/chunk' && event.data.turn === 1,
)).toHaveLength(2)
expect(agent.session.events.some(event => event.type === 'assistant/message')).toBe(false)
expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', failure: { code: 'STREAM_CLOSED' } } },
})
})
it('turns a stalled body into TIMEOUT and succeeds on the next request', async () => {
const server = await start(['stall', 'success'], {
apiKey: 'mock-key',
successText: 'recovered after timeout',
})
// This crosses the real HTTP idle timer, so leave scheduler slack between
// the stalled attempt and the mock server's immediate successful response.
context = await harness(server.baseURL, { streamIdleTimeoutMs: 1_000 })
const agent = context.agentLoop.create(SessionId('wire-stall'), {
provider: 'deepseek-official',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests.map(record => record.behavior)).toEqual(['stall', 'success'])
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code))
.toEqual(['TIMEOUT'])
expect(finalAssistantText(agent)).toBe('recovered after timeout')
}, 10_000)
it('stops after the configured transport retry budget is exhausted', async () => {
const server = await start(['connection_reset', 'connection_reset', 'connection_reset'], {
apiKey: 'mock-key',
})
context = await harness(server.baseURL)
const agent = context.agentLoop.create(SessionId('wire-exhausted'), {
provider: 'deepseek-official',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests).toHaveLength(3)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(3)
expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', failure: { code: 'TRANSPORT' } } },
})
})
})