fix pre-step lifecycle regressions

This commit is contained in:
_Kerman
2026-07-31 19:40:59 +08:00
parent fcc2b5e282
commit 8e88b17c9f
37 changed files with 331 additions and 265 deletions

View File

@@ -2,8 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage,
CONTEXT_WINDOW_EXCEEDED_CODE,
errorChain,
LlmError,
ProviderRequestId,
QUOTA_EXCEEDED_CODE,
ReasoningEffortId,
@@ -206,18 +204,22 @@ describe('DeepSeekAdapter against a mock server', () => {
})
})
it('rejects a per-request effort before I/O when thinking is disabled', async () => {
it('reports a per-request effort failure before I/O when thinking is disabled', async () => {
const server = await mockServer([])
const ctx = await harness(server.url, { thinking: 'disabled' })
await expect(assemble(ctx, {
const result = await assemble(ctx, {
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('high'),
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
})
expect(result.finish).toMatchObject({
kind: 'error',
failure: { code: 'UNSUPPORTED_REASONING_EFFORT' },
})
expect(server.requests).toHaveLength(0)
})
@@ -250,23 +252,22 @@ describe('DeepSeekAdapter against a mock server', () => {
[400, 'INVALID_REQUEST'],
[500, 'SERVER'],
[503, 'SERVER'],
])('maps HTTP %d to LlmError code %s with the body message', async (status, code) => {
])('maps HTTP %d to failure code %s with the body message', async (status, code) => {
const behavior: Behavior = {
kind: 'http-error',
status,
body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }),
}
const server = await mockServer([behavior, behavior])
const server = await mockServer([behavior])
const ctx = await harness(server.url)
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(`failed with ${status}`)
await expect(
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).code),
).resolves.toBe(code)
const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toEqual({
kind: 'error',
failure: { message: `failed with ${status}`, code, status },
})
})
it('classifies a thrown HTTP context-window rejection with the canonical code', async () => {
it('classifies an HTTP context-window failure with the canonical code', async () => {
const server = await mockServer([{
kind: 'http-error',
status: 400,
@@ -279,9 +280,11 @@ describe('DeepSeekAdapter against a mock server', () => {
}),
}])
const ctx = await harness(server.url)
const code = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).code)
expect(code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toMatchObject({
kind: 'error',
failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE },
})
})
it('retains status, Retry-After seconds, and provider request id as structured facts', async () => {
@@ -292,19 +295,16 @@ describe('DeepSeekAdapter against a mock server', () => {
headers: { 'retry-after': '2', 'x-request-id': 'req-429' },
}])
const ctx = await harness(server.url)
let thrown: unknown
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
} catch (error: unknown) {
thrown = error
}
expect(thrown).toBeInstanceOf(LlmError)
expect((thrown as LlmError).failure).toEqual({
message: 'slow down',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-429'),
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toEqual({
kind: 'error',
failure: {
message: 'slow down',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-429'),
},
})
})
@@ -322,16 +322,17 @@ describe('DeepSeekAdapter against a mock server', () => {
},
}])
const ctx = await harness(server.url)
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({
failure: {
message: 'come back later',
code: 'SERVER',
status: 503,
providerRetryAfterMs: 3_000,
requestId: ProviderRequestId('deepseek-503'),
},
})
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toEqual({
kind: 'error',
failure: {
message: 'come back later',
code: 'SERVER',
status: 503,
providerRetryAfterMs: 3_000,
requestId: ProviderRequestId('deepseek-503'),
},
})
} finally {
dateNow.mockRestore()
}
@@ -352,13 +353,11 @@ describe('DeepSeekAdapter against a mock server', () => {
headers: { 'retry-after': value },
}])
const ctx = await harness(server.url)
let thrown: LlmError | undefined
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
} catch (error: unknown) {
if (error instanceof LlmError) thrown = error
}
expect(thrown?.failure).toEqual({ message: 'retry later', code: 'RATE_LIMIT', status: 429 })
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toEqual({
kind: 'error',
failure: { message: 'retry later', code: 'RATE_LIMIT', status: 429 },
})
}
})
@@ -379,53 +378,50 @@ describe('DeepSeekAdapter against a mock server', () => {
it('keeps the status-line message for JSON error bodies without a message', async () => {
const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }])
const ctx = await harness(server.url)
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/HTTP 500/)
const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(result.finish.kind).toBe('error')
if (result.finish.kind !== 'error') throw new Error('expected an error finish')
expect(result.finish.failure.code).toBe('SERVER')
expect(result.finish.failure.message).toMatch(/HTTP 500/)
})
it('keeps the status-line message for non-JSON error bodies', async () => {
const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }])
const ctx = await harness(server.url)
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/HTTP 502/)
const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(result.finish.kind).toBe('error')
if (result.finish.kind !== 'error') throw new Error('expected an error finish')
expect(result.finish.failure.code).toBe('SERVER')
expect(result.finish.failure.message).toMatch(/HTTP 502/)
})
it('maps unusual statuses to HTTP_<status>', () => {
expect(httpErrorCode(418)).toBe('HTTP_418')
})
it('wraps a transport failure in TRANSPORT with the fetch cause chain in the message', async () => {
// Port 1 is reserved/unbound: fetch rejects with `TypeError: fetch failed`
// whose actionable detail (ECONNREFUSED) lives on `cause`.
it('reports a transport failure with the endpoint in the message', async () => {
// Port 1 is reserved/unbound, so the service normalizes the fetch failure.
const ctx = await harness('http://127.0.0.1:1')
let caught: unknown
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
} catch (error: unknown) {
caught = error
}
expect(caught).toBeInstanceOf(LlmError)
const llmError = caught as LlmError
expect(llmError.code).toBe('TRANSPORT')
expect(llmError.message).toContain('http://127.0.0.1:1')
expect(llmError.cause).toBeInstanceOf(TypeError)
// The chain renderer reaches the transport diagnosis through the cause.
expect(errorChain(llmError)).toMatch(/ECONNREFUSED|EADDRNOTAVAIL|bad port/)
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toMatchObject({
kind: 'error',
failure: {
code: 'TRANSPORT',
message: 'DeepSeek API request to http://127.0.0.1:1 failed',
},
})
})
it('classifies an aborted request without losing the transport rejection', async () => {
it('classifies an aborted request as an aborted finish', async () => {
const controller = new AbortController()
controller.abort()
const ctx = await harness('http://127.0.0.1:1')
let caught: unknown
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], signal: controller.signal })
} catch (error: unknown) {
caught = error
}
expect(caught).toBeInstanceOf(LlmError)
expect(caught).toMatchObject({ code: 'ABORTED' })
expect((caught as LlmError).cause).toMatchObject({ name: 'AbortError' })
const result = await assemble(ctx, {
model: 'deepseek-v4-flash',
messages: [],
signal: controller.signal,
})
expect(result.finish).toMatchObject({ kind: 'aborted', failure: { code: 'ABORTED' } })
})
it('throws EMPTY_RESPONSE when the response has no body', async () => {
@@ -443,20 +439,17 @@ describe('DeepSeekAdapter against a mock server', () => {
}
})
it('classifies an abrupt body close as TRANSPORT and retains its cause', async () => {
it('classifies an abrupt body close as TRANSPORT', async () => {
const server = await mockServer([{
kind: 'close-early',
events: ['{"choices":[{"delta":{"content":"par"}}]}'],
}])
const ctx = await harness(server.url)
let caught: unknown
try {
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
} catch (error: unknown) {
caught = error
}
expect(caught).toMatchObject({ code: 'TRANSPORT' })
expect(errorChain(caught)).toMatch(/terminated|socket|without \[DONE\]/)
const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(result.finish.kind).toBe('error')
if (result.finish.kind !== 'error') throw new Error('expected an error finish')
expect(result.finish.failure.code).toBe('TRANSPORT')
expect(result.finish.failure.message).toMatch(/^DeepSeek API stream from .* failed$/)
})
it('aborts mid-stream via the request signal', async () => {
@@ -478,7 +471,13 @@ describe('DeepSeekAdapter against a mock server', () => {
})()
setTimeout(() => { controller.abort() }, 30)
await expect(pending).rejects.toMatchObject({ code: 'ABORTED' })
const chunks = await pending
expect(chunks).toHaveLength(1)
expect(chunks[0]?.type).toBe('finish')
if (chunks[0]?.type !== 'finish') throw new Error('expected a finish chunk')
expect(chunks[0].reason.kind).toBe('aborted')
if (chunks[0].reason.kind !== 'aborted') throw new Error('expected an aborted finish')
expect(chunks[0].reason.failure.code).toBe('ABORTED')
})
it('maps connection failures to TRANSPORT without losing the cause', async () => {
@@ -858,12 +857,15 @@ describe('plugin registration and config', () => {
// only the request itself needs a key.
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
// The guidance leads with the credential store — the path that keeps the
// secret out of configuration files — and mentions a literal key last.
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s)
const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(second.finish.kind).toBe('error')
if (second.finish.kind !== 'error') throw new Error('expected an error finish')
expect(second.finish.failure.message)
.toMatch(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s)
})
it('reads the ambient variable when no credentials seam is mounted', async () => {
@@ -883,8 +885,8 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
})
it('prefers explicit config over env for key and base URL', async () => {

View File

@@ -96,7 +96,8 @@ describe('request-level dynamic configuration', () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: server.url })
await expect(prompt(ctx)).rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
const keyless = await prompt(ctx)
expect(keyless.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
await ctx.credentials.set(KEY_REF, 'sk-arrived')
await prompt(ctx)
expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived')

View File

@@ -85,7 +85,7 @@ describe('PiAiAdapter provider routing', () => {
})
})
it('uses a dynamic request effort and rejects unsupported efforts before network I/O', async () => {
it('uses a dynamic request effort and reports unsupported efforts before network I/O', async () => {
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const ctx = await harness(server.url, { reasoning: 'max' })
@@ -104,11 +104,15 @@ describe('PiAiAdapter provider routing', () => {
expect(server.requests[1]).toMatchObject({ thinking: { type: 'disabled' } })
expect(server.requests[1]).not.toHaveProperty('reasoning_effort')
await expect(assemble(ctx, {
const unsupported = await assemble(ctx, {
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('xhigh'),
messages: [],
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
})
expect(unsupported.finish).toMatchObject({
kind: 'error',
failure: { code: 'UNSUPPORTED_REASONING_EFFORT' },
})
expect(server.requests).toHaveLength(2)
})
@@ -125,19 +129,19 @@ describe('PiAiAdapter provider routing', () => {
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
})
it('rejects stop sequences rather than silently ignoring them', async () => {
it('reports unsupported stop sequences rather than silently ignoring them', async () => {
const server = await mockServer([])
const ctx = await harness(server.url)
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [], stop: ['END'] }))
.rejects.toMatchObject({ code: 'UNSUPPORTED_OPTION' })
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], stop: ['END'] })
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNSUPPORTED_OPTION' } })
expect(server.requests).toEqual([])
})
it('rejects unknown catalog models before network I/O', async () => {
it('reports unknown catalog models before network I/O', async () => {
const server = await mockServer([])
const ctx = await harness(server.url)
await expect(assemble(ctx, { model: 'not-in-the-catalog', messages: [] }))
.rejects.toMatchObject({ code: 'UNKNOWN_MODEL' })
const result = await assemble(ctx, { model: 'not-in-the-catalog', messages: [] })
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNKNOWN_MODEL' } })
expect(server.requests).toEqual([])
})
@@ -237,8 +241,8 @@ describe('PiAiAdapter provider routing', () => {
const server = await mockServer([{ events: textEvents, delayMs: 200 }])
const ctx = await harness(server.url, { streamIdleTimeoutMs: 20 })
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'TIMEOUT' })
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'TIMEOUT' } })
await Promise.race([
server.responseClosed,
new Promise<never>((_resolve, reject) => {
@@ -393,10 +397,12 @@ describe('provider profile lifecycle', () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' })
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/provider route "deepseek".*PI_CUSTOM_REF_KEY/s)
const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(second.finish.kind).toBe('error')
if (second.finish.kind !== 'error') throw new Error('expected an error finish')
expect(second.finish.failure.message).toMatch(/provider route "deepseek".*PI_CUSTOM_REF_KEY/s)
expect(server.requests).toHaveLength(0)
})

View File

@@ -105,8 +105,8 @@ describe('request-level dynamic profiles', () => {
// composition route stays.
await ctx.settings.replace(NS, {})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
await expect(assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'NO_ADAPTER' })
const removed = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
expect(removed.finish).toMatchObject({ kind: 'error', failure: { code: 'NO_ADAPTER' } })
})
it('rotates the per-request credential referenced by apiKeyEnv', async () => {