/** * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as assert from 'assert'; import { GenkitError, MessageData, Part } from 'genkit'; import { ToolDefinition } from 'genkit/model'; import { describe, it } from 'node:test'; import { ensureToolIds, fromInteraction, fromInteractionContent, fromInteractionDelta, fromInteractionStep, fromInteractionSync, mergeStreamedAudio, removeId3FromAudio, toInteractionAudioResponseFormat, toInteractionConfigTool, toInteractionContent, toInteractionGenerationConfig, toInteractionGoogleSearch, toInteractionRole, toInteractionSafetySettings, toInteractionSpeechConfig, toInteractionSteps, toInteractionTool, toInteractionVideoResponseFormat, } from '../../src/common/interaction-converters.js'; import { Content, GeminiInteraction, Step, StepDeltaData, } from '../../src/common/interaction-types.js'; describe('Interaction Converters', () => { describe('toInteractionSafetySettings', () => { it('converts to lower-case types and thresholds', () => { // The server rejects upper-case values with a 400 listing the // lower-case ones (seen live on vertexAI, Oct 2026). assert.deepStrictEqual( toInteractionSafetySettings([ { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_LOW_AND_ABOVE', }, { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, ]), [ { type: 'hate_speech', threshold: 'block_low_and_above' }, { type: 'harassment', threshold: 'block_none' }, ] ); }); it('lower-cases method', () => { // The server rejects 'SEVERITY' with a 400 (reported in review). assert.deepStrictEqual( toInteractionSafetySettings([ { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_ONLY_HIGH', method: 'SEVERITY', }, ]), [ { type: 'hate_speech', threshold: 'block_only_high', method: 'severity', }, ] ); }); it('leaves out HARM_CATEGORY_UNSPECIFIED', () => { assert.strictEqual( toInteractionSafetySettings([ { category: 'HARM_CATEGORY_UNSPECIFIED', threshold: 'BLOCK_ONLY_HIGH', }, ]), undefined ); }); it('returns undefined when not set', () => { assert.strictEqual(toInteractionSafetySettings(undefined), undefined); }); }); describe('toInteractionAudioResponseFormat', () => { it('maps mimeType and sampleRate', () => { assert.deepStrictEqual( toInteractionAudioResponseFormat({ mimeType: 'audio/l16', sampleRate: 16000, }), { type: 'audio', mime_type: 'audio/l16', sample_rate: 16000 } ); }); it('sends only the fields that are set', () => { assert.deepStrictEqual( toInteractionAudioResponseFormat({ mimeType: 'audio/wav' }), { type: 'audio', mime_type: 'audio/wav' } ); }); it('returns undefined when audioFormat is not set', () => { assert.strictEqual( toInteractionAudioResponseFormat(undefined), undefined ); }); }); describe('toInteractionVideoResponseFormat', () => { it('maps aspectRatio and resolution', () => { assert.deepStrictEqual( toInteractionVideoResponseFormat({ aspectRatio: '9:16', resolution: '4k', }), { type: 'video', aspect_ratio: '9:16', resolution: '4k' } ); }); it('sends only the fields that are set', () => { assert.deepStrictEqual( toInteractionVideoResponseFormat({ resolution: '360p' }), { type: 'video', resolution: '360p' } ); }); it('maps delivery', () => { assert.deepStrictEqual( toInteractionVideoResponseFormat({ delivery: 'uri' }), { type: 'video', delivery: 'uri' } ); }); it('returns undefined when videoFormat is not set', () => { assert.strictEqual( toInteractionVideoResponseFormat(undefined), undefined ); }); }); describe('toInteractionGenerationConfig (video)', () => { it('keeps videoFormat out of generation_config and sends videoConfig as video_config', () => { const result = toInteractionGenerationConfig({ videoFormat: { resolution: '1080p' }, videoConfig: { task: 'extend' }, }); assert.deepStrictEqual(result, { video_config: { task: 'extend' } }); }); }); describe('computer use safety decisions', () => { // The plugin doesn't run computer use actions; users run the loop // themselves (returnToolRequests: true). The safety decision must reach // them, and their acknowledgement must reach the server. Shapes from the // computer use guide. const safetyDecision = { explanation: 'Must check check-box', decision: 'require_confirmation', }; it('passes safety_decision through to the toolRequest input', () => { const result = fromInteractionSync({ status: 'requires_action', steps: [ { type: 'function_call', id: 'call_123', name: 'click', arguments: { x: 60, y: 100, safety_decision: safetyDecision }, }, ], }); const toolRequest = result.message?.content.find( (p) => p.toolRequest )?.toolRequest; assert.deepStrictEqual(toolRequest, { name: 'click', ref: 'call_123', input: { x: 60, y: 100, safety_decision: safetyDecision }, }); }); it('resends safety_decision and sends safety_acknowledgement back', () => { const steps = toInteractionSteps([ { role: 'model', content: [ { toolRequest: { name: 'click', ref: 'call_123', input: { x: 60, y: 100, safety_decision: safetyDecision }, }, }, ], }, { role: 'tool', content: [ { toolResponse: { name: 'click', ref: 'call_123', output: { url: 'https://example.com', safety_acknowledgement: true, }, }, }, ], }, ]); assert.deepStrictEqual(steps, [ { type: 'function_call', name: 'click', arguments: { x: 60, y: 100, safety_decision: safetyDecision }, id: 'call_123', }, { type: 'function_result', name: 'click', result: { url: 'https://example.com', safety_acknowledgement: true }, call_id: 'call_123', }, ]); }); }); describe('agentic video understanding', () => { // Shapes seen live on gemini-4-argon (Oct 2026). const processingCallStep: Step = { type: 'processing_call', id: 'call_598332', signature: 'sig_call', }; const processingResultStep: Step = { type: 'processing_result', call_id: 'call_598332', signature: 'sig_result', }; it('sends metadata.processing on a YouTube video', () => { assert.deepStrictEqual( toInteractionContent({ media: { url: 'https://www.youtube.com/watch?v=abc123' }, metadata: { processing: 'agentic' }, }), { type: 'video', uri: 'https://www.youtube.com/watch?v=abc123', processing: 'agentic', } ); }); it('sends metadata.processing on inline and URI videos', () => { assert.deepStrictEqual( toInteractionContent({ media: { url: 'data:video/mp4;base64,AAAA', contentType: 'video/mp4', }, metadata: { processing: 'static' }, }), { type: 'video', mime_type: 'video/mp4', data: 'AAAA', processing: 'static', } ); assert.deepStrictEqual( toInteractionContent({ media: { url: 'https://generativelanguage.googleapis.com/v1beta/files/abc123', contentType: 'video/mp4', }, metadata: { processing: 'agentic' }, }), { type: 'video', mime_type: 'video/mp4', uri: 'https://generativelanguage.googleapis.com/v1beta/files/abc123', processing: 'agentic', } ); }); it('sends no processing field when it is not set', () => { assert.deepStrictEqual( toInteractionContent({ media: { url: 'https://www.youtube.com/watch?v=abc123' }, }), { type: 'video', uri: 'https://www.youtube.com/watch?v=abc123' } ); }); it('ignores metadata.processing on media that is not video', () => { assert.deepStrictEqual( toInteractionContent({ media: { url: 'data:image/png;base64,AAAA', contentType: 'image/png', }, metadata: { processing: 'agentic' }, }), { type: 'image', mime_type: 'image/png', data: 'AAAA' } ); }); it('returns processing steps as custom parts with their signatures', () => { assert.deepStrictEqual(fromInteractionStep(processingCallStep), [ { custom: { processingCall: { id: 'call_598332' } }, metadata: { thoughtSignature: 'sig_call' }, }, ]); assert.deepStrictEqual(fromInteractionStep(processingResultStep), [ { custom: { processingResult: { callId: 'call_598332' } }, metadata: { thoughtSignature: 'sig_result' }, }, ]); }); it('resends processing steps unchanged in the history', () => { // Turn 1's response, converted to Genkit parts and sent back as history. const modelContent = [ ...fromInteractionStep(processingCallStep), ...fromInteractionStep(processingResultStep), { text: 'The main moments are...' }, ]; const steps = toInteractionSteps([ { role: 'user', content: [ { media: { url: 'https://www.youtube.com/watch?v=abc123' }, metadata: { processing: 'agentic' }, }, { text: 'What happens in this video?' }, ], }, { role: 'model', content: modelContent }, { role: 'user', content: [{ text: 'What happens at the end?' }] }, ]); assert.deepStrictEqual(steps, [ { type: 'user_input', content: [ { type: 'video', uri: 'https://www.youtube.com/watch?v=abc123', processing: 'agentic', }, { type: 'text', text: 'What happens in this video?' }, ], }, processingCallStep, processingResultStep, { type: 'model_output', content: [{ type: 'text', text: 'The main moments are...' }], }, { type: 'user_input', content: [{ type: 'text', text: 'What happens at the end?' }], }, ]); }); it('reports tool use tokens in usage.custom', () => { const result = fromInteractionSync({ status: 'completed', steps: [ processingCallStep, processingResultStep, { type: 'model_output', content: [{ type: 'text', text: 'done' }] }, ], usage: { total_input_tokens: 416, total_output_tokens: 393, total_tool_use_tokens: 2054, total_thought_tokens: 2117, total_tokens: 4980, }, }); assert.deepStrictEqual(result.usage?.custom, { toolUseTokens: 2054 }); }); it('leaves usage.custom unset without tool use tokens', () => { const result = fromInteractionSync({ status: 'completed', usage: { total_input_tokens: 4, total_tokens: 4 }, }); assert.strictEqual(result.usage?.custom, undefined); }); }); describe('removeId3FromAudio', () => { // An ID3 tag holding a C2PA manifest, at the start of a streamed // audio/l16 chunk, as seen live: 6,125-byte tag + 5,760 bytes of PCM. const id3Tag = () => { const tag = Buffer.alloc(6125, 0x55); Buffer.from([ 0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x2f, 0x63, ]).copy(tag); tag.write('GEOB', 10, 'ascii'); return tag; }; const part = (bytes: Buffer, contentType = 'audio/l16'): Part => ({ media: { url: `data:${contentType};base64,${bytes.toString('base64')}`, contentType, }, }); const decode = (p: Part) => { const url = p.media?.url ?? ''; return Buffer.from(url.substring(url.indexOf(',') + 1), 'base64'); }; it('removes the tag and keeps the audio after it', () => { const pcm = Buffer.alloc(5760, 0x07); const result = removeId3FromAudio(part(Buffer.concat([id3Tag(), pcm]))); assert.strictEqual(result.media?.contentType, 'audio/l16'); assert.deepStrictEqual(decode(result), pcm); }); it('leaves chunks without a tag, and other parts, unchanged', () => { const pcm = part(Buffer.from([1, 2, 3, 4])); assert.strictEqual(removeId3FromAudio(pcm), pcm); const text: Part = { text: 'hi' }; assert.strictEqual(removeId3FromAudio(text), text); }); it('only changes headerless audio types', () => { const wav = part( Buffer.concat([id3Tag(), Buffer.from([1, 2])]), 'audio/wav' ); assert.strictEqual(removeId3FromAudio(wav), wav); }); }); describe('mergeStreamedAudio', () => { // Streamed TTS sends headerless chunks (`audio/l16` by default, as seen // live with gemini-3.8-flash-lite-tts). const audioPart = (bytes: number[], contentType = 'audio/l16'): Part => ({ media: { url: `data:${contentType};base64,${Buffer.from(bytes).toString('base64')}`, contentType, }, }); const decode = (part: Part) => { const url = part.media?.url ?? ''; return Buffer.from(url.substring(url.indexOf(',') + 1), 'base64'); }; it('joins audio/l16 chunks into one audio/l16 part', () => { const result = mergeStreamedAudio([ audioPart([1, 2]), audioPart([3, 4]), audioPart([5, 6]), ]); assert.strictEqual(result.length, 1); assert.strictEqual(result[0].media?.contentType, 'audio/l16'); assert.deepStrictEqual( decode(result[0]), Buffer.from([1, 2, 3, 4, 5, 6]) ); }); it('keeps the requested headerless format', () => { for (const type of ['audio/mulaw', 'audio/alaw']) { const result = mergeStreamedAudio([ audioPart([1], type), audioPart([2], type), ]); assert.strictEqual(result.length, 1); assert.strictEqual(result[0].media?.contentType, type); assert.deepStrictEqual(decode(result[0]), Buffer.from([1, 2])); } }); it('keeps content type parameters such as rate', () => { const type = 'audio/L16;rate=16000'; const result = mergeStreamedAudio([ audioPart([1], type), audioPart([2], type), ]); assert.strictEqual(result[0].media?.contentType, type); }); it('does not join chunks of different types', () => { const result = mergeStreamedAudio([ audioPart([1], 'audio/l16'), audioPart([2], 'audio/mulaw'), ]); assert.strictEqual(result.length, 2); }); // A streamed audio/wav request starts with a small complete WAV (header, // a little audio, then extra bytes after `data`) and continues with // audio/l16 chunks, as seen live. const wavPart = (audio: number[]): Part => { const data = Buffer.from(audio); const extra = Buffer.from([9, 9, 9, 9]); const header = Buffer.alloc(44); header.write('RIFF', 0, 'ascii'); header.write('WAVE', 8, 'ascii'); header.write('fmt ', 12, 'ascii'); header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22); header.writeUInt32LE(24000, 24); header.writeUInt32LE(48000, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34); header.write('data', 36, 'ascii'); header.writeUInt32LE(data.length, 40); const extraHeader = Buffer.alloc(8); extraHeader.write('LIST', 0, 'ascii'); extraHeader.writeUInt32LE(extra.length, 4); const wav = Buffer.concat([header, data, extraHeader, extra]); wav.writeUInt32LE(wav.length - 8, 4); return { media: { url: `data:audio/wav;base64,${wav.toString('base64')}`, contentType: 'audio/wav', }, }; }; it('puts the audio/l16 chunks of a streamed WAV into one WAV', () => { const result = mergeStreamedAudio([ wavPart([1, 2]), audioPart([3, 4]), audioPart([5, 6]), ]); assert.strictEqual(result.length, 1); assert.strictEqual(result[0].media?.contentType, 'audio/wav'); const wav = decode(result[0]); assert.strictEqual(wav.readUInt32LE(4), wav.length - 8); assert.strictEqual(wav.readUInt32LE(40), 6); assert.deepStrictEqual( wav.subarray(44, 50), Buffer.from([1, 2, 3, 4, 5, 6]) ); assert.strictEqual(wav.toString('ascii', 50, 54), 'LIST'); }); it('leaves a WAV without following chunks unchanged', () => { const wav = wavPart([1, 2]); assert.deepStrictEqual(mergeStreamedAudio([wav]), [wav]); }); it('leaves media typed audio/wav that is not a WAV file unchanged', () => { const notWav = audioPart([1, 2], 'audio/wav'); const pcm = audioPart([3, 4]); assert.deepStrictEqual(mergeStreamedAudio([notWav, pcm]), [notWav, pcm]); }); it('leaves other parts alone and joins each run separately', () => { const text: Part = { text: 'hello' }; const result = mergeStreamedAudio([ audioPart([1, 2]), audioPart([3, 4]), text, audioPart([5, 6]), ]); assert.strictEqual(result.length, 3); assert.deepStrictEqual(decode(result[0]), Buffer.from([1, 2, 3, 4])); assert.deepStrictEqual(result[1], text); assert.deepStrictEqual(decode(result[2]), Buffer.from([5, 6])); }); it('returns parts without audio unchanged', () => { const parts: Part[] = [{ text: 'a' }, { text: 'b' }]; assert.deepStrictEqual(mergeStreamedAudio(parts), parts); }); }); describe('ensureToolIds', () => { it('should assign IDs to tool requests without refs', () => { const messages: MessageData[] = [ { role: 'model', content: [ { toolRequest: { name: 'tool1', input: {} } }, { toolRequest: { name: 'tool2', input: {} } }, ], }, ]; const result = ensureToolIds(messages); const req1 = result[0].content[0].toolRequest!; const req2 = result[0].content[1].toolRequest!; assert.ok(req1.ref && req1.ref.startsWith('genkit-auto-id-')); assert.ok(req2.ref && req2.ref.startsWith('genkit-auto-id-')); assert.notStrictEqual(req1.ref, req2.ref); }); it('should assign matching IDs to tool responses without refs based on order', () => { const messages: MessageData[] = [ { role: 'model', content: [ { toolRequest: { name: 'tool1', input: {} } }, { toolRequest: { name: 'tool2', input: {} } }, ], }, { role: 'tool', content: [ { toolResponse: { name: 'tool1', output: {} } }, { toolResponse: { name: 'tool2', output: {} } }, ], }, ]; const result = ensureToolIds(messages); const req1 = result[0].content[0].toolRequest!; const req2 = result[0].content[1].toolRequest!; const res1 = result[1].content[0].toolResponse!; const res2 = result[1].content[1].toolResponse!; assert.ok(req1.ref); assert.strictEqual(req1.ref, res1.ref); assert.ok(req2.ref); assert.strictEqual(req2.ref, res2.ref); }); it('should assign orphan ID to tool response if no matching request', () => { const messages: MessageData[] = [ { role: 'tool', content: [{ toolResponse: { name: 'tool1', output: {} } }], }, ]; const result = ensureToolIds(messages); const res1 = result[0].content[0].toolResponse!; assert.ok(res1.ref && res1.ref.startsWith('genkit-orphan-id-')); }); it('should preserve existing refs', () => { const messages: MessageData[] = [ { role: 'model', content: [ { toolRequest: { name: 'tool1', input: {}, ref: 'existing-id' } }, ], }, ]; const result = ensureToolIds(messages); const req1 = result[0].content[0].toolRequest!; assert.strictEqual(req1.ref, 'existing-id'); }); }); describe('toInteractionRole', () => { it('should convert user role', () => { assert.strictEqual(toInteractionRole('user'), 'user'); }); it('should convert model role', () => { assert.strictEqual(toInteractionRole('model'), 'model'); }); it('should convert tool role to user', () => { assert.strictEqual(toInteractionRole('tool'), 'user'); }); it('should throw for system role', () => { assert.throws( () => toInteractionRole('system'), /System role should be handled as system_instruction/ ); }); }); describe('toInteractionTool', () => { it('should convert ToolDefinition to InteractionTool', () => { const tool: ToolDefinition = { name: 'myFunc', description: 'desc', inputSchema: { type: 'object', properties: { arg: { type: 'string' } }, }, }; const result = toInteractionTool(tool); assert.deepStrictEqual(result, { type: 'function', name: 'myFunc', description: 'desc', parameters: { type: 'object', properties: { arg: { type: 'string' } }, }, }); }); }); describe('toInteractionGenerationConfig', () => { it('should flatten thinkingConfig and map casing', () => { const config = { thinkingConfig: { thinkingLevel: 'HIGH', includeThoughts: true, }, }; const result = toInteractionGenerationConfig(config); assert.deepStrictEqual(result, { thinking_level: 'high', thinking_summaries: 'auto', }); }); it('should drop other thinkingConfig fields (no thinking_config) and report them', () => { const dropped: string[] = []; const result = toInteractionGenerationConfig( { thinkingConfig: { thinkingLevel: 'LOW', includeThoughts: false, thinkingBudget: 1024, unknownProp: 'test', }, }, (field) => dropped.push(field) ); assert.deepStrictEqual(result, { thinking_level: 'low', thinking_summaries: 'none', }); assert.deepStrictEqual(dropped, [ 'thinkingConfig.thinkingBudget', 'thinkingConfig.unknownProp', ]); }); it('should handle already snake_cased thinking_config', () => { const dropped: string[] = []; const result = toInteractionGenerationConfig( { thinking_config: { thinking_level: 'MEDIUM', include_thoughts: true, thinking_budget: 2048, }, }, (field) => dropped.push(field) ); assert.deepStrictEqual(result, { thinking_level: 'medium', thinking_summaries: 'auto', }); assert.deepStrictEqual(dropped, ['thinkingConfig.thinking_budget']); }); it('should drop contextCache and report it', () => { const dropped: string[] = []; const result = toInteractionGenerationConfig( { contextCache: true, temperature: 0.5 }, (field) => dropped.push(field) ); assert.deepStrictEqual(result, { temperature: 0.5 }); assert.deepStrictEqual(dropped, ['contextCache']); }); it('should forward unknown passthrough keys in snake_case', () => { const result = toInteractionGenerationConfig({ someNewField: 1 }); assert.deepStrictEqual(result, { some_new_field: 1 }); }); }); describe('toInteractionSpeechConfig', () => { it('maps a single prebuilt voice', () => { assert.deepStrictEqual( toInteractionSpeechConfig({ voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Puck' } }, }), [{ voice: 'Puck' }] ); }); it('maps multi-speaker voices', () => { assert.deepStrictEqual( toInteractionSpeechConfig({ multiSpeakerVoiceConfig: { speakerVoiceConfigs: [ { speaker: 'Joe', voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } }, }, { speaker: 'Jane', voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Puck' } }, }, ], }, }), { speakers: [ { voice: 'Kore', speaker: 'Joe' }, { voice: 'Puck', speaker: 'Jane' }, ], } ); }); it('applies languageCode to every entry', () => { assert.deepStrictEqual( toInteractionSpeechConfig({ languageCode: 'fr-FR', voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Puck' } }, }), [{ voice: 'Puck', language: 'fr-FR' }] ); }); it('returns undefined when no voice or language is set', () => { assert.strictEqual(toInteractionSpeechConfig(undefined), undefined); assert.strictEqual(toInteractionSpeechConfig({}), undefined); assert.strictEqual( toInteractionSpeechConfig({ voiceConfig: {} }), undefined ); }); it('is applied by toInteractionGenerationConfig', () => { assert.deepStrictEqual( toInteractionGenerationConfig({ maxOutputTokens: 100, speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Puck' } }, }, }), { max_output_tokens: 100, speech_config: [{ voice: 'Puck' }], } ); }); }); describe('speech_metadata annotations on text parts', () => { it('adds a speech_metadata annotation from metadata.speechMetadata', () => { assert.deepStrictEqual( toInteractionContent({ text: "How's it going today Jane?", metadata: { speechMetadata: { speaker: 'Joe', style: 'cheerful' } }, }), { type: 'text', text: "How's it going today Jane?", annotations: [ { type: 'speech_metadata', speaker: 'Joe', style: 'cheerful' }, ], } ); }); it('supports style alone (single-speaker)', () => { assert.deepStrictEqual( toInteractionContent({ text: 'Have a wonderful day!', metadata: { speechMetadata: { style: 'cheerful and friendly' } }, }), { type: 'text', text: 'Have a wonderful day!', annotations: [ { type: 'speech_metadata', style: 'cheerful and friendly' }, ], } ); }); it('adds no annotations for plain text or empty speechMetadata', () => { assert.deepStrictEqual(toInteractionContent({ text: 'Hi' }), { type: 'text', text: 'Hi', }); assert.deepStrictEqual( toInteractionContent({ text: 'Hi', metadata: { speechMetadata: {} } }), { type: 'text', text: 'Hi' } ); }); }); describe('toInteractionGoogleSearch', () => { it('should handle boolean true config', () => { const result = toInteractionGoogleSearch(true); assert.deepStrictEqual(result, { type: 'google_search' }); }); it('should handle array format in snake_case', () => { const result = toInteractionGoogleSearch({ search_types: ['web_search', 'image_search'], }); assert.deepStrictEqual(result, { type: 'google_search', search_types: ['web_search', 'image_search'], }); }); it('should handle object format in camelCase', () => { const result = toInteractionGoogleSearch({ searchTypes: { webSearch: {}, enterpriseWebSearch: {} }, }); assert.deepStrictEqual(result, { type: 'google_search', search_types: ['web_search', 'enterprise_web_search'], }); }); it('should handle empty config object', () => { const result = toInteractionGoogleSearch({}); assert.deepStrictEqual(result, { type: 'google_search' }); }); it('should pass through unrecognized fields from search types array', () => { const result = toInteractionGoogleSearch({ searchTypes: ['web_search', 'my_custom_search'], }); assert.deepStrictEqual(result, { type: 'google_search', search_types: ['web_search', 'my_custom_search'], }); }); it('should pass through unrecognized fields from search types object', () => { const result = toInteractionGoogleSearch({ searchTypes: { webSearch: {}, customSearchPlugin: {} }, }); assert.deepStrictEqual(result, { type: 'google_search', search_types: ['web_search', 'custom_search_plugin'], }); }); it('should throw an error for invalid top-level config', () => { assert.throws( () => toInteractionGoogleSearch('invalid'), /Invalid configuration for googleSearch tool/ ); }); it('should throw an error for invalid searchTypes format', () => { assert.throws( () => toInteractionGoogleSearch({ searchTypes: 'invalid' }), /Invalid searchTypes configuration/ ); }); it('should throw an error for invalid search type in array', () => { assert.throws( () => toInteractionGoogleSearch({ searchTypes: [123] }), /Invalid search type/ ); }); }); describe('toInteractionConfigTool', () => { it('should throw an error if toolRaw is not an object', () => { assert.throws( () => toInteractionConfigTool('not-an-object'), /Invalid tool configuration/ ); assert.throws( () => toInteractionConfigTool(['not-an-object']), /Invalid tool configuration/ ); assert.throws( () => toInteractionConfigTool(null), /Invalid tool configuration/ ); }); it('should handle built-in tool format with valid inputs', () => { const tool = { codeExecution: { someProp: 123 } }; const result = toInteractionConfigTool(tool); assert.deepStrictEqual(result, { type: 'code_execution', some_prop: 123, }); }); it('should handle built-in tool format with boolean true', () => { const tool = { codeExecution: true }; const result = toInteractionConfigTool(tool); assert.deepStrictEqual(result, { type: 'code_execution', }); }); it('should throw an error if built-in tool config is invalid type', () => { const tool = { codeExecution: 'invalid' }; assert.throws( () => toInteractionConfigTool(tool), /Invalid configuration for codeExecution tool/ ); }); it('should handle fileSearch configurations and validate array', () => { const tool = { fileSearch: { fileSearchStoreNames: ['store1'] } }; const result = toInteractionConfigTool(tool); assert.deepStrictEqual(result, { type: 'file_search', file_search_store_names: ['store1'], }); const invalidTool = { fileSearch: { fileSearchStoreNames: 'store1' } }; assert.throws( () => toInteractionConfigTool(invalidTool), /fileSearchStoreNames must be an array of strings/ ); }); it('should pass through arbitrary custom tools un-nested', () => { const tool = { type: 'function', name: 'myFunction', parameters: { type: 'object' }, }; const result = toInteractionConfigTool(tool); assert.deepStrictEqual(result, { type: 'function', name: 'myFunction', parameters: { type: 'object' }, }); }); it('should pass through unrecognized fields from tool configurations', () => { const tool = { urlContext: { myUrlParam: 1, }, }; const result = toInteractionConfigTool(tool); assert.deepStrictEqual(result, { type: 'url_context', my_url_param: 1, }); }); it('should handle mcpServer tool configuration and normalize string array allowed_tools to objects', () => { const tool = { mcpServer: { name: 'my-server', url: 'https://mcp.example.com', allowedTools: ['search', 'read_doc'], }, }; const result = toInteractionConfigTool(tool); assert.deepStrictEqual(result, { type: 'mcp_server', name: 'my-server', url: 'https://mcp.example.com', allowed_tools: [{ tools: ['search', 'read_doc'] }], }); }); }); describe('toInteractionContent', () => { it('should convert TextPart', () => { const part: Part = { text: 'Hello' }; const result = toInteractionContent(part); assert.deepStrictEqual(result, { type: 'text', text: 'Hello' }); }); it('should convert MediaPart (image data)', () => { const part: Part = { media: { url: 'data:image/png;base64,DATA', contentType: 'image/png', }, }; const result = toInteractionContent(part); assert.deepStrictEqual(result, { type: 'image', data: 'DATA', mime_type: 'image/png', }); }); it('should convert MediaPart (image uri)', () => { const part: Part = { media: { url: 'gs://bucket/image.png', contentType: 'image/png', }, }; const result = toInteractionContent(part); assert.deepStrictEqual(result, { type: 'image', uri: 'gs://bucket/image.png', mime_type: 'image/png', }); }); it('should convert MediaPart (audio)', () => { const part: Part = { media: { url: 'data:audio/mp3;base64,DATA', contentType: 'audio/mp3', }, }; const result = toInteractionContent(part); assert.deepStrictEqual(result, { type: 'audio', data: 'DATA', mime_type: 'audio/mp3', }); }); it('should convert MediaPart (document)', () => { const part: Part = { media: { url: 'gs://bucket/doc.pdf', contentType: 'application/pdf', }, }; const result = toInteractionContent(part); assert.deepStrictEqual(result, { type: 'document', uri: 'gs://bucket/doc.pdf', mime_type: 'application/pdf', }); }); it('should convert a CSV MediaPart to a document', () => { const result = toInteractionContent({ media: { url: 'data:text/csv;base64,DATA', contentType: 'text/csv' }, }); assert.deepStrictEqual(result, { type: 'document', data: 'DATA', mime_type: 'text/csv', }); }); it('should send a YouTube URL without contentType as video without mime_type', () => { const url = 'https://www.youtube.com/watch?v=abc123'; assert.deepStrictEqual(toInteractionContent({ media: { url } }), { type: 'video', uri: url, }); assert.deepStrictEqual( toInteractionContent({ media: { url: 'https://youtu.be/abc123' } }), { type: 'video', uri: 'https://youtu.be/abc123' } ); }); it('should send other URIs without a known contentType as documents without mime_type', () => { const url = 'https://generativelanguage.googleapis.com/v1beta/files/abc123'; assert.deepStrictEqual(toInteractionContent({ media: { url } }), { type: 'document', uri: url, }); }); it('should still throw for a data URL without a contentType', () => { assert.throws( () => toInteractionContent({ media: { url: 'data:;base64,DATA' } }), /Media part missing contentType/ ); }); it('should throw for an unsupported contentType', () => { assert.throws( () => toInteractionContent({ media: { url: 'gs://bucket/notes.txt', contentType: 'text/plain' }, }), /Unsupported media type: text\/plain/ ); }); }); describe('toInteractionSteps', () => { it('should convert ToolRequestPart to step', () => { const messages: MessageData[] = [ { role: 'model', content: [ { toolRequest: { name: 'func', input: { a: 1 }, ref: 'ref1', }, }, ], }, ]; const result = toInteractionSteps(messages); assert.deepStrictEqual(result, [ { type: 'function_call', name: 'func', arguments: { a: 1 }, id: 'ref1', }, ]); }); it('should send a ToolRequestPart thoughtSignature back as the function_call signature', () => { const messages: MessageData[] = [ { role: 'model', content: [ { toolRequest: { name: 'func', input: { a: 1 }, ref: 'ref1' }, metadata: { thoughtSignature: 'sig-call' }, }, ], }, ]; assert.deepStrictEqual(toInteractionSteps(messages), [ { type: 'function_call', name: 'func', arguments: { a: 1 }, id: 'ref1', signature: 'sig-call', }, ]); }); it('should convert ToolResponsePart to step', () => { const messages: MessageData[] = [ { role: 'tool', content: [ { toolResponse: { name: 'func', output: { result: 'ok' }, ref: 'ref1', }, }, ], }, ]; const result = toInteractionSteps(messages); assert.deepStrictEqual(result, [ { type: 'function_result', name: 'func', result: { result: 'ok' }, call_id: 'ref1', }, ]); }); it('should group text contents into model_output step', () => { const messages: MessageData[] = [ { role: 'model', content: [{ text: 'Thinking' }, { text: 'Done' }], }, ]; const result = toInteractionSteps(messages); assert.deepStrictEqual(result, [ { type: 'model_output', content: [ { type: 'text', text: 'Thinking' }, { type: 'text', text: 'Done' }, ], }, ]); }); it('should convert custom googleSearchCall to google_search_call step', () => { const messages: MessageData[] = [ { role: 'model', content: [ { custom: { googleSearchCall: { id: 'gs1', arguments: { queries: ['genkit'] }, }, }, metadata: { thoughtSignature: 'sig' }, }, ], }, ]; const result = toInteractionSteps(messages); assert.deepStrictEqual(result, [ { type: 'google_search_call', id: 'gs1', arguments: { queries: ['genkit'] }, signature: 'sig', }, ]); }); it('should convert custom googleSearchResult to google_search_result step', () => { const messages: MessageData[] = [ { role: 'tool', content: [ { custom: { googleSearchResult: { callId: 'gs1', result: { answer: 'framework' }, }, }, metadata: { thoughtSignature: 'sig' }, }, ], }, ]; const result = toInteractionSteps(messages); assert.deepStrictEqual(result, [ { type: 'google_search_result', call_id: 'gs1', result: { answer: 'framework' }, signature: 'sig', }, ]); }); it('should round-trip a google_search_result with an array result (tool loop history)', () => { // Shape returned live by gemini-3.6-flash. Resending it in the history // (second turn of a tool loop) used to fail Zod validation. const step: Step = { type: 'google_search_result', call_id: 'call_1', result: [{ search_suggestions: '