Files
open-multi-agent/packages/core/tests/ai-sdk-adapter.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

378 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { LanguageModel } from 'ai'
import { UnsupportedContentBlockError, isRetryableError } from '../src/errors.js'
const generateTextMock = vi.fn()
const streamTextMock = vi.fn()
vi.mock('ai', async (importOriginal) => {
const mod = await importOriginal<typeof import('ai')>()
return {
...mod,
generateText: (...args: unknown[]) => generateTextMock(...args),
streamText: (...args: unknown[]) => streamTextMock(...args),
}
})
import { AISdkAdapter, llmMessagesToAiSdkModelMessages } from '../src/llm/ai-sdk.js'
describe('llmMessagesToAiSdkModelMessages', () => {
it('maps a simple user text message', () => {
const out = llmMessagesToAiSdkModelMessages([
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
])
expect(out).toEqual([{ role: 'user', content: 'Hello' }])
})
it('splits tool results into tool-role messages before remaining user parts', () => {
const out = llmMessagesToAiSdkModelMessages([
{
role: 'assistant',
content: [
{ type: 'tool_use', id: 'call_1', name: 'lookup', input: { q: 'x' } },
],
},
{
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'call_1', content: '{"ok":true}' },
{ type: 'text', text: 'Continue.' },
],
},
])
expect(out[0]).toMatchObject({ role: 'assistant' })
expect(out[1]).toEqual({
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: 'call_1',
toolName: 'lookup',
output: { type: 'text', value: '{"ok":true}' },
},
],
})
expect(out[2]).toEqual({ role: 'user', content: 'Continue.' })
})
it('marks errored tool results as error-text output', () => {
const out = llmMessagesToAiSdkModelMessages([
{
role: 'assistant',
content: [{ type: 'tool_use', id: 'c2', name: 'fail', input: {} }],
},
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'c2', content: 'boom', is_error: true }],
},
])
const toolMsg = out[1] as { role: string; content: Array<{ output: { type: string; value: string } }> }
expect(toolMsg.role).toBe('tool')
expect(toolMsg.content[0]?.output).toEqual({ type: 'error-text', value: 'boom' })
})
it('maps rich tool results to AI SDK content output without text serialization', () => {
const out = llmMessagesToAiSdkModelMessages([
{
role: 'assistant',
content: [{ type: 'tool_use', id: 'c3', name: 'render', input: {} }],
},
{
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: 'c3',
content: [
{ type: 'text', text: 'Rendered preview' },
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'aW1hZ2U=' },
},
{
type: 'file',
filename: 'report.pdf',
source: {
type: 'url',
media_type: 'application/pdf',
url: 'https://example.com/report.pdf',
},
},
],
}],
},
])
const toolMsg = out[1] as {
role: string
content: Array<{ output: { type: string; value: unknown[] } }>
}
expect(toolMsg.content[0]?.output).toEqual({
type: 'content',
value: [
{ type: 'text', text: 'Rendered preview' },
{ type: 'file-data', data: 'aW1hZ2U=', mediaType: 'image/png' },
{ type: 'text', text: '[File: report.pdf; application/pdf]' },
{ type: 'file-url', url: 'https://example.com/report.pdf' },
],
})
})
it('rejects video blocks instead of silently dropping them', () => {
const convert = () => llmMessagesToAiSdkModelMessages([
{
role: 'user',
content: [
{
type: 'video',
source: { type: 'url', media_type: 'video/mp4', url: 'https://example.com/video.mp4' },
},
],
},
])
expect(convert).toThrow(UnsupportedContentBlockError)
// Typed, not a bare Error: the run is failed permanently rather than
// retried through the backoff ladder on a capability that cannot appear.
try {
convert()
expect.unreachable('conversion should have thrown')
} catch (error) {
expect(error).toBeInstanceOf(UnsupportedContentBlockError)
expect((error as UnsupportedContentBlockError).blockType).toBe('video')
expect((error as UnsupportedContentBlockError).provider).toContain('AI SDK')
expect(isRetryableError(error)).toBe(false)
}
})
it('does not serialize opaque redacted reasoning payloads', () => {
// Security regression guard (upstream 6b63302): the back-compat default-off
// path must never leak the opaque `redactedData` blob into the AI SDK
// request body. We emit ONLY the `[redacted_thinking]` marker.
const out = llmMessagesToAiSdkModelMessages([
{
role: 'assistant',
content: [
{
type: 'reasoning',
text: '',
redactedData: 'opaque-redacted-thinking-payload',
},
],
},
])
const assistant = out[0] as { role: string; content: Array<{ type: string; text: string }> }
expect(assistant.content[0]).toEqual({ type: 'reasoning', text: '[redacted_thinking]' })
expect(JSON.stringify(out)).not.toContain('opaque-redacted-thinking-payload')
})
describe('reasoning text fallback (#223 Phase 2)', () => {
it('default-off: reasoning passes through as AI SDK structured part (back-compat)', () => {
const out = llmMessagesToAiSdkModelMessages([
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'plan', provenance: 'openai' },
{ type: 'text', text: 'reply' },
],
},
])
const assistant = out[0] as { content: Array<{ type: string; text: string }> }
expect(assistant.content).toEqual([
{ type: 'reasoning', text: 'plan' },
{ type: 'text', text: 'reply' },
])
})
it('preserve=true: reasoning becomes a text part wrapped in <thinking>', () => {
const out = llmMessagesToAiSdkModelMessages(
[
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'plan', provenance: 'openai' },
{ type: 'text', text: 'reply' },
],
},
],
{ preserveReasoningAsText: true },
)
const assistant = out[0] as { content: Array<{ type: string; text: string }> }
expect(assistant.content).toEqual([
{ type: 'text', text: '<thinking>plan</thinking>' },
{ type: 'text', text: 'reply' },
])
})
it('preserve=true: redacted reasoning becomes [redacted] placeholder', () => {
const out = llmMessagesToAiSdkModelMessages(
[
{
role: 'assistant',
content: [
{ type: 'reasoning', text: '', redactedData: 'opaque', provenance: 'anthropic' },
{ type: 'text', text: 'reply' },
],
},
],
{ preserveReasoningAsText: true },
)
const assistant = out[0] as { content: Array<{ type: string; text: string }> }
expect(assistant.content).toEqual([
{ type: 'text', text: '<thinking>[redacted]</thinking>' },
{ type: 'text', text: 'reply' },
])
})
})
})
describe('AISdkAdapter', () => {
const dummyModel = { _brand: 'test' } as unknown as LanguageModel
beforeEach(() => {
generateTextMock.mockReset()
streamTextMock.mockReset()
})
it('calls generateText and maps the result to LLMResponse', async () => {
generateTextMock.mockResolvedValue({
text: 'Hello',
reasoningText: undefined,
toolCalls: [],
finishReason: 'stop',
usage: { inputTokens: 2, outputTokens: 3, totalTokens: 5 },
response: { id: 'gen-1', modelId: 'gpt-test' },
})
const adapter = new AISdkAdapter(dummyModel)
const res = await adapter.chat([{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }], {
model: 'gpt-test',
})
expect(generateTextMock).toHaveBeenCalledTimes(1)
const arg = generateTextMock.mock.calls[0]![0] as { model: unknown; messages: unknown[]; system?: string }
expect(arg.model).toBe(dummyModel)
expect(arg.messages).toEqual([{ role: 'user', content: 'Hi' }])
expect(res.id).toBe('gen-1')
expect(res.model).toBe('gpt-test')
expect(res.stop_reason).toBe('end_turn')
expect(res.usage).toEqual({ input_tokens: 2, output_tokens: 3 })
expect(res.content).toEqual([{ type: 'text', text: 'Hello' }])
})
it('maps tool-calls finish reason to tool_use', async () => {
generateTextMock.mockResolvedValue({
text: '',
reasoningText: undefined,
toolCalls: [{ toolCallId: 'tc1', toolName: 'add', input: { a: 1 } }],
finishReason: 'tool-calls',
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
response: { id: 'r2', modelId: 'gpt-test' },
})
const adapter = new AISdkAdapter(dummyModel)
const res = await adapter.chat([{ role: 'user', content: [{ type: 'text', text: 'x' }] }], {
model: 'gpt-test',
})
expect(res.stop_reason).toBe('tool_use')
expect(res.content).toEqual([
{ type: 'tool_use', id: 'tc1', name: 'add', input: { a: 1 } },
])
})
it('lets extraBody override sampling defaults like temperature', async () => {
generateTextMock.mockResolvedValue({
text: 'ok',
reasoningText: undefined,
toolCalls: [],
finishReason: 'stop',
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
response: { id: 'r', modelId: 'm' },
})
const adapter = new AISdkAdapter(dummyModel)
await adapter.chat([{ role: 'user', content: [{ type: 'text', text: 'x' }] }], {
model: 'm',
temperature: 0.5,
extraBody: { temperature: 0.99 },
})
const body = generateTextMock.mock.calls[0]![0] as Record<string, unknown>
expect(body['temperature']).toBe(0.99)
})
it('refuses to let extraBody override structural fields (model, messages, tools)', async () => {
generateTextMock.mockResolvedValue({
text: 'ok',
reasoningText: undefined,
toolCalls: [],
finishReason: 'stop',
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
response: { id: 'r', modelId: 'm' },
})
const adapter = new AISdkAdapter(dummyModel)
const messages = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'real' }] }]
const tools = [
{
name: 'lookup',
description: 'Look up',
inputSchema: { type: 'object' as const, properties: {} },
},
]
await adapter.chat(messages, {
model: 'm',
systemPrompt: 'real system',
tools,
extraBody: {
model: { spoofed: true },
messages: [],
system: 'evil',
tools: {},
} as Record<string, unknown>,
})
const body = generateTextMock.mock.calls[0]![0] as Record<string, unknown>
expect(body['model']).toBe(dummyModel)
expect(body['messages']).toEqual([{ role: 'user', content: 'real' }])
expect(body['system']).toBe('real system')
expect(body['tools']).toBeDefined()
expect(Object.keys(body['tools'] as object)).toEqual(['lookup'])
})
it('streams text deltas and a terminal done event', async () => {
streamTextMock.mockReturnValue({
fullStream: (async function* () {
yield { type: 'text-delta', id: 't1', text: 'Hi' }
yield {
type: 'finish',
finishReason: 'stop',
totalUsage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 },
}
})(),
response: Promise.resolve({ id: 's1', modelId: 'stream-model' }),
totalUsage: Promise.resolve({ inputTokens: 1, outputTokens: 2, totalTokens: 3 }),
})
const adapter = new AISdkAdapter(dummyModel)
const events: Array<{ type: string; data?: unknown }> = []
for await (const ev of adapter.stream([{ role: 'user', content: [{ type: 'text', text: 'Go' }] }], {
model: 'stream-model',
})) {
events.push(ev)
}
expect(events.map(e => e.type)).toEqual(['text', 'done'])
expect(events[0]).toEqual({ type: 'text', data: 'Hi' })
const done = events[1] as { type: 'done'; data: { content: unknown[]; usage: { input_tokens: number; output_tokens: number } } }
expect(done.type).toBe('done')
expect(done.data.usage).toEqual({
input_tokens: 1,
output_tokens: 2,
})
})
})