kimi-code/packages/kosong/test/errors.test.ts
Kai 71bcfba54a
fix(agent-core): drop vacuous assistant messages that permanently wedge sessions (#1968)
* fix(agent-core): drop vacuous assistant messages that permanently wedge sessions

A provider-filtered response can seal an assistant message holding only an
empty thinking part into the recorded history. The projection's empty-message
guard only counted parts, so it survived, and was serialized as an assistant
message with no content and no tool calls — which the provider rejects with
"the message at position N with role 'assistant' must not be empty" on
every resend, permanently wedging the session.

The projection now drops any message whose parts all serialize to nothing
(empty/whitespace text, or empty unsigned thinking). Content-bearing
messages keep every part verbatim, including empty thinking blocks that
preserved-thinking providers require back. Drops surface through a new
vacuous_message_dropped projection anomaly with log/telemetry counters, and
the structural-error recovery now recognizes this provider rejection so the
strict resend self-heals any residual path. Mirrored in agent-core-v2.

* docs(agent-core-v2): move vacuous-message rationale into the file header

The v2 comment convention keeps comments solely in the top-of-file block.
Move the vacuous-message drop rationale there and drop the inline/JSDoc
comments added beside statements in the projector service, the llmProtocol
error patterns, and the projector tests. No behavior change.

* fix(agent-core-v2): drop output-free steps at fold settle time

The loop-event fold already dropped a settled assistant message when it was
structurally empty (no content, no tool calls), but a step that recorded
only an empty thinking part — e.g. a provider-filtered response carrying an
empty reasoning field — survived into history, relying on the projection to
keep it off the wire. Widen the settle condition to drop the step whenever
nothing sendable was recorded (no tool calls; every content part vacuous),
using a predicate now shared with the context projector. This heals stored
v2 histories at restore time instead of only at request time.

* fix(agent-core): drop duplicate-stripped assistants left with only vacuous content

The strict resend's dedupe pass could re-create the exact empty-assistant
shape the projection now guards against: when every tool call a message
carried was removed as a later duplicate, a remainder holding only an empty
thinking part was kept (its content was non-empty), serialized as an
assistant message with no content and no tool calls, and rejected again
with "the message at position N with role 'assistant' must not be empty"
— the same failure the strict resend was recovering from.

The keep condition now requires sendable content, so such a message is
dropped wholesale with a vacuous_message_dropped anomaly alongside the
duplicate-tool-call one. Mirrored in agent-core-v2.

* fix(agent-core-v2): mirror output-free step drops in the transcript reducer

The live loop-event fold drops a settled assistant step when nothing
sendable was recorded (no tool calls; every content part vacuous), but the
cold transcript reducer still pushed an assistant on every step.begin and
never dropped it. Cold readers (snapshot / messages) therefore kept
showing output-free phantom assistants — including one per failed retry
attempt, which predates the vacuous-step case — and foldedLength overcounted
the live folded history, which can misplace the unflushed-tail splice in
the messages view while a turn is in flight.

The reducer now settles steps the same way the fold does: at a step's end
or at the next begin, an assistant with no tool calls and only vacuous
content is removed and foldedLength is decremented. Steps carrying any
sendable output — real text, real thinking, signed thinking, or tool calls
— are kept verbatim.

* fix(agent-core-v2): update vacuous-content predicate documentation for clarity

* docs(changeset): simplify the vacuous-assistant fix changelog entry
2026-07-21 11:04:55 +08:00

680 lines
27 KiB
TypeScript

import {
APIConnectionError,
APIContextOverflowError,
APIEmptyResponseError,
APIProviderRateLimitError,
APIRequestTooLargeError,
APIStatusError,
APITimeoutError,
ChatProviderError,
isImageFormatError,
isProviderRateLimitError,
isRecoverableRequestStructureError,
isRetryableGenerateError,
isToolExchangeAdjacencyError,
normalizeAPIStatusError,
} from '#/errors';
import { describe, expect, it } from 'vitest';
describe('ChatProviderError', () => {
it('is an instance of Error', () => {
const err = new ChatProviderError('base error');
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(ChatProviderError);
expect(err.message).toBe('base error');
expect(err.name).toBe('ChatProviderError');
});
});
describe('APIConnectionError', () => {
it('extends ChatProviderError', () => {
const err = new APIConnectionError('connection refused');
expect(err).toBeInstanceOf(ChatProviderError);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('APIConnectionError');
expect(err.message).toBe('connection refused');
});
});
describe('APITimeoutError', () => {
it('extends ChatProviderError', () => {
const err = new APITimeoutError('request timed out after 30s');
expect(err).toBeInstanceOf(ChatProviderError);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('APITimeoutError');
expect(err.message).toBe('request timed out after 30s');
});
});
describe('APIStatusError', () => {
it('extends ChatProviderError and stores status code', () => {
const err = new APIStatusError(429, 'rate limited', 'req-abc');
expect(err).toBeInstanceOf(ChatProviderError);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('APIStatusError');
expect(err.message).toBe('rate limited');
expect(err.statusCode).toBe(429);
expect(err.requestId).toBe('req-abc');
});
it('accepts null requestId', () => {
const err = new APIStatusError(500, 'server error', null);
expect(err.statusCode).toBe(500);
expect(err.requestId).toBeNull();
});
it('defaults requestId to null when omitted', () => {
const err = new APIStatusError(502, 'bad gateway');
expect(err.statusCode).toBe(502);
expect(err.requestId).toBeNull();
});
});
describe('APIEmptyResponseError', () => {
it('extends ChatProviderError', () => {
const err = new APIEmptyResponseError('empty response');
expect(err).toBeInstanceOf(ChatProviderError);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('APIEmptyResponseError');
expect(err.message).toBe('empty response');
expect(err.finishReason).toBeNull();
expect(err.rawFinishReason).toBeNull();
});
it('preserves provider finish reason details', () => {
const err = new APIEmptyResponseError('empty response', {
finishReason: 'filtered',
rawFinishReason: 'content_filter',
});
expect(err.finishReason).toBe('filtered');
expect(err.rawFinishReason).toBe('content_filter');
});
});
describe('APIContextOverflowError', () => {
it('extends APIStatusError and preserves HTTP details', () => {
const err = new APIContextOverflowError(400, 'Context length exceeded', 'req-context');
expect(err).toBeInstanceOf(APIStatusError);
expect(err).toBeInstanceOf(ChatProviderError);
expect(err.name).toBe('APIContextOverflowError');
expect(err.statusCode).toBe(400);
expect(err.requestId).toBe('req-context');
});
});
describe('APIProviderRateLimitError', () => {
it('extends APIStatusError and preserves HTTP details', () => {
const err = new APIProviderRateLimitError('Rate limited', 'req-rate');
expect(err).toBeInstanceOf(APIStatusError);
expect(err).toBeInstanceOf(ChatProviderError);
expect(err.name).toBe('APIProviderRateLimitError');
expect(err.statusCode).toBe(429);
expect(err.requestId).toBe('req-rate');
});
});
describe('APIRequestTooLargeError', () => {
it('extends APIStatusError and preserves HTTP details', () => {
const err = new APIRequestTooLargeError(413, 'Request exceeds the maximum size.', 'req-large');
expect(err).toBeInstanceOf(APIStatusError);
expect(err).toBeInstanceOf(ChatProviderError);
expect(err.name).toBe('APIRequestTooLargeError');
expect(err.statusCode).toBe(413);
expect(err.requestId).toBe('req-large');
});
it('is not retryable', () => {
expect(
isRetryableGenerateError(new APIRequestTooLargeError(413, 'Request exceeds the maximum size.')),
).toBe(false);
});
});
describe('isRetryableGenerateError', () => {
it('matches transient provider errors and empty generate responses', () => {
expect(isRetryableGenerateError(new APIConnectionError('conn'))).toBe(true);
expect(isRetryableGenerateError(new APITimeoutError('timeout'))).toBe(true);
expect(isRetryableGenerateError(new APIEmptyResponseError('empty'))).toBe(true);
});
it.each([408, 409, 429, 500, 502, 503, 504, 529])('treats HTTP %i as retryable', (statusCode) => {
expect(isRetryableGenerateError(new APIStatusError(statusCode, 'retryable'))).toBe(true);
});
it.each([400, 401, 403, 404, 422])('treats HTTP %i as non-retryable', (statusCode) => {
expect(isRetryableGenerateError(new APIStatusError(statusCode, 'non-retryable'))).toBe(false);
});
it('propagates retryAfterMs through normalizeAPIStatusError onto the typed error', () => {
const rateLimited = normalizeAPIStatusError(429, 'rate limited', 'req-1', 12_500);
expect(rateLimited).toBeInstanceOf(APIProviderRateLimitError);
expect(rateLimited.retryAfterMs).toBe(12_500);
const generic = normalizeAPIStatusError(503, 'bad gateway', null, 3_000);
expect(generic).toBeInstanceOf(APIStatusError);
expect(generic.retryAfterMs).toBe(3_000);
});
it('defaults retryAfterMs to null when no retry-after header is present', () => {
expect(new APIStatusError(429, 'x').retryAfterMs).toBeNull();
expect(normalizeAPIStatusError(429, 'x').retryAfterMs).toBeNull();
});
it('does not retry context overflow or unknown errors', () => {
expect(
isRetryableGenerateError(new APIContextOverflowError(400, 'Context length exceeded')),
).toBe(false);
expect(isRetryableGenerateError(new Error('boom'))).toBe(false);
expect(isRetryableGenerateError('boom')).toBe(false);
});
it('retries an unclassified base ChatProviderError as a transient fallback', () => {
// An upstream gateway that forwards the original failure only as text (no
// usable HTTP status) surfaces as a base ChatProviderError. It must be
// retried rather than failing the run on the first blip — while typed
// 4xx / context-overflow / request-too-large (all APIStatusError) stay
// non-retryable on their dedicated recovery paths.
expect(isRetryableGenerateError(new ChatProviderError('unclassified upstream failure'))).toBe(
true,
);
});
});
describe('error hierarchy instanceof checks', () => {
it('all error types are instanceof ChatProviderError', () => {
const errors = [
new APIConnectionError('conn'),
new APITimeoutError('timeout'),
new APIStatusError(400, 'status', null),
new APIContextOverflowError(400, 'context length exceeded'),
new APIEmptyResponseError('empty'),
];
for (const err of errors) {
expect(err).toBeInstanceOf(ChatProviderError);
}
});
it('specific types are distinguishable', () => {
const connErr = new APIConnectionError('conn');
const statusErr = new APIStatusError(400, 'status', null);
expect(connErr).not.toBeInstanceOf(APIStatusError);
expect(statusErr).not.toBeInstanceOf(APIConnectionError);
});
it('can catch with ChatProviderError and inspect subtype', () => {
const err: ChatProviderError = new APIStatusError(404, 'not found', 'req-123');
if (err instanceof APIStatusError) {
expect(err.statusCode).toBe(404);
expect(err.requestId).toBe('req-123');
} else {
expect.unreachable('Expected APIStatusError');
}
});
});
describe('normalizeAPIStatusError', () => {
it('normalizes HTTP 429 to APIProviderRateLimitError', () => {
const error = normalizeAPIStatusError(429, 'Too many requests', 'req-rate');
expect(error).toBeInstanceOf(APIProviderRateLimitError);
expect(error.statusCode).toBe(429);
expect(error.requestId).toBe('req-rate');
});
it.each([
[400, 'Context length exceeded'],
[400, 'Exceeded max tokens'],
[413, 'Context length exceeded'],
[422, 'Maximum context window exceeded'],
[400, 'context_length_exceeded'],
[422, 'Too many tokens in prompt'],
[400, 'prompt is too long: 210000 tokens exceeds the maximum'],
[400, 'input token count 131072 exceeds the maximum number of tokens allowed'],
[400, 'Invalid request: Your request exceeded model token limit: 262144 (requested: 274613)'],
])('normalizes %i "%s" to APIContextOverflowError', (statusCode, message) => {
const error = normalizeAPIStatusError(statusCode, message, 'req-context');
expect(error).toBeInstanceOf(APIContextOverflowError);
expect(error.statusCode).toBe(statusCode);
expect(error.requestId).toBe('req-context');
});
it.each([
[401, 'Context length exceeded'],
[500, 'Context length exceeded'],
[400, 'Bad request'],
[422, 'Invalid tool schema'],
[400, 'max_tokens must be less than or equal to 4096'],
[422, 'max_output_tokens must not exceed 8192'],
[400, 'max tokens must not exceed the configured output limit'],
])('keeps %i "%s" as APIStatusError', (statusCode, message) => {
const error = normalizeAPIStatusError(statusCode, message);
expect(error).toBeInstanceOf(APIStatusError);
expect(error).not.toBeInstanceOf(APIContextOverflowError);
});
it.each([
// Moonshot / Kimi 413 observed in the field when accumulated media pushed
// the request body over the provider's byte ceiling.
[413, 'Request exceeds the maximum size'],
// Reverse-proxy (nginx-style) 413 with an HTML body.
[413, '413 <html><head><title>413 Request Entity Too Large</title></head></html>'],
// Anthropic request_too_large: body over the 32 MB API ceiling.
[413, 'request_too_large: Request exceeds the maximum allowed number of bytes'],
// RFC 9110 reason phrase / Node-style wording.
[413, 'Payload Too Large'],
[413, 'Content Too Large'],
// Plain wordings without "entity": generic gateways say "Request too
// large"; Go's http.MaxBytesReader says "http: request body too large".
[413, 'Request too large'],
[413, 'Request body too large'],
[413, 'http: request body too large'],
])('normalizes %i "%s" to APIRequestTooLargeError', (statusCode, message) => {
const error = normalizeAPIStatusError(statusCode, message, 'req-large');
expect(error).toBeInstanceOf(APIRequestTooLargeError);
expect(error.statusCode).toBe(statusCode);
expect(error.requestId).toBe('req-large');
});
it('keeps a 413 with token-overflow wording as APIContextOverflowError', () => {
// Vertex phrases prompt-too-long as a 413; that is a token problem
// (recoverable by compaction), not a request-body-size problem.
const error = normalizeAPIStatusError(413, 'prompt is too long: 210000 tokens > 200000 maximum');
expect(error).toBeInstanceOf(APIContextOverflowError);
expect(error).not.toBeInstanceOf(APIRequestTooLargeError);
});
it.each([
// A bare 413 with unrecognized wording stays unclassified: Vertex abuses
// 413 for prompt-too-long, so the status alone is not proof of a
// body-size rejection.
[413, 'Request failed'],
// Size wording without the 413 status is not classified either.
[400, 'Payload too large'],
[422, 'Request entity too large'],
])('keeps %i "%s" as plain APIStatusError', (statusCode, message) => {
const error = normalizeAPIStatusError(statusCode, message);
expect(error).toBeInstanceOf(APIStatusError);
expect(error).not.toBeInstanceOf(APIRequestTooLargeError);
expect(error).not.toBeInstanceOf(APIContextOverflowError);
});
});
describe('isToolExchangeAdjacencyError', () => {
// The exact Anthropic message observed in the field when a tool_use was not
// immediately followed by its tool_result.
const ANTHROPIC_MISSING_RESULT =
'messages.142: `tool_use` ids were found without `tool_result` blocks immediately after: ' +
'toolu_01MWFhDRqdbB4nzCJNuWYiun. Each `tool_use` block must have a corresponding ' +
'`tool_result` block in the next message.';
it('matches the missing-tool_result 400', () => {
expect(isToolExchangeAdjacencyError(new APIStatusError(400, ANTHROPIC_MISSING_RESULT))).toBe(
true,
);
});
it('matches the reverse unexpected-tool_result 400', () => {
expect(
isToolExchangeAdjacencyError(
new APIStatusError(
400,
'messages.5: `tool_result` block(s) provided when previous message does not ' +
'contain any `tool_use` blocks',
),
),
).toBe(true);
expect(
isToolExchangeAdjacencyError(new APIStatusError(400, 'unexpected `tool_result` block')),
).toBe(true);
});
it('also matches a 422 with the same shape', () => {
expect(isToolExchangeAdjacencyError(new APIStatusError(422, ANTHROPIC_MISSING_RESULT))).toBe(
true,
);
});
// The exact OpenAI-compatible (Moonshot / Kimi) message observed in the field
// when a `tool` message's `tool_call_id` has no matching `tool_calls` entry in
// the preceding assistant message. The doubled space is verbatim from the
// provider.
const MOONSHOT_TOOL_CALL_ID_NOT_FOUND = '400 tool_call_id is not found';
it('matches the OpenAI/Moonshot tool_call_id-not-found 400', () => {
expect(
isToolExchangeAdjacencyError(new APIStatusError(400, MOONSHOT_TOOL_CALL_ID_NOT_FOUND)),
).toBe(true);
expect(
isToolExchangeAdjacencyError(new APIStatusError(400, "tool_call_id 'call_abc123' is not found")),
).toBe(true);
});
it('also matches a 422 tool_call_id-not-found', () => {
expect(
isToolExchangeAdjacencyError(new APIStatusError(422, MOONSHOT_TOOL_CALL_ID_NOT_FOUND)),
).toBe(true);
});
// OpenAI / DeepSeek / vLLM and other OpenAI-compatible providers phrase the
// orphan-`tool`-result case as a `role 'tool'` message that has no preceding
// assistant `tool_calls`. Observed verbatim in the field (see zed #41531,
// llama_index #13715). Quote style varies by provider (straight or backtick).
it('matches the OpenAI/DeepSeek role-tool-without-tool_calls 400', () => {
expect(
isToolExchangeAdjacencyError(
new APIStatusError(
400,
"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'",
),
),
).toBe(true);
expect(
isToolExchangeAdjacencyError(
new APIStatusError(
400,
'Role `tool` must be a response to a preceding message with `tool_calls`',
),
),
).toBe(true);
});
// The mirror-image OpenAI-compatible rejection: an assistant `tool_calls`
// message with no following `tool` results. OpenAI/Portkey (#6621, error
// 10067) spell it out; Qwen/DashScope (#454) uses double quotes; some
// providers emit the terse "(insufficient tool messages following ...)".
it('matches the assistant-tool_calls-without-response 400', () => {
expect(
isToolExchangeAdjacencyError(
new APIStatusError(
400,
"An assistant message with 'tool_calls' must be followed by tool messages responding to each " +
"'tool_call_id'. The following tool_call_ids did not have response messages: call_hSmZB4G8",
),
),
).toBe(true);
expect(
isToolExchangeAdjacencyError(
new APIStatusError(
400,
'An assistant message with "tool_calls" must be followed by tool messages responding to each ' +
'"tool_call_id". The following tool_call_ids did not have response messages: message[322].role',
),
),
).toBe(true);
expect(
isToolExchangeAdjacencyError(
new APIStatusError(400, '(insufficient tool messages following tool_calls message)'),
),
).toBe(true);
});
it('does not match a context-overflow 400 or unrelated errors', () => {
expect(
isToolExchangeAdjacencyError(new APIContextOverflowError(400, 'context length exceeded')),
).toBe(false);
expect(isToolExchangeAdjacencyError(new APIStatusError(400, 'Bad request'))).toBe(false);
// A bare "not found" without a tool_call_id anchor must not match, so an
// unrelated 404-style body cannot trip the tool-exchange recovery.
expect(isToolExchangeAdjacencyError(new APIStatusError(400, 'resource not found'))).toBe(false);
// A model-availability 400 (observed alongside this family in the field) is a
// config error, not a tool-exchange defect — strict resend must not fire.
expect(
isToolExchangeAdjacencyError(
new APIStatusError(400, '400 Not supported model mimo-v2.5-pro-ultraspeed'),
),
).toBe(false);
expect(isToolExchangeAdjacencyError(new APIStatusError(500, ANTHROPIC_MISSING_RESULT))).toBe(
false,
);
expect(isToolExchangeAdjacencyError(new Error(ANTHROPIC_MISSING_RESULT))).toBe(false);
expect(isToolExchangeAdjacencyError('boom')).toBe(false);
});
});
describe('isRecoverableRequestStructureError', () => {
it('matches the whole tool_use/tool_result adjacency family', () => {
expect(
isRecoverableRequestStructureError(
new APIStatusError(400, '`tool_use` ids were found without `tool_result` blocks'),
),
).toBe(true);
});
it('matches the OpenAI/Moonshot tool_call_id-not-found 400', () => {
expect(
isRecoverableRequestStructureError(new APIStatusError(400, '400 tool_call_id is not found')),
).toBe(true);
});
it('matches the OpenAI-compatible role-tool / assistant-tool_calls pairing 400s', () => {
expect(
isRecoverableRequestStructureError(
new APIStatusError(
400,
"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'",
),
),
).toBe(true);
expect(
isRecoverableRequestStructureError(
new APIStatusError(
400,
"An assistant message with 'tool_calls' must be followed by tool messages responding to each " +
"'tool_call_id'. The following tool_call_ids did not have response messages: call_hSmZB4G8",
),
),
).toBe(true);
});
it('matches the Anthropic duplicate tool_use id rejection', () => {
expect(
isRecoverableRequestStructureError(
new APIStatusError(400, 'messages: `tool_use` ids must be unique'),
),
).toBe(true);
});
it('matches empty / whitespace-only text content rejections', () => {
expect(
isRecoverableRequestStructureError(
new APIStatusError(400, 'messages: text content blocks must be non-empty'),
),
).toBe(true);
expect(
isRecoverableRequestStructureError(
new APIStatusError(400, 'text content blocks must contain non-whitespace text'),
),
).toBe(true);
});
it('matches first-message-must-be-user and role-alternation rejections', () => {
expect(
isRecoverableRequestStructureError(
new APIStatusError(400, 'messages: first message must use the "user" role'),
),
).toBe(true);
expect(
isRecoverableRequestStructureError(
new APIStatusError(
400,
'messages: roles must alternate between "user" and "assistant", but found multiple "user" roles in a row',
),
),
).toBe(true);
});
it('matches the Moonshot/Kimi vacuous-message rejection', () => {
expect(
isRecoverableRequestStructureError(
new APIStatusError(
400,
"400 the message at position 105 with role 'assistant' must not be empty",
),
),
).toBe(true);
});
it('does not match context overflow, auth, or non-status errors', () => {
expect(
isRecoverableRequestStructureError(new APIContextOverflowError(400, 'context length exceeded')),
).toBe(false);
expect(isRecoverableRequestStructureError(new APIStatusError(401, 'unauthorized'))).toBe(false);
expect(isRecoverableRequestStructureError(new APIStatusError(400, 'Bad request'))).toBe(false);
expect(isRecoverableRequestStructureError(new Error('roles must alternate'))).toBe(false);
});
});
describe('isProviderRateLimitError', () => {
it('matches explicit HTTP 429 status errors', () => {
expect(isProviderRateLimitError(new APIProviderRateLimitError('rate limited'))).toBe(true);
expect(isProviderRateLimitError(new APIStatusError(429, 'rate limited'))).toBe(true);
expect(isProviderRateLimitError({ response: { status: 429 } })).toBe(true);
expect(isProviderRateLimitError({ statusCode: 503, message: 'rate limit' })).toBe(false);
});
it('matches wrapped provider rate-limit messages without status metadata', () => {
expect(
isProviderRateLimitError(
new Error(
'APIStatusError: 429 request id: req-429, request reached user+model max RPM: 50',
),
),
).toBe(true);
expect(
isProviderRateLimitError(
"[provider.api_error] We're receiving too many requests at the moment. Please wait.",
),
).toBe(true);
expect(isProviderRateLimitError(new Error('[provider.rate_limit] slow down'))).toBe(true);
});
it('does not match non-rate-limit provider errors', () => {
expect(isProviderRateLimitError(new APIStatusError(401, 'unauthorized'))).toBe(false);
expect(isProviderRateLimitError('APIStatusError: 401 unauthorized')).toBe(false);
expect(isProviderRateLimitError(new Error('context length exceeded'))).toBe(false);
});
});
describe('isImageFormatError', () => {
it('matches documented provider image format/data rejections', () => {
// OpenAI
expect(
isImageFormatError(
new APIStatusError(400, 'The image data you provided does not represent a valid image'),
),
).toBe(true);
// Anthropic media_type enum violation
expect(
isImageFormatError(
new APIStatusError(
400,
"messages.0.content.1.image.source.base64.media_type: Input should be 'image/jpeg'",
),
),
).toBe(true);
// Anthropic decode failure
expect(isImageFormatError(new APIStatusError(400, 'Could not process image'))).toBe(true);
// Moonshot/Kimi (from the Kimi Code error reference)
expect(
isImageFormatError(
new APIStatusError(400, 'Invalid request: unsupported image url: /tmp/photo.avif'),
),
).toBe(true);
expect(isImageFormatError(new APIStatusError(400, 'unsupported image format'))).toBe(true);
// Gemini
expect(isImageFormatError(new APIStatusError(400, 'Unable to process input image'))).toBe(true);
expect(
isImageFormatError(
new APIStatusError(400, 'The mime_type must accurately match the actual image format'),
),
).toBe(true);
});
it('matches kosong client-side image whitelist throws', () => {
expect(
isImageFormatError(new ChatProviderError('Unsupported media type for base64 image: image/avif')),
).toBe(true);
expect(
isImageFormatError(
new ChatProviderError('Invalid data URL for image: data:image/avif;BASE64,AAA'),
),
).toBe(true);
});
it('does not match a non-image 400, an unrelated status, or overflow/413 subclasses', () => {
expect(isImageFormatError(new APIStatusError(400, 'max_tokens must be positive'))).toBe(false);
expect(isImageFormatError(new APIStatusError(422, 'image is bad'))).toBe(false);
expect(isImageFormatError(new APIStatusError(401, 'invalid api key'))).toBe(false);
expect(
isImageFormatError(new APIContextOverflowError(400, 'context length exceeded for image model')),
).toBe(false);
expect(
isImageFormatError(new APIRequestTooLargeError(413, 'image request too large')),
).toBe(false);
expect(isImageFormatError(new ChatProviderError('connection reset'))).toBe(false);
expect(isImageFormatError(new Error('image is bad'))).toBe(false);
});
it('does not match image count/size/support errors that stripping media cannot fix', () => {
// Stripping media to zero would let these requests "succeed" with the
// model blind to the user's images — hiding the real error. They must
// surface instead of triggering a media-stripped resend.
expect(isImageFormatError(new APIStatusError(400, 'too many images in request'))).toBe(false);
expect(
isImageFormatError(new APIStatusError(400, 'image dimension 5000 exceeds maximum 2048')),
).toBe(false);
expect(
isImageFormatError(new APIStatusError(400, 'image input is disabled for this model')),
).toBe(false);
expect(isImageFormatError(new APIStatusError(400, 'image_url is not allowed'))).toBe(false);
// Documented provider messages that are image-shaped but not
// format/data errors: Anthropic's per-image size cap, Moonshot's
// capability code, Gemini's unsupported-inlineData rejection.
expect(
isImageFormatError(
new APIStatusError(
400,
'messages.44.content.1.image.source.base64: image exceeds 5 MB maximum: 11641928 bytes > 5242880 bytes',
),
),
).toBe(false);
expect(isImageFormatError(new APIStatusError(400, 'Image Input Not Supported'))).toBe(false);
expect(
isImageFormatError(new APIStatusError(400, "`inlineData` isn't supported by this model.")),
).toBe(false);
// Video/audio media_type errors are NOT image errors: they must surface
// (no conversion-guidance path exists for video) instead of triggering a
// blind media-stripped resend.
expect(
isImageFormatError(
new APIStatusError(
400,
"messages.0.content.1.video.source.base64.media_type: Input should be 'video/mp4'",
),
),
).toBe(false);
// Bare "media type" phrasings for audio/video inputs likewise surface.
expect(
isImageFormatError(new APIStatusError(400, 'unsupported media type for audio input')),
).toBe(false);
expect(isImageFormatError(new APIStatusError(400, 'invalid media type'))).toBe(false);
});
it('is excluded from the transient-retry fallback so dedicated recovery fires first', () => {
// A base ChatProviderError is normally retried as an unclassified
// transient; image-format errors must not be, or the run would burn the
// retry budget on an identical request before reaching the media strip.
expect(isRetryableGenerateError(new ChatProviderError('transient blip'))).toBe(true);
expect(
isRetryableGenerateError(
new ChatProviderError('Unsupported media type for base64 image: image/avif'),
),
).toBe(false);
expect(
isRetryableGenerateError(new APIStatusError(400, 'unsupported image format')),
).toBe(false);
});
});