Files
deepseek-harness/packages/llm/llm-retry/tests/transport-recovery.spec.ts
_Kerman 57d90c343d Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine
# Conflicts:
#	.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md
#	.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md
#	.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-06-sandbox.md
#	.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
#	.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml
#	docs/architecture.i18n.yaml
#	docs/cookbook/adding-a-tool.i18n.yaml
#	docs/cookbook/extension-cookbook.i18n.yaml
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/session.i18n.yaml
#	docs/core-data-structures/tools.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
#	examples/acp-agent/tests/snapshots/plan-mode-reject/session.jsonl
#	examples/acp-agent/tests/snapshots/plan-mode/session.jsonl
#	examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl
#	packages/context/session-reference/README.md
#	packages/core/agent-loop/tests/agent.spec.ts
#	packages/hooks/hooks-claude/tests/coverage-cases.ts
#	packages/host/runtime/tests/host-runtime.spec.ts
#	packages/llm/llm-retry/tests/retry.spec.ts
#	packages/session-persistence/session-persistence/src/coordinator.ts
#	packages/support/acp-snapshot/README.md
#	packages/support/acp-snapshot/src/normalize.ts
#	packages/ui/acp/acp-feature-support.md
#	packages/ui/acp/src/codec.ts
#	packages/ui/acp/src/index.ts
#	packages/ui/acp/tests/bridge.spec.ts
#	packages/ui/acp/tests/codec.spec.ts
#	packages/ui/acp/tests/config-options.spec.ts
#	packages/ui/acp/tests/dispose.spec.ts
#	packages/ui/acp/tests/edges.spec.ts
#	packages/ui/acp/tests/stream-update.spec.ts
#	packages/ui/acp/tests/turns.spec.ts
2026-07-26 14:05:33 +08:00

238 lines
8.9 KiB
TypeScript

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,
})
await ctx.plugin(Retry, {
maxTransientRetries: 2,
initialDelayMs: options.initialDelayMs ?? 10,
maxDelayMs: options.initialDelayMs ?? 10,
jitterRatio: 0,
})
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({ 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',
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',
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',
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',
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',
})
context = await harness(server.baseURL, { streamIdleTimeoutMs: 30 })
const agent = context.agentLoop.create(SessionId('wire-stall'), {
provider: 'deepseek',
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')
})
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',
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' } } },
})
})
})