Files
open-multi-agent/packages/core/tests/error-classification.test.ts
T
Jack Chen 5033842802 fix(core): make unsupported-video terminal and validate video URL sources (#566)
Follow-up to #548, which introduced VideoBlock. Two gaps were missed when that
PR was reviewed.

The four adapters that cannot map video threw a bare Error. isRetryableError
returns true for any Error without a status, so executeWithRetry treated a
deterministic capability gap as transient: it ran the full backoff ladder and
rewrote a checkpoint per attempt before failing the same way, and the failure
was recorded as retryable. UnsupportedContentBlockError makes that terminal,
names the adapter that rejected the block, and carries a code alongside the
existing UnsupportedToolResultContentError.

VideoBlock is the first top-level content block with a URL source, so it
inherited no validation. That source is dereferenced by the provider rather
than by OMA, and a file: path or a data URL pasted into the field was forwarded
verbatim; an unvalidated media_type could also alter the data URL built for
inline bytes. Public entry points now run video sources through the same
copyMediaSource rules already applied to tool-result media, so the documented
HTTP(S) constraint is enforced rather than aspirational. Provider-private
upload handles such as MiniMax's mm_file:// are rejected: supporting them needs
a source variant of its own, not an unchecked string.

Also from the same review:

- estimateTokens charged an inline video the 64-character fixed cost images
  get, hiding a multi-megabyte payload from every context strategy keyed off
  the estimate. It is now sized like rich tool-result media, so the attachment
  that needs compacting is the one that triggers compaction.
- stripImageBlocksForSummary handles video too, so it is now
  stripMediaBlocksForSummary.
- The two double casts in openai-common are narrowed to a single seam, and the
  video_url extension type now records that it is not an OpenAI part.
- docs/structured-input.md states the rule the adapters actually follow:
  OpenAI-shaped adapters forward and let the provider decide, adapters with a
  typed content union reject locally. Bedrock and Gemini support video
  upstream, so the gap there is OMA's mapping rather than theirs.
2026-09-03 12:05:03 +08:00

107 lines
3.8 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import OpenAI from 'openai'
import {
isRetryableError,
InvalidTaskRequirementsError,
TokenBudgetExceededError,
InvalidMessageError,
LLMCallTimeoutError,
StructuredOutputValidationError,
UnsupportedToolCallError,
UnsupportedToolResultContentError,
UnsupportedContentBlockError,
} from '../src/errors.js'
import { classifyRunFailure } from '../src/observability/status.js'
describe('isRetryableError', () => {
it('treats terminal 4xx client errors as non-retryable', () => {
for (const status of [400, 401, 403, 404, 422]) {
expect(isRetryableError({ status })).toBe(false)
}
})
it('treats 408 / 409 / 429 as retryable', () => {
for (const status of [408, 409, 429]) {
expect(isRetryableError({ status })).toBe(true)
}
})
it('treats 5xx server errors as retryable', () => {
for (const status of [500, 502, 503, 504]) {
expect(isRetryableError({ status })).toBe(true)
}
})
it('treats network / no-status errors as retryable', () => {
expect(isRetryableError(new Error('ECONNRESET'))).toBe(true)
expect(isRetryableError(new Error('socket hang up'))).toBe(true)
expect(isRetryableError('some string')).toBe(true)
expect(isRetryableError(undefined)).toBe(true)
})
it('reads status from `.statusCode` as well as `.status`', () => {
expect(isRetryableError({ statusCode: 401 })).toBe(false)
expect(isRetryableError({ statusCode: 503 })).toBe(true)
})
it('classifies budget, invalid-message, and task-requirement errors as terminal', () => {
expect(isRetryableError(new TokenBudgetExceededError('a', 100, 50))).toBe(false)
expect(isRetryableError(new InvalidMessageError('bad'))).toBe(false)
expect(isRetryableError(new StructuredOutputValidationError())).toBe(false)
expect(isRetryableError(new UnsupportedToolCallError('openai', 'custom'))).toBe(false)
expect(isRetryableError(
new UnsupportedToolResultContentError('openai', 'file-url'),
)).toBe(false)
expect(isRetryableError(new InvalidTaskRequirementsError([{
code: 'NO_ELIGIBLE_AGENT',
taskId: 'task-1',
taskTitle: 'Restricted',
reasons: ['worker excluded'],
}]))).toBe(false)
})
it('classifies an unsupported content block as terminal', () => {
// A block the adapter has no mapping for cannot become mappable on a
// second attempt, so retrying only spends the backoff ladder and a
// checkpoint rewrite per attempt before failing the same way.
const error = new UnsupportedContentBlockError('Google Gemini', 'video')
expect(isRetryableError(error)).toBe(false)
expect(error.code).toBe('UNSUPPORTED_CONTENT_BLOCK')
expect(error.message).toContain('Google Gemini')
expect(error.message).toContain('video')
expect(classifyRunFailure(error).errorInfo.retryable).toBe(false)
})
it('classifies a per-call timeout as retryable', () => {
expect(isRetryableError(new LLMCallTimeoutError(1000, 'agent'))).toBe(true)
})
it('classifies an AbortError as terminal', () => {
const err = new Error('aborted')
err.name = 'AbortError'
expect(isRetryableError(err)).toBe(false)
})
it('classifies the OpenAI SDK APIUserAbortError as terminal', () => {
const err = new OpenAI.APIUserAbortError()
expect(isRetryableError(err)).toBe(false)
})
it('classifies the real OpenAI SDK abort as cancellation', () => {
const err = new OpenAI.APIUserAbortError()
expect(classifyRunFailure(err, { provider: 'openai' })).toMatchObject({
status: { code: 'cancelled' },
errorInfo: {
kind: 'cancellation',
retryable: false,
provider: 'openai',
},
})
})
it('ignores a non-numeric status (defaults to retryable)', () => {
expect(isRetryableError({ status: 'nope' })).toBe(true)
expect(isRetryableError({ status: NaN })).toBe(true)
})
})