mirror of
https://github.com/open-multi-agent/open-multi-agent.git
synced 2026-10-21 10:40:03 -05:00
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.
167 lines
6.0 KiB
TypeScript
167 lines
6.0 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
TokenBudgetExceededError,
|
|
InvalidMessageError,
|
|
LLMCallTimeoutError,
|
|
StructuredOutputValidationError,
|
|
UnsupportedToolCallError,
|
|
UnsupportedToolResultContentError,
|
|
UnsupportedContentBlockError,
|
|
} from '../src/errors.js'
|
|
|
|
describe('TokenBudgetExceededError', () => {
|
|
it('sets .name to TokenBudgetExceededError', () => {
|
|
const err = new TokenBudgetExceededError('agent-1', 500, 400)
|
|
expect(err.name).toBe('TokenBudgetExceededError')
|
|
})
|
|
|
|
it('sets .code to TOKEN_BUDGET_EXCEEDED', () => {
|
|
const err = new TokenBudgetExceededError('agent-1', 500, 400)
|
|
expect(err.code).toBe('TOKEN_BUDGET_EXCEEDED')
|
|
})
|
|
|
|
it('stores agent, tokensUsed, and budget as readonly properties', () => {
|
|
const err = new TokenBudgetExceededError('worker-a', 1234, 1000)
|
|
expect(err.agent).toBe('worker-a')
|
|
expect(err.tokensUsed).toBe(1234)
|
|
expect(err.budget).toBe(1000)
|
|
})
|
|
|
|
it('formats the message with agent name, tokens used, and budget', () => {
|
|
const err = new TokenBudgetExceededError('analyst', 750, 500)
|
|
expect(err.message).toBe('Agent "analyst" exceeded token budget: 750 tokens used (budget: 500)')
|
|
})
|
|
|
|
it('is an instance of TokenBudgetExceededError', () => {
|
|
const err = new TokenBudgetExceededError('b', 1, 2)
|
|
expect(err).toBeInstanceOf(TokenBudgetExceededError)
|
|
})
|
|
|
|
it('is an instance of Error (extends built-in Error)', () => {
|
|
const err = new TokenBudgetExceededError('b', 1, 2)
|
|
expect(err).toBeInstanceOf(Error)
|
|
})
|
|
})
|
|
|
|
describe('InvalidMessageError', () => {
|
|
it('sets .name to InvalidMessageError', () => {
|
|
const err = new InvalidMessageError('bad content')
|
|
expect(err.name).toBe('InvalidMessageError')
|
|
})
|
|
|
|
it('sets .code to INVALID_MESSAGE', () => {
|
|
const err = new InvalidMessageError('some reason')
|
|
expect(err.code).toBe('INVALID_MESSAGE')
|
|
})
|
|
|
|
it('uses the constructor argument as the message', () => {
|
|
const err = new InvalidMessageError('content must be a ContentBlock[]')
|
|
expect(err.message).toBe('content must be a ContentBlock[]')
|
|
})
|
|
|
|
it('is an instance of InvalidMessageError', () => {
|
|
const err = new InvalidMessageError('test')
|
|
expect(err).toBeInstanceOf(InvalidMessageError)
|
|
})
|
|
|
|
it('is an instance of Error (extends built-in Error)', () => {
|
|
const err = new InvalidMessageError('test')
|
|
expect(err).toBeInstanceOf(Error)
|
|
})
|
|
})
|
|
|
|
describe('StructuredOutputValidationError', () => {
|
|
it('exposes a stable code, message, and validation cause', () => {
|
|
const cause = new Error('schema mismatch')
|
|
const err = new StructuredOutputValidationError(cause)
|
|
expect(err.name).toBe('StructuredOutputValidationError')
|
|
expect(err.code).toBe('STRUCTURED_OUTPUT_VALIDATION_FAILED')
|
|
expect(err.message).toBe('Structured output validation failed after retry.')
|
|
expect(err.cause).toBe(cause)
|
|
expect(err).toBeInstanceOf(Error)
|
|
})
|
|
})
|
|
|
|
describe('UnsupportedToolCallError', () => {
|
|
it('identifies the provider and unsupported tool type', () => {
|
|
const err = new UnsupportedToolCallError('openai', 'custom')
|
|
expect(err.name).toBe('UnsupportedToolCallError')
|
|
expect(err.code).toBe('UNSUPPORTED_TOOL_CALL')
|
|
expect(err.provider).toBe('openai')
|
|
expect(err.toolType).toBe('custom')
|
|
expect(err.message).toBe('openai returned unsupported tool-call type "custom"')
|
|
})
|
|
})
|
|
|
|
describe('UnsupportedToolResultContentError', () => {
|
|
it('identifies the provider and unmappable content type', () => {
|
|
const err = new UnsupportedToolResultContentError(
|
|
'Anthropic Messages',
|
|
'file:text/plain',
|
|
'only PDF files are supported',
|
|
)
|
|
expect(err.name).toBe('UnsupportedToolResultContentError')
|
|
expect(err.code).toBe('UNSUPPORTED_TOOL_RESULT_CONTENT')
|
|
expect(err.provider).toBe('Anthropic Messages')
|
|
expect(err.contentType).toBe('file:text/plain')
|
|
expect(err.message).toContain('only PDF files are supported')
|
|
})
|
|
})
|
|
|
|
describe('UnsupportedContentBlockError', () => {
|
|
it('identifies the provider and the block it cannot map', () => {
|
|
const err = new UnsupportedContentBlockError(
|
|
'Anthropic Messages',
|
|
'video',
|
|
'OMA does not map video for this adapter yet',
|
|
)
|
|
expect(err.name).toBe('UnsupportedContentBlockError')
|
|
expect(err.code).toBe('UNSUPPORTED_CONTENT_BLOCK')
|
|
expect(err.provider).toBe('Anthropic Messages')
|
|
expect(err.blockType).toBe('video')
|
|
expect(err.message).toContain('Anthropic Messages')
|
|
expect(err.message).toContain('video')
|
|
expect(err.message).toContain('OMA does not map video for this adapter yet')
|
|
})
|
|
|
|
it('omits the detail clause when none is given', () => {
|
|
const err = new UnsupportedContentBlockError('Google Gemini', 'video')
|
|
expect(err.message).toBe('Google Gemini cannot represent the "video" content block')
|
|
})
|
|
})
|
|
|
|
describe('LLMCallTimeoutError', () => {
|
|
it('sets .name to LLMCallTimeoutError', () => {
|
|
const err = new LLMCallTimeoutError(30_000, 'agent-1')
|
|
expect(err.name).toBe('LLMCallTimeoutError')
|
|
})
|
|
|
|
it('sets .code to LLM_CALL_TIMEOUT', () => {
|
|
const err = new LLMCallTimeoutError(30_000, 'agent-1')
|
|
expect(err.code).toBe('LLM_CALL_TIMEOUT')
|
|
})
|
|
|
|
it('stores timeoutMs and agent as readonly properties', () => {
|
|
const err = new LLMCallTimeoutError(15_000, 'worker-a')
|
|
expect(err.timeoutMs).toBe(15_000)
|
|
expect(err.agent).toBe('worker-a')
|
|
})
|
|
|
|
it('formats the message with agent name and timeout', () => {
|
|
const err = new LLMCallTimeoutError(20_000, 'analyst')
|
|
expect(err.message).toBe('Agent "analyst" LLM call exceeded per-call timeout of 20000ms')
|
|
})
|
|
|
|
it('omits the agent name from the message when unknown', () => {
|
|
const err = new LLMCallTimeoutError(20_000)
|
|
expect(err.agent).toBeUndefined()
|
|
expect(err.message).toBe('LLM call exceeded per-call timeout of 20000ms')
|
|
})
|
|
|
|
it('is an instance of LLMCallTimeoutError and Error', () => {
|
|
const err = new LLMCallTimeoutError(1000, 'b')
|
|
expect(err).toBeInstanceOf(LLMCallTimeoutError)
|
|
expect(err).toBeInstanceOf(Error)
|
|
})
|
|
})
|