From 78eaec83afa20cef799ade5d815fa4260235426d Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 20 May 2026 13:11:55 -0700 Subject: [PATCH 01/14] Feat: Add previousInteractionId to LlmRequest and interactionId to LlmResponse --- core/src/models/llm_request.ts | 5 +++++ core/src/models/llm_response.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/core/src/models/llm_request.ts b/core/src/models/llm_request.ts index 676780fb..9545858d 100644 --- a/core/src/models/llm_request.ts +++ b/core/src/models/llm_request.ts @@ -46,6 +46,11 @@ export interface LlmRequest { * The set of allowed tools, populated by request processors. */ allowedTools?: string[]; + + /** + * The interaction ID from the previous turn, if any. + */ + previousInteractionId?: string; } /** diff --git a/core/src/models/llm_response.ts b/core/src/models/llm_response.ts index 4a869c7d..de2e4726 100644 --- a/core/src/models/llm_response.ts +++ b/core/src/models/llm_response.ts @@ -94,6 +94,11 @@ export interface LlmResponse { * Audio transcription of model output. */ outputTranscription?: Transcription; + + /** + * The interaction ID returned by the model, if any. + */ + interactionId?: string; } /** From b7ff160349650ad694d205c58d65970fb37317ad Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 20 May 2026 13:12:06 -0700 Subject: [PATCH 02/14] Feat: Implement and register InteractionsRequestProcessor --- core/src/agents/llm_agent.ts | 2 + .../interactions_request_processor.ts | 49 +++++++++++++++++++ core/src/common.ts | 4 ++ 3 files changed, 55 insertions(+) create mode 100644 core/src/agents/processors/interactions_request_processor.ts diff --git a/core/src/agents/llm_agent.ts b/core/src/agents/llm_agent.ts index 30aba287..ad8f2d0a 100644 --- a/core/src/agents/llm_agent.ts +++ b/core/src/agents/llm_agent.ts @@ -62,6 +62,7 @@ import {CONTENT_REQUEST_PROCESSOR} from './processors/content_request_processor. import {ContextCompactorRequestProcessor} from './processors/context_compactor_request_processor.js'; import {IDENTITY_LLM_REQUEST_PROCESSOR} from './processors/identity_llm_request_processor.js'; import {INSTRUCTIONS_LLM_REQUEST_PROCESSOR} from './processors/instructions_llm_request_processor.js'; +import {INTERACTIONS_REQUEST_PROCESSOR} from './processors/interactions_request_processor.js'; import {REQUEST_CONFIRMATION_LLM_REQUEST_PROCESSOR} from './processors/request_confirmation_llm_request_processor.js'; import {TOOL_FILTER_REQUEST_PROCESSOR} from './processors/tool_filter_request_processor.js'; import {ReadonlyContext} from './readonly_context.js'; @@ -396,6 +397,7 @@ export class LlmAgent extends BaseAgent { INSTRUCTIONS_LLM_REQUEST_PROCESSOR, REQUEST_CONFIRMATION_LLM_REQUEST_PROCESSOR, CONTENT_REQUEST_PROCESSOR, + INTERACTIONS_REQUEST_PROCESSOR, CODE_EXECUTION_REQUEST_PROCESSOR, TOOL_FILTER_REQUEST_PROCESSOR, ]; diff --git a/core/src/agents/processors/interactions_request_processor.ts b/core/src/agents/processors/interactions_request_processor.ts new file mode 100644 index 00000000..258c8ef7 --- /dev/null +++ b/core/src/agents/processors/interactions_request_processor.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Event} from '../../events/event.js'; +import {Gemini} from '../../models/google_llm.js'; +import {LlmRequest} from '../../models/llm_request.js'; +import {InvocationContext} from '../invocation_context.js'; +import {isLlmAgent} from '../llm_agent.js'; +import {BaseLlmRequestProcessor} from './base_llm_processor.js'; + +/** + * Request processor for Gemini Interactions API. + * Resolves the previous interaction ID from the session history. + */ +export class InteractionsRequestProcessor implements BaseLlmRequestProcessor { + // eslint-disable-next-line require-yield + async *runAsync( + invocationContext: InvocationContext, + llmRequest: LlmRequest, + ): AsyncGenerator { + const agent = invocationContext.agent; + if (!agent || !isLlmAgent(agent)) { + return; + } + + const model = agent.canonicalModel; + if (model instanceof Gemini && model.useInteractionsApi) { + const events = invocationContext.session.events; + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + // Skip events not belonging to the current branch or author + if ( + event.branch === invocationContext.branch && + event.author === agent.name && + event.interactionId + ) { + llmRequest.previousInteractionId = event.interactionId; + break; + } + } + } + } +} + +export const INTERACTIONS_REQUEST_PROCESSOR = + new InteractionsRequestProcessor(); diff --git a/core/src/common.ts b/core/src/common.ts index 264b33dd..a9f602bb 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -47,6 +47,10 @@ export { ContentRequestProcessor, } from './agents/processors/content_request_processor.js'; export {ContextCompactorRequestProcessor} from './agents/processors/context_compactor_request_processor.js'; +export { + INTERACTIONS_REQUEST_PROCESSOR, + InteractionsRequestProcessor, +} from './agents/processors/interactions_request_processor.js'; export {ReadonlyContext} from './agents/readonly_context.js'; export {RoutedAgent, isRoutedAgent} from './agents/routed_agent.js'; export type {AgentRouter, RoutedAgentConfig} from './agents/routed_agent.js'; From 18494c043dfb8f47cd425f62d5a22d1f070d02d0 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 20 May 2026 13:12:59 -0700 Subject: [PATCH 03/14] Feat: Implement interactions_utils for mapping and running Interactions API --- core/src/models/interactions_utils.ts | 991 ++++++++++++++++++++++++++ 1 file changed, 991 insertions(+) create mode 100644 core/src/models/interactions_utils.ts diff --git a/core/src/models/interactions_utils.ts b/core/src/models/interactions_utils.ts new file mode 100644 index 00000000..e5e516de --- /dev/null +++ b/core/src/models/interactions_utils.ts @@ -0,0 +1,991 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Content, + FinishReason, + FunctionCall, + FunctionResponse, + GenerateContentConfig, + GoogleGenAI, + Outcome, + Part, +} from '@google/genai'; +import {base64Encode, isBrowser} from '../utils/env_aware_utils.js'; +import {logger} from '../utils/logger.js'; +import {LlmRequest} from './llm_request.js'; +import {LlmResponse} from './llm_response.js'; + +// --- Helper Interfaces for Strong Typing --- + +interface ExtendedPart extends Part { + thoughtSignature?: string | Uint8Array | null; + thought?: boolean; +} + +interface ExtendedTool { + functionDeclarations?: Array<{ + name: string; + description?: string; + parameters?: { + properties?: Record; + required?: string[]; + }; + parametersJsonSchema?: unknown; + }>; + googleSearch?: unknown; + codeExecution?: unknown; + urlContext?: unknown; +} + +interface InteractionTextContent { + type: 'text'; + text: string; +} + +interface InteractionFunctionCall { + type: 'function_call'; + id: string; + name: string; + arguments: Record; + thought_signature?: string; +} + +interface InteractionFunctionResult { + type: 'function_result'; + name: string; + call_id: string; + result: unknown; +} + +interface InteractionMediaContent { + type: 'image' | 'audio' | 'video' | 'document'; + data?: string; + uri?: string; + mime_type: string; +} + +interface InteractionThought { + type: 'thought'; + signature?: string; +} + +interface InteractionCodeExecutionCall { + type: 'code_execution_call'; + id: string; + arguments: { + code: string; + language: string; + }; +} + +interface InteractionCodeExecutionResult { + type: 'code_execution_result'; + call_id: string; + result: string; + is_error: boolean; +} + +type InteractionContent = + | InteractionTextContent + | InteractionFunctionCall + | InteractionFunctionResult + | InteractionMediaContent + | InteractionThought + | InteractionCodeExecutionCall + | InteractionCodeExecutionResult; + +interface InteractionTurn { + role: string; + content: InteractionContent[]; +} + +interface InteractionTool { + type: 'function' | 'google_search' | 'code_execution' | 'url_context'; + name?: string; + description?: string; + parameters?: unknown; +} + +interface InteractionResponse { + id: string; + status: 'completed' | 'requires_action' | 'failed' | string; + error?: { + code: string; + message: string; + }; + outputs?: Record[]; + usage?: { + total_input_tokens?: number; + total_output_tokens?: number; + }; +} + +interface InteractionSSEEvent { + event_type?: string; + eventType?: string; + delta?: { + type: string; + text?: string; + name?: string; + id?: string; + arguments?: Record; + thought_signature?: string; + data?: string; + uri?: string; + mime_type: string; + }; + status?: string; + error?: { + code: string; + message: string; + }; + code?: string; + message?: string; + interaction_id?: string; + interactionId?: string; + interaction?: { + id: string; + }; + id?: string; +} + +interface GoogleGenAIWithInteractions extends GoogleGenAI { + interactions: { + create(params: { + model?: string; + input: InteractionTurn[]; + stream: boolean; + systemInstruction?: string; + tools?: InteractionTool[]; + generationConfig?: Record; + previousInteractionId?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise; // We keep 'any' here as the SDK return type is complex (stream vs non-stream) + }; +} + +// --- Helper Functions --- + +/** + * Helper to encode string or Uint8Array to base64. + */ +function encodeToBase64(data: string | Uint8Array): string { + if (typeof data === 'string') { + return base64Encode(data); + } + if (isBrowser()) { + let binary = ''; + const len = data.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(data[i]); + } + // eslint-disable-next-line no-undef + return window.btoa(binary); + } + return Buffer.from(data).toString('base64'); +} + +/** + * Extracts the latest turn contents for interactions API. + */ +export function getLatestUserContents(contents: Content[]): Content[] { + if (!contents || contents.length === 0) { + return []; + } + + // Find the latest continuous user messages from the end + const latestUserContents: Content[] = []; + for (let i = contents.length - 1; i >= 0; i--) { + const content = contents[i]; + if (content.role === 'user') { + latestUserContents.unshift(content); + } else { + // Stop when we hit a non-user message + break; + } + } + + // Check if the user contents contain a function_result + let hasFunctionResult = false; + for (const content of latestUserContents) { + if (content.parts) { + for (const part of content.parts) { + if ( + part.functionResponse !== undefined && + part.functionResponse !== null + ) { + hasFunctionResult = true; + break; + } + } + } + if (hasFunctionResult) { + break; + } + } + + // If we have a function_result, we also need the preceding model content + // with the function_call so the API can match the call_id + if (hasFunctionResult && contents.length > latestUserContents.length) { + const userStartIdx = contents.length - latestUserContents.length; + if (userStartIdx > 0) { + const precedingContent = contents[userStartIdx - 1]; + if (precedingContent.role === 'model' && precedingContent.parts) { + for (const part of precedingContent.parts) { + if (part.functionCall !== undefined && part.functionCall !== null) { + return [precedingContent, ...latestUserContents]; + } + } + } + } + } + + return latestUserContents; +} + +/** + * Convert a Part to an interaction content object. + */ +export function convertPartToInteractionContent( + part: Part, +): InteractionContent | null { + const extPart = part as ExtendedPart; + + if (extPart.text !== undefined && extPart.text !== null) { + return {type: 'text', text: extPart.text}; + } + + if (extPart.functionCall !== undefined && extPart.functionCall !== null) { + const result: InteractionFunctionCall = { + type: 'function_call', + id: extPart.functionCall.id || '', + name: extPart.functionCall.name, + arguments: (extPart.functionCall.args as Record) || {}, + }; + if ( + extPart.thoughtSignature !== undefined && + extPart.thoughtSignature !== null + ) { + result['thought_signature'] = encodeToBase64(extPart.thoughtSignature); + } + return result; + } + + if ( + extPart.functionResponse !== undefined && + extPart.functionResponse !== null + ) { + let resultValue: unknown = extPart.functionResponse.response; + if ( + typeof resultValue !== 'object' && + typeof resultValue !== 'string' && + !Array.isArray(resultValue) + ) { + resultValue = String(resultValue); + } + logger.debug( + `Converting function_response: name=${extPart.functionResponse.name}, call_id=${extPart.functionResponse.id}`, + ); + return { + type: 'function_result', + name: extPart.functionResponse.name || '', + call_id: extPart.functionResponse.id || '', + result: resultValue, + }; + } + + if (extPart.inlineData !== undefined && extPart.inlineData !== null) { + const mimeType = extPart.inlineData.mimeType || ''; + if (mimeType.startsWith('image/')) { + return { + type: 'image', + data: extPart.inlineData.data, + mime_type: mimeType, + }; + } else if (mimeType.startsWith('audio/')) { + return { + type: 'audio', + data: extPart.inlineData.data, + mime_type: mimeType, + }; + } else if (mimeType.startsWith('video/')) { + return { + type: 'video', + data: extPart.inlineData.data, + mime_type: mimeType, + }; + } else { + return { + type: 'document', + data: extPart.inlineData.data, + mime_type: mimeType, + }; + } + } + + if (extPart.fileData !== undefined && extPart.fileData !== null) { + const mimeType = extPart.fileData.mimeType || ''; + if (mimeType.startsWith('image/')) { + return { + type: 'image', + uri: extPart.fileData.fileUri, + mime_type: mimeType, + }; + } else if (mimeType.startsWith('audio/')) { + return { + type: 'audio', + uri: extPart.fileData.fileUri, + mime_type: mimeType, + }; + } else if (mimeType.startsWith('video/')) { + return { + type: 'video', + uri: extPart.fileData.fileUri, + mime_type: mimeType, + }; + } else { + return { + type: 'document', + uri: extPart.fileData.fileUri, + mime_type: mimeType, + }; + } + } + + if (extPart.thought) { + const result: InteractionThought = {type: 'thought'}; + if ( + extPart.thoughtSignature !== undefined && + extPart.thoughtSignature !== null + ) { + result['signature'] = encodeToBase64(extPart.thoughtSignature); + } + return result; + } + + if ( + extPart.codeExecutionResult !== undefined && + extPart.codeExecutionResult !== null + ) { + const isError = + extPart.codeExecutionResult.outcome === Outcome.OUTCOME_FAILED || + extPart.codeExecutionResult.outcome === Outcome.OUTCOME_DEADLINE_EXCEEDED; + return { + type: 'code_execution_result', + call_id: '', + result: extPart.codeExecutionResult.output || '', + is_error: isError, + }; + } + + if (extPart.executableCode !== undefined && extPart.executableCode !== null) { + return { + type: 'code_execution_call', + id: '', + arguments: { + code: extPart.executableCode.code, + language: extPart.executableCode.language, + }, + }; + } + + return null; +} + +/** + * Convert a Content to a TurnParam object. + */ +export function convertContentToTurn(content: Content): InteractionTurn { + const contents: InteractionContent[] = []; + if (content.parts) { + for (const part of content.parts) { + const interactionContent = convertPartToInteractionContent(part); + if (interactionContent) { + contents.push(interactionContent); + } + } + } + + return { + role: content.role || 'user', + content: contents, + }; +} + +/** + * Convert a list of Content objects to turns. + */ +export function convertContentsToTurns(contents: Content[]): InteractionTurn[] { + const turns: InteractionTurn[] = []; + for (const content of contents) { + const turn = convertContentToTurn(content); + if (turn.content && turn.content.length > 0) { + turns.push(turn); + } + } + return turns; +} + +/** + * Convert tools config to interactions format. + */ +export function convertToolsConfigToInteractionsFormat( + config: GenerateContentConfig, +): InteractionTool[] { + if (!config.tools) { + return []; + } + + const interactionTools: InteractionTool[] = []; + for (const tool of config.tools) { + const t = tool as ExtendedTool; + if (t.functionDeclarations) { + for (const funcDecl of t.functionDeclarations) { + const funcTool: InteractionTool = { + type: 'function', + name: funcDecl.name, + }; + if (funcDecl.description) { + funcTool['description'] = funcDecl.description; + } + if (funcDecl.parameters) { + if (funcDecl.parameters.properties) { + const props: Record = {}; + for (const [k, v] of Object.entries( + funcDecl.parameters.properties, + )) { + props[k] = JSON.parse(JSON.stringify(v)); + } + funcTool['parameters'] = { + type: 'object', + properties: props, + required: funcDecl.parameters.required + ? [...funcDecl.parameters.required] + : undefined, + }; + } + } else if (funcDecl.parametersJsonSchema) { + funcTool['parameters'] = funcDecl.parametersJsonSchema; + } + interactionTools.push(funcTool); + } + } + + if (t.googleSearch) { + interactionTools.push({type: 'google_search'}); + } + + if (t.codeExecution) { + interactionTools.push({type: 'code_execution'}); + } + + if (t.urlContext) { + interactionTools.push({type: 'url_context'}); + } + } + + return interactionTools; +} + +/** + * Convert interaction output to a Part. + */ +export function convertInteractionOutputToPart( + output: Record, +): Part | null { + if (!output || !output.type) { + return null; + } + + const outputType = output.type as string; + + if (outputType === 'text') { + return {text: (output.text as string) || ''}; + } + + if (outputType === 'function_call') { + logger.debug( + `Converting function_call output: name=${output.name}, id=${output.id}`, + ); + let thoughtSignature: Uint8Array | undefined = undefined; + const thoughtSigValue = output.thought_signature; + if (thoughtSigValue && typeof thoughtSigValue === 'string') { + if (isBrowser()) { + // eslint-disable-next-line no-undef + const binaryString = window.atob(thoughtSigValue); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + thoughtSignature = bytes; + } else { + thoughtSignature = Buffer.from(thoughtSigValue, 'base64'); + } + } + return { + functionCall: { + id: output.id as string, + name: output.name as string, + args: (output.arguments as Record) || {}, + } as FunctionCall, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + thoughtSignature: thoughtSignature as any, // Keep any here if Part thoughtSignature is strict + }; + } + + if (outputType === 'function_result') { + const result = output.result; + return { + functionResponse: { + id: output.call_id as string, + response: result, + } as FunctionResponse, + }; + } + + if (outputType === 'image') { + if (output.data) { + return { + inlineData: { + data: output.data as string, + mimeType: output.mime_type as string, + }, + }; + } else if (output.uri) { + return { + fileData: { + fileUri: output.uri as string, + mimeType: output.mime_type as string, + }, + }; + } + } + + if (outputType === 'audio') { + if (output.data) { + return { + inlineData: { + data: output.data as string, + mimeType: output.mime_type as string, + }, + }; + } else if (output.uri) { + return { + fileData: { + fileUri: output.uri as string, + mimeType: output.mime_type as string, + }, + }; + } + } + + if (outputType === 'thought') { + return null; + } + + if (outputType === 'code_execution_result') { + return { + codeExecutionResult: { + output: (output.result as string) || '', + outcome: output.is_error ? Outcome.OUTCOME_FAILED : Outcome.OUTCOME_OK, + }, + }; + } + + if (outputType === 'code_execution_call') { + const args = (output.arguments as Record) || {}; + return { + executableCode: { + code: args.code || '', + language: args.language || 'PYTHON', + }, + }; + } + + if (outputType === 'google_search_result') { + if (output.result && Array.isArray(output.result)) { + const resultsText = output.result + .filter((r) => r) + .map((r) => (typeof r === 'object' ? JSON.stringify(r) : String(r))) + .join('\n'); + return {text: resultsText}; + } + } + + return null; +} + +/** + * Convert Interaction response to an LlmResponse. + */ +export function convertInteractionToLlmResponse( + interaction: InteractionResponse, +): LlmResponse { + if (interaction.status === 'failed') { + let errorMsg = 'Unknown error'; + let errorCode = 'UNKNOWN_ERROR'; + if (interaction.error) { + errorMsg = interaction.error.message || errorMsg; + errorCode = interaction.error.code || errorCode; + } + return { + errorCode: errorCode, + errorMessage: errorMsg, + interactionId: interaction.id, + }; + } + + const parts: Part[] = []; + if (interaction.outputs) { + for (const output of interaction.outputs) { + const part = convertInteractionOutputToPart(output); + if (part) { + parts.push(part); + } + } + } + + let content: Content | undefined = undefined; + if (parts.length > 0) { + content = {role: 'model', parts: parts}; + } + + let usageMetadata: LlmResponse['usageMetadata'] = undefined; + if (interaction.usage) { + const inputTokens = interaction.usage.total_input_tokens || 0; + const outputTokens = interaction.usage.total_output_tokens || 0; + usageMetadata = { + promptTokenCount: inputTokens, + candidatesTokenCount: outputTokens, + totalTokenCount: inputTokens + outputTokens, + }; + } + + let finishReason: FinishReason | undefined = undefined; + if ( + interaction.status === 'completed' || + interaction.status === 'requires_action' + ) { + finishReason = 'STOP' as FinishReason; + } + + return { + content: content, + usageMetadata: usageMetadata, + finishReason: finishReason, + turnComplete: + interaction.status === 'completed' || + interaction.status === 'requires_action', + interactionId: interaction.id, + }; +} + +/** + * Convert InteractionSSEEvent to LlmResponse. + */ +export function convertInteractionEventToLlmResponse( + event: InteractionSSEEvent, + aggregatedParts: Part[], + interactionId?: string, +): LlmResponse | null { + const eventType = event.event_type || event.eventType; + + if (eventType === 'content.delta') { + const delta = event.delta; + if (!delta) { + return null; + } + + const deltaType = delta.type; + + if (deltaType === 'text') { + const text = delta.text || ''; + if (text) { + const part: Part = {text: text}; + aggregatedParts.push(part); + return { + content: {role: 'model', parts: [part]}, + partial: true, + turnComplete: false, + interactionId: interactionId, + }; + } + } else if (deltaType === 'function_call') { + if (delta.name) { + let thoughtSignature: Uint8Array | undefined = undefined; + const thoughtSigValue = delta.thought_signature; + if (thoughtSigValue && typeof thoughtSigValue === 'string') { + if (isBrowser()) { + // eslint-disable-next-line no-undef + const binaryString = window.atob(thoughtSigValue); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + thoughtSignature = bytes; + } else { + thoughtSignature = Buffer.from(thoughtSigValue, 'base64'); + } + } + const part: Part = { + functionCall: { + id: delta.id || '', + name: delta.name, + args: delta.arguments || {}, + } as FunctionCall, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + thoughtSignature: thoughtSignature as any, + }; + aggregatedParts.push(part); + return null; + } + } else if (deltaType === 'image') { + if (delta.data || delta.uri) { + let part: Part; + if (delta.data) { + part = { + inlineData: { + data: delta.data, + mimeType: delta.mime_type, + }, + }; + } else { + part = { + fileData: { + fileUri: delta.uri, + mimeType: delta.mime_type, + }, + }; + } + aggregatedParts.push(part); + return { + content: {role: 'model', parts: [part]}, + partial: false, + turnComplete: false, + interactionId: interactionId, + }; + } + } + } else if (eventType === 'content.stop') { + if (aggregatedParts.length > 0) { + return { + content: {role: 'model', parts: [...aggregatedParts]}, + partial: false, + turnComplete: false, + interactionId: interactionId, + }; + } + } else if (eventType === 'interaction') { + return convertInteractionToLlmResponse( + event as unknown as InteractionResponse, + ); + } else if (eventType === 'interaction.status_update') { + const status = event.status; + if (status === 'completed' || status === 'requires_action') { + return { + content: + aggregatedParts.length > 0 + ? {role: 'model', parts: [...aggregatedParts]} + : undefined, + partial: false, + turnComplete: true, + finishReason: 'STOP' as FinishReason, + interactionId: interactionId, + }; + } else if (status === 'failed') { + const error = event.error; + return { + errorCode: error ? error.code : 'UNKNOWN_ERROR', + errorMessage: error ? error.message : 'Unknown error', + turnComplete: true, + interactionId: interactionId, + }; + } + } else if (eventType === 'error') { + return { + errorCode: event.code || 'UNKNOWN_ERROR', + errorMessage: event.message || 'Unknown error', + turnComplete: true, + interactionId: interactionId, + }; + } + + return null; +} + +/** + * Build generation config. + */ +export function buildGenerationConfig( + config: GenerateContentConfig, +): Record { + const generationConfig: Record = {}; + if (config.temperature !== undefined && config.temperature !== null) { + generationConfig['temperature'] = config.temperature; + } + if (config.topP !== undefined && config.topP !== null) { + generationConfig['top_p'] = config.topP; + } + if (config.topK !== undefined && config.topK !== null) { + generationConfig['top_k'] = config.topK; + } + if (config.maxOutputTokens !== undefined && config.maxOutputTokens !== null) { + generationConfig['max_output_tokens'] = config.maxOutputTokens; + } + if (config.stopSequences) { + generationConfig['stop_sequences'] = config.stopSequences; + } + if (config.presencePenalty !== undefined && config.presencePenalty !== null) { + generationConfig['presence_penalty'] = config.presencePenalty; + } + if ( + config.frequencyPenalty !== undefined && + config.frequencyPenalty !== null + ) { + generationConfig['frequency_penalty'] = config.frequencyPenalty; + } + return generationConfig; +} + +/** + * Extract system instruction. + */ +export function extractSystemInstruction( + config: GenerateContentConfig, +): string | undefined { + const systemInstruction = config.systemInstruction; + if (!systemInstruction) { + return undefined; + } + + if (typeof systemInstruction === 'string') { + return systemInstruction; + } + + if ( + typeof systemInstruction === 'object' && + 'parts' in systemInstruction && + Array.isArray(systemInstruction.parts) + ) { + const texts: string[] = []; + for (const part of systemInstruction.parts) { + const p = part as Part; + if (p.text) { + texts.push(p.text); + } + } + return texts.length > 0 ? texts.join('\n') : undefined; + } + + return undefined; +} + +/** + * Extract stream interaction ID helper. + */ +function extractStreamInteractionId( + event: InteractionSSEEvent, +): string | undefined { + if (event.interaction_id || event.interactionId) { + return event.interaction_id || event.interactionId; + } + + if (event.interaction && event.interaction.id) { + return event.interaction.id; + } + + if (event.event_type === 'interaction' || event.eventType === 'interaction') { + return event.id; + } + + return undefined; +} + +/** + * Generate content using the interactions API. + */ +export async function* generateContentViaInteractions( + apiClient: GoogleGenAI, + llmRequest: LlmRequest, + stream: boolean, +): AsyncGenerator { + let contents = llmRequest.contents; + if (llmRequest.previousInteractionId && contents) { + contents = getLatestUserContents(contents); + } + + const inputTurns = convertContentsToTurns(contents); + const interactionTools = convertToolsConfigToInteractionsFormat( + llmRequest.config || {}, + ); + const systemInstruction = extractSystemInstruction(llmRequest.config || {}); + const generationConfig = buildGenerationConfig(llmRequest.config || {}); + const previousInteractionId = llmRequest.previousInteractionId; + + logger.info( + `Sending request via interactions API, model: ${llmRequest.model}, stream: ${stream}, previous_interaction_id: ${previousInteractionId}`, + ); + + let currentInteractionId = previousInteractionId; + const clientWithInteractions = apiClient as GoogleGenAIWithInteractions; + + if (stream) { + const responses = await clientWithInteractions.interactions.create({ + model: llmRequest.model, + input: inputTurns, + stream: true, + systemInstruction: systemInstruction, + tools: interactionTools.length > 0 ? interactionTools : undefined, + generationConfig: + Object.keys(generationConfig).length > 0 ? generationConfig : undefined, + previousInteractionId: previousInteractionId, + }); + + const aggregatedParts: Part[] = []; + for await (const event of responses) { + const sseEvent = event as InteractionSSEEvent; + const interactionId = extractStreamInteractionId(sseEvent); + if (interactionId) { + currentInteractionId = interactionId; + } + const llmResponse = convertInteractionEventToLlmResponse( + sseEvent, + aggregatedParts, + currentInteractionId, + ); + if (llmResponse) { + yield llmResponse; + } + } + + if (aggregatedParts.length > 0) { + yield { + content: {role: 'model', parts: aggregatedParts}, + partial: false, + turnComplete: true, + finishReason: 'STOP' as FinishReason, + interactionId: currentInteractionId, + }; + } + } else { + const interaction = await clientWithInteractions.interactions.create({ + model: llmRequest.model, + input: inputTurns, + stream: false, + systemInstruction: systemInstruction, + tools: interactionTools.length > 0 ? interactionTools : undefined, + generationConfig: + Object.keys(generationConfig).length > 0 ? generationConfig : undefined, + previousInteractionId: previousInteractionId, + }); + + logger.info('Interaction response received from the model.'); + yield convertInteractionToLlmResponse(interaction as InteractionResponse); + } +} From 81285dfdb19b09e138891cdd45bfc31d2b09452b Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 20 May 2026 13:13:08 -0700 Subject: [PATCH 04/14] Feat: Integrate Interactions API into Gemini model class --- core/src/models/google_llm.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/core/src/models/google_llm.ts b/core/src/models/google_llm.ts index 55737629..7785a496 100644 --- a/core/src/models/google_llm.ts +++ b/core/src/models/google_llm.ts @@ -20,6 +20,7 @@ import {StreamingResponseAggregator} from '../utils/streaming_utils.js'; import {BaseLlm} from './base_llm.js'; import {BaseLlmConnection} from './base_llm_connection.js'; import {GeminiLlmConnection} from './gemini_llm_connection.js'; +import {generateContentViaInteractions} from './interactions_utils.js'; import {LlmRequest} from './llm_request.js'; import {createLlmResponse, LlmResponse} from './llm_response.js'; @@ -53,6 +54,10 @@ export interface GeminiParams { * Headers to merge with internally crafted headers. */ headers?: Record; + /** + * Whether to use the Interactions API for stateful conversations. + */ + useInteractionsApi?: boolean; } /** @@ -64,6 +69,7 @@ export class Gemini extends BaseLlm { private readonly project?: string; private readonly location?: string; private readonly headers?: Record; + readonly useInteractionsApi: boolean; /** * @param params The parameters for creating a Gemini instance. @@ -75,6 +81,7 @@ export class Gemini extends BaseLlm { project, location, headers, + useInteractionsApi, }: GeminiParams) { if (!model) { model = 'gemini-2.5-flash'; @@ -99,6 +106,7 @@ export class Gemini extends BaseLlm { this.apiKey = params.apiKey; this.headers = headers; this.vertexai = !!params.vertexai; + this.useInteractionsApi = !!useInteractionsApi; } /** @@ -132,6 +140,10 @@ export class Gemini extends BaseLlm { stream = false, abortSignal?: AbortSignal, ): AsyncGenerator { + if (this.useInteractionsApi) { + yield* generateContentViaInteractions(this.apiClient, llmRequest, stream); + return; + } this.preprocessRequest(llmRequest); this.maybeAppendUserContent(llmRequest); logger.info( From ab2cf0b96e8c5461919c85c70dbcb0f1c3d006bb Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 20 May 2026 13:13:40 -0700 Subject: [PATCH 05/14] Test: Add unit tests for Interactions API and verify script --- .../interactions_request_processor_test.ts | 227 +++ core/test/models/interactions_utils_test.ts | 1771 +++++++++++++++++ verify_interactions.ts | 97 + 3 files changed, 2095 insertions(+) create mode 100644 core/test/agents/processors/interactions_request_processor_test.ts create mode 100644 core/test/models/interactions_utils_test.ts create mode 100644 verify_interactions.ts diff --git a/core/test/agents/processors/interactions_request_processor_test.ts b/core/test/agents/processors/interactions_request_processor_test.ts new file mode 100644 index 00000000..ba530bd6 --- /dev/null +++ b/core/test/agents/processors/interactions_request_processor_test.ts @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + BaseAgent, + BaseLlm, + Event, + EventActions, + Gemini, + INTERACTIONS_REQUEST_PROCESSOR, + InvocationContext, + LlmAgent, + LlmRequest, + PluginManager, + Session, +} from '@google/adk'; +import {describe, expect, it} from 'vitest'; + +class MockLlm extends BaseLlm { + constructor() { + super({model: 'mock-model'}); + } + + override async *generateContentAsync( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + llmRequest: LlmRequest, + ): AsyncGenerator {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + override async connect(llmRequest: LlmRequest): Promise { + return {} as any; + } +} + +function createMockEvent( + id: string, + author: string, + branch: string, + interactionId?: string, +): Event { + return { + id, + invocationId: 'test-invoc', + author, + branch, + interactionId, + actions: {} as EventActions, + timestamp: Date.now(), + }; +} + +function createMockInvocationContext( + events: Event[], + model: any, + agentName = 'test_agent', +): InvocationContext { + const session = { + id: 'test-session', + events, + appName: 'test-app', + userId: 'test-user', + } as unknown as Session; + + const agent = new LlmAgent({ + name: agentName, + model: model, + }); + + return new InvocationContext({ + invocationId: 'test-invocation', + agent: agent as BaseAgent, + session, + pluginManager: new PluginManager([]), + }); +} + +describe('InteractionsRequestProcessor', () => { + it('should not set previousInteractionId if model is not Gemini', async () => { + const rawEvents: Event[] = [ + createMockEvent('1', 'test_agent', 'main', 'int-1'), + ]; + const mockModel = new MockLlm(); + const invocationContext = createMockInvocationContext(rawEvents, mockModel); + const llmRequest: LlmRequest = { + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + }; + + for await (const _ of INTERACTIONS_REQUEST_PROCESSOR.runAsync( + invocationContext, + llmRequest, + )) { + // intentionally empty + } + + expect(llmRequest.previousInteractionId).toBeUndefined(); + }); + + it('should not set previousInteractionId if model is Gemini but useInteractionsApi is false', async () => { + const rawEvents: Event[] = [ + createMockEvent('1', 'test_agent', 'main', 'int-1'), + ]; + const geminiModel = new Gemini({ + model: 'gemini-2.5-flash', + apiKey: 'dummy', + useInteractionsApi: false, + }); + const invocationContext = createMockInvocationContext( + rawEvents, + geminiModel, + ); + const llmRequest: LlmRequest = { + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + }; + + for await (const _ of INTERACTIONS_REQUEST_PROCESSOR.runAsync( + invocationContext, + llmRequest, + )) { + // intentionally empty + } + + expect(llmRequest.previousInteractionId).toBeUndefined(); + }); + + it('should set previousInteractionId to latest interactionId from history if model is Gemini and useInteractionsApi is true', async () => { + const rawEvents: Event[] = [ + createMockEvent('1', 'test_agent', 'main', 'int-1'), + createMockEvent('2', 'test_agent', 'main', 'int-2'), + ]; + const geminiModel = new Gemini({ + model: 'gemini-2.5-flash', + apiKey: 'dummy', + useInteractionsApi: true, + }); + const invocationContext = createMockInvocationContext( + rawEvents, + geminiModel, + ); + invocationContext.branch = 'main'; + const llmRequest: LlmRequest = { + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + }; + + for await (const _ of INTERACTIONS_REQUEST_PROCESSOR.runAsync( + invocationContext, + llmRequest, + )) { + // intentionally empty + } + + expect(llmRequest.previousInteractionId).toBe('int-2'); + }); + + it('should ignore events from other branches', async () => { + const rawEvents: Event[] = [ + createMockEvent('1', 'test_agent', 'other-branch', 'int-1'), + createMockEvent('2', 'test_agent', 'main', 'int-2'), + ]; + const geminiModel = new Gemini({ + model: 'gemini-2.5-flash', + apiKey: 'dummy', + useInteractionsApi: true, + }); + const invocationContext = createMockInvocationContext( + rawEvents, + geminiModel, + ); + invocationContext.branch = 'main'; + + const llmRequest: LlmRequest = { + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + }; + + for await (const _ of INTERACTIONS_REQUEST_PROCESSOR.runAsync( + invocationContext, + llmRequest, + )) { + // intentionally empty + } + + expect(llmRequest.previousInteractionId).toBe('int-2'); + }); + + it('should ignore events from other authors', async () => { + const rawEvents: Event[] = [ + createMockEvent('1', 'other_agent', 'main', 'int-1'), + createMockEvent('2', 'test_agent', 'main', 'int-2'), + ]; + const geminiModel = new Gemini({ + model: 'gemini-2.5-flash', + apiKey: 'dummy', + useInteractionsApi: true, + }); + const invocationContext = createMockInvocationContext( + rawEvents, + geminiModel, + 'test_agent', + ); + invocationContext.branch = 'main'; + const llmRequest: LlmRequest = { + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + }; + + for await (const _ of INTERACTIONS_REQUEST_PROCESSOR.runAsync( + invocationContext, + llmRequest, + )) { + // intentionally empty + } + + expect(llmRequest.previousInteractionId).toBe('int-2'); + }); +}); diff --git a/core/test/models/interactions_utils_test.ts b/core/test/models/interactions_utils_test.ts new file mode 100644 index 00000000..bf6dbce6 --- /dev/null +++ b/core/test/models/interactions_utils_test.ts @@ -0,0 +1,1771 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + Content, + FinishReason, + FunctionCall, + FunctionResponse, + GenerateContentConfig, + Outcome, + Part, +} from '@google/genai'; +import {describe, expect, it, vi} from 'vitest'; +import { + convertContentToTurn, + convertInteractionEventToLlmResponse, + convertInteractionOutputToPart, + convertInteractionToLlmResponse, + convertPartToInteractionContent, + convertToolsConfigToInteractionsFormat, + extractSystemInstruction, + generateContentViaInteractions, + getLatestUserContents, +} from '../../src/models/interactions_utils.js'; + +describe('interactions_utils', () => { + describe('getLatestUserContents', () => { + it('should return empty array for empty input', () => { + expect(getLatestUserContents([])).toEqual([]); + }); + + it('should return only the latest continuous user messages', () => { + const contents: Content[] = [ + {role: 'user', parts: [{text: 'Hello'}]}, + {role: 'model', parts: [{text: 'Hi'}]}, + {role: 'user', parts: [{text: 'How are you?'}]}, + {role: 'user', parts: [{text: 'Today is sunny'}]}, + ]; + + const expected: Content[] = [ + {role: 'user', parts: [{text: 'How are you?'}]}, + {role: 'user', parts: [{text: 'Today is sunny'}]}, + ]; + + expect(getLatestUserContents(contents)).toEqual(expected); + }); + + it('should include preceding model function call when user content has function response', () => { + const contents: Content[] = [ + {role: 'user', parts: [{text: 'Call tool'}]}, + { + role: 'model', + parts: [ + { + functionCall: { + name: 'my_tool', + args: {arg1: 'val1'}, + id: 'call-1', + } as FunctionCall, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'my_tool', + response: {result: 'success'}, + id: 'call-1', + } as FunctionResponse, + }, + ], + }, + ]; + + const expected: Content[] = [ + contents[1], // model function call + contents[2], // user function response + ]; + + expect(getLatestUserContents(contents)).toEqual(expected); + }); + + it('should not include preceding turn if it is not a model turn with function call', () => { + const contents: Content[] = [ + {role: 'model', parts: [{text: 'some model text'}]}, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'my_tool', + response: {result: 'success'}, + id: 'call-1', + } as FunctionResponse, + }, + ], + }, + ]; + const expected: Content[] = [contents[1]]; + expect(getLatestUserContents(contents)).toEqual(expected); + }); + }); + + describe('convertPartToInteractionContent', () => { + it('should convert text part', () => { + const part: Part = {text: 'Hello'}; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'text', + text: 'Hello', + }); + }); + + it('should convert function call part', () => { + const part: Part = { + functionCall: { + name: 'test_tool', + args: {a: 1}, + id: 'call-123', + } as FunctionCall, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'function_call', + id: 'call-123', + name: 'test_tool', + arguments: {a: 1}, + }); + }); + + it('should convert function call part with missing id and args', () => { + const part: Part = { + functionCall: { + name: 'test_tool', + } as FunctionCall, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'function_call', + id: '', + name: 'test_tool', + arguments: {}, + }); + }); + + it('should convert function call part with thought signature', () => { + const part: Part = { + functionCall: { + name: 'test_tool', + args: {a: 1}, + id: 'call-123', + } as FunctionCall, + thoughtSignature: Buffer.from('sig-data'), + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'function_call', + id: 'call-123', + name: 'test_tool', + arguments: {a: 1}, + thought_signature: 'c2lnLWRhdGE=', + }); + }); + + it('should convert function response part', () => { + const part: Part = { + functionResponse: { + name: 'test_tool', + response: {result: 'ok'}, + id: 'call-123', + } as FunctionResponse, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'function_result', + name: 'test_tool', + call_id: 'call-123', + result: {result: 'ok'}, + }); + }); + + it('should convert function response part with missing name and id', () => { + const part: Part = { + functionResponse: { + response: {result: 'ok'}, + } as FunctionResponse, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'function_result', + name: '', + call_id: '', + result: {result: 'ok'}, + }); + }); + + it('should convert function response part with primitive response', () => { + const part: Part = { + functionResponse: { + name: 'test_tool', + response: true, + id: 'call-123', + } as FunctionResponse, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'function_result', + name: 'test_tool', + call_id: 'call-123', + result: 'true', + }); + }); + + it('should convert inline image data', () => { + const part: Part = { + inlineData: { + data: 'base64data', + mimeType: 'image/png', + }, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'image', + data: 'base64data', + mime_type: 'image/png', + }); + }); + + it('should convert file image data', () => { + const part: Part = { + fileData: { + fileUri: 'gs://bucket/img.png', + mimeType: 'image/png', + }, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'image', + uri: 'gs://bucket/img.png', + mime_type: 'image/png', + }); + }); + + it('should convert code execution result', () => { + const part: Part = { + codeExecutionResult: { + output: 'success output', + outcome: Outcome.OUTCOME_OK, + }, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'code_execution_result', + call_id: '', + result: 'success output', + is_error: false, + }); + }); + + it('should convert executable code', () => { + const part: Part = { + executableCode: { + code: 'print("hello")', + language: 'PYTHON', + }, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'code_execution_call', + id: '', + arguments: { + code: 'print("hello")', + language: 'PYTHON', + }, + }); + }); + + it('should convert thought part', () => { + const part: Part = { + thought: true, + thoughtSignature: Buffer.from('base64data'), + } as any; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'thought', + signature: 'YmFzZTY0ZGF0YQ==', + }); + }); + + it('should convert inline audio data', () => { + const part: Part = { + inlineData: { + data: 'audiodata', + mimeType: 'audio/mp3', + }, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'audio', + data: 'audiodata', + mime_type: 'audio/mp3', + }); + }); + + it('should convert inline video data', () => { + const part: Part = { + inlineData: { + data: 'videodata', + mimeType: 'video/mp4', + }, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'video', + data: 'videodata', + mime_type: 'video/mp4', + }); + }); + + it('should convert inline document data', () => { + const part: Part = { + inlineData: { + data: 'docdata', + mimeType: 'application/pdf', + }, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'document', + data: 'docdata', + mime_type: 'application/pdf', + }); + }); + + it('should convert file audio data', () => { + const part: Part = { + fileData: { + fileUri: 'gs://bucket/audio.mp3', + mimeType: 'audio/mp3', + }, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'audio', + uri: 'gs://bucket/audio.mp3', + mime_type: 'audio/mp3', + }); + }); + + it('should convert file video data', () => { + const part: Part = { + fileData: { + fileUri: 'gs://bucket/video.mp4', + mimeType: 'video/mp4', + }, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'video', + uri: 'gs://bucket/video.mp4', + mime_type: 'video/mp4', + }); + }); + + it('should convert file document data', () => { + const part: Part = { + fileData: { + fileUri: 'gs://bucket/doc.pdf', + mimeType: 'application/pdf', + }, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'document', + uri: 'gs://bucket/doc.pdf', + mime_type: 'application/pdf', + }); + }); + + it('should convert function call part with string thought signature', () => { + const part: Part = { + functionCall: { + name: 'test_tool', + args: {a: 1}, + id: 'call-123', + } as FunctionCall, + thoughtSignature: 'sig-data-string' as any, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'function_call', + id: 'call-123', + name: 'test_tool', + arguments: {a: 1}, + thought_signature: 'c2lnLWRhdGEtc3RyaW5n', + }); + }); + + it('should convert function call part with thought signature in browser environment', () => { + const originalWindow = global.window; + (global as any).window = { + btoa: (str: string) => Buffer.from(str, 'binary').toString('base64'), + }; + + const part: Part = { + functionCall: { + name: 'test_tool', + args: {a: 1}, + id: 'call-123', + } as FunctionCall, + thoughtSignature: new TextEncoder().encode('sig-data-browser') as any, + }; + + const result = convertPartToInteractionContent(part); + expect(result?.thought_signature).toBe('c2lnLWRhdGEtYnJvd3Nlcg=='); + + (global as any).window = originalWindow; + }); + + it('should convert inlineData with missing mimeType to document', () => { + const part: Part = { + inlineData: { + data: 'docdata', + }, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'document', + data: 'docdata', + mime_type: '', + }); + }); + + it('should convert fileData with missing mimeType to document', () => { + const part: Part = { + fileData: { + fileUri: 'gs://bucket/doc.pdf', + }, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'document', + uri: 'gs://bucket/doc.pdf', + mime_type: '', + }); + }); + + it('should convert codeExecutionResult with missing output', () => { + const part: Part = { + codeExecutionResult: { + outcome: Outcome.OUTCOME_OK, + }, + }; + expect(convertPartToInteractionContent(part)).toEqual({ + type: 'code_execution_result', + call_id: '', + result: '', + is_error: false, + }); + }); + + it('should return null for empty or invalid part', () => { + expect(convertPartToInteractionContent({})).toBeNull(); + }); + }); + + describe('convertToolsConfigToInteractionsFormat', () => { + it('should convert function declarations and built-in tools', () => { + const config = { + tools: [ + { + functionDeclarations: [ + { + name: 'tool1', + description: 'desc1', + parameters: { + type: 'OBJECT', + properties: { + param1: {type: 'STRING'}, + }, + required: ['param1'], + }, + }, + ], + }, + {googleSearch: {}}, + {codeExecution: {}}, + ], + }; + + const expected = [ + { + type: 'function', + name: 'tool1', + description: 'desc1', + parameters: { + type: 'object', + properties: { + param1: {type: 'STRING'}, + }, + required: ['param1'], + }, + }, + {type: 'google_search'}, + {type: 'code_execution'}, + ]; + + expect(convertToolsConfigToInteractionsFormat(config as any)).toEqual( + expected, + ); + }); + + it('should convert function declarations with parametersJsonSchema and urlContext', () => { + const config = { + tools: [ + { + functionDeclarations: [ + { + name: 'tool2', + parametersJsonSchema: { + type: 'object', + properties: { + param2: {type: 'string'}, + }, + }, + }, + ], + }, + {urlContext: {}}, + ], + }; + + const expected = [ + { + type: 'function', + name: 'tool2', + parameters: { + type: 'object', + properties: { + param2: {type: 'string'}, + }, + }, + }, + {type: 'url_context'}, + ]; + + expect(convertToolsConfigToInteractionsFormat(config as any)).toEqual( + expected, + ); + }); + }); + + describe('convertInteractionToLlmResponse', () => { + it('should convert successful interaction response', () => { + const interaction = { + id: 'int-123', + status: 'completed', + outputs: [{type: 'text', text: 'Response text'}], + usage: { + total_input_tokens: 10, + total_output_tokens: 20, + }, + }; + + const response = convertInteractionToLlmResponse(interaction); + + expect(response.interactionId).toBe('int-123'); + expect(response.turnComplete).toBe(true); + expect(response.content?.role).toBe('model'); + expect(response.content?.parts?.[0]?.text).toBe('Response text'); + expect(response.usageMetadata).toEqual({ + promptTokenCount: 10, + candidatesTokenCount: 20, + totalTokenCount: 30, + }); + expect(response.finishReason).toBe('STOP'); + }); + + it('should convert failed interaction response', () => { + const interaction = { + id: 'int-123', + status: 'failed', + error: { + code: 'RESOURCE_EXHAUSTED', + message: 'Quota exceeded', + }, + }; + + const response = convertInteractionToLlmResponse(interaction); + + expect(response.interactionId).toBe('int-123'); + expect(response.errorCode).toBe('RESOURCE_EXHAUSTED'); + expect(response.errorMessage).toBe('Quota exceeded'); + }); + + it('should convert failed interaction response with missing error details', () => { + const interaction = { + id: 'int-123', + status: 'failed', + error: {}, + }; + const response = convertInteractionToLlmResponse(interaction); + expect(response.errorCode).toBe('UNKNOWN_ERROR'); + expect(response.errorMessage).toBe('Unknown error'); + }); + + it('should handle missing token counts in usage', () => { + const interaction = { + id: 'int-123', + status: 'completed', + usage: {}, + }; + const response = convertInteractionToLlmResponse(interaction); + expect(response.usageMetadata).toEqual({ + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0, + }); + }); + + it('should handle requires_action status', () => { + const interaction = { + id: 'int-123', + status: 'requires_action', + }; + const response = convertInteractionToLlmResponse(interaction); + expect(response.turnComplete).toBe(true); + expect(response.finishReason).toBe('STOP'); + }); + }); + + describe('convertInteractionEventToLlmResponse', () => { + it('should handle content.delta text event', () => { + const event = { + event_type: 'content.delta', + delta: { + type: 'text', + text: 'hello', + }, + }; + const aggregatedParts: Part[] = []; + const response = convertInteractionEventToLlmResponse( + event, + aggregatedParts, + 'int-1', + ); + + expect(response).toEqual({ + content: {role: 'model', parts: [{text: 'hello'}]}, + partial: true, + turnComplete: false, + interactionId: 'int-1', + }); + expect(aggregatedParts).toEqual([{text: 'hello'}]); + }); + + it('should accumulate function call delta without yielding immediately', () => { + const event = { + event_type: 'content.delta', + delta: { + type: 'function_call', + name: 'my_tool', + arguments: {x: 1}, + id: 'call-1', + }, + }; + const aggregatedParts: Part[] = []; + const response = convertInteractionEventToLlmResponse( + event, + aggregatedParts, + 'int-1', + ); + + expect(response).toBeNull(); + expect(aggregatedParts).toEqual([ + { + functionCall: { + id: 'call-1', + name: 'my_tool', + args: {x: 1}, + } as FunctionCall, + thoughtSignature: undefined, + }, + ]); + }); + + it('should handle content.delta function_call with missing delta id', () => { + const event = { + event_type: 'content.delta', + delta: { + type: 'function_call', + name: 'my_tool', + }, + }; + const aggregatedParts: Part[] = []; + convertInteractionEventToLlmResponse(event, aggregatedParts, 'int-1'); + expect(aggregatedParts[0].functionCall?.id).toBe(''); + }); + + it('should handle content.stop event and return aggregated parts', () => { + const event = {event_type: 'content.stop'}; + const aggregatedParts: Part[] = [{text: 'hello '}, {text: 'world'}]; + const response = convertInteractionEventToLlmResponse( + event, + aggregatedParts, + 'int-1', + ); + + expect(response).toEqual({ + content: {role: 'model', parts: [{text: 'hello '}, {text: 'world'}]}, + partial: false, + turnComplete: false, + interactionId: 'int-1', + }); + }); + + it('should handle interaction.status_update completed event', () => { + const event = { + event_type: 'interaction.status_update', + status: 'completed', + }; + const aggregatedParts: Part[] = [{text: 'final text'}]; + const response = convertInteractionEventToLlmResponse( + event, + aggregatedParts, + 'int-1', + ); + + expect(response).toEqual({ + content: {role: 'model', parts: [{text: 'final text'}]}, + partial: false, + turnComplete: true, + finishReason: 'STOP' as FinishReason, + interactionId: 'int-1', + }); + }); + }); + + describe('generateContentViaInteractions', () => { + it('should handle non-streaming call', async () => { + const mockInteraction = { + id: 'int-999', + status: 'completed', + outputs: [{type: 'text', text: 'Mocked static response'}], + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockInteraction), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello'}]}], + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + false, + ); + const responses = []; + for await (const res of generator) { + responses.push(res); + } + + expect(responses.length).toBe(1); + expect(responses[0].content?.parts?.[0]?.text).toBe( + 'Mocked static response', + ); + expect(responses[0].interactionId).toBe('int-999'); + + expect(mockApiClient.interactions.create).toHaveBeenCalledWith({ + model: 'gemini-2.5-flash', + input: [ + { + role: 'user', + content: [{type: 'text', text: 'Hello'}], + }, + ], + stream: false, + systemInstruction: undefined, + tools: undefined, + generationConfig: undefined, + previousInteractionId: undefined, + }); + }); + + it('should handle streaming call', async () => { + const mockEvents = [ + { + event_type: 'content.delta', + delta: {type: 'text', text: 'Part 1'}, + interaction_id: 'int-stream', + }, + { + event_type: 'content.delta', + delta: {type: 'text', text: 'Part 2'}, + }, + { + event_type: 'interaction.status_update', + status: 'completed', + }, + ]; + + // Create an async iterable mock + const mockStream = { + [Symbol.asyncIterator]: async function* () { + for (const event of mockEvents) { + yield event; + } + }, + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockStream), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello stream'}]}], + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + true, + ); + const responses = []; + for await (const res of generator) { + responses.push(res); + } + + // We expect: + // 1. Response for Part 1 delta (partial: true) + // 2. Response for Part 2 delta (partial: true) + // 3. Response for status_update completed (turnComplete: true) + // 4. Final aggregated response yielded at the end of generator + expect(responses.length).toBe(4); + + expect(responses[0]).toEqual({ + content: {role: 'model', parts: [{text: 'Part 1'}]}, + partial: true, + turnComplete: false, + interactionId: 'int-stream', + }); + + expect(responses[1]).toEqual({ + content: {role: 'model', parts: [{text: 'Part 2'}]}, + partial: true, + turnComplete: false, + interactionId: 'int-stream', + }); + + expect(responses[2]).toEqual({ + content: {role: 'model', parts: [{text: 'Part 1'}, {text: 'Part 2'}]}, + partial: false, + turnComplete: true, + finishReason: 'STOP', + interactionId: 'int-stream', + }); + + expect(responses[3]).toEqual({ + content: {role: 'model', parts: [{text: 'Part 1'}, {text: 'Part 2'}]}, + partial: false, + turnComplete: true, + finishReason: 'STOP', + interactionId: 'int-stream', + }); + }); + + it('should trim history when previousInteractionId is present', async () => { + const mockInteraction = { + id: 'int-999', + status: 'completed', + outputs: [{type: 'text', text: 'Mocked response'}], + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockInteraction), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [ + {role: 'user', parts: [{text: 'Turn 1'}]}, + {role: 'model', parts: [{text: 'Reply 1'}]}, + {role: 'user', parts: [{text: 'Turn 2'}]}, + ], + previousInteractionId: 'int-prev', + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + false, + ); + const responses = []; + for await (const res of generator) { + responses.push(res); + } + + expect(responses.length).toBe(1); + expect(mockApiClient.interactions.create).toHaveBeenCalledWith({ + model: 'gemini-2.5-flash', + input: [ + { + role: 'user', + content: [{type: 'text', text: 'Turn 2'}], + }, + ], + stream: false, + systemInstruction: undefined, + tools: undefined, + generationConfig: undefined, + previousInteractionId: 'int-prev', + }); + }); + + it('should handle streaming call with interaction event and extract interaction ID', async () => { + const mockEvents = [ + { + event_type: 'content.delta', + delta: {type: 'text', text: 'Stream text'}, + }, + { + event_type: 'interaction', + id: 'int-from-event', + status: 'completed', + outputs: [{type: 'text', text: 'Stream text'}], + }, + ]; + + const mockStream = { + [Symbol.asyncIterator]: async function* () { + for (const event of mockEvents) { + yield event; + } + }, + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockStream), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello'}]}], + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + true, + ); + const responses = []; + for await (const res of generator) { + responses.push(res); + } + + expect(responses.length).toBe(3); + + expect(responses[0]).toEqual({ + content: {role: 'model', parts: [{text: 'Stream text'}]}, + partial: true, + turnComplete: false, + interactionId: undefined, + }); + + expect(responses[1].interactionId).toBe('int-from-event'); + expect(responses[1].turnComplete).toBe(true); + + expect(responses[2].interactionId).toBe('int-from-event'); + }); + + it('should pass all generation config parameters', async () => { + const mockInteraction = { + id: 'int-999', + status: 'completed', + outputs: [{type: 'text', text: 'Mocked response'}], + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockInteraction), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello'}]}], + config: { + temperature: 0.7, + topP: 0.9, + topK: 40, + maxOutputTokens: 100, + stopSequences: ['STOP'], + presencePenalty: 0.5, + frequencyPenalty: 0.5, + tools: [{functionDeclarations: [{name: 'my_tool'}]}], + } as GenerateContentConfig, + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + false, + ); + for await (const _ of generator) { + // empty + } + + expect(mockApiClient.interactions.create).toHaveBeenCalledWith({ + model: 'gemini-2.5-flash', + input: [ + { + role: 'user', + content: [{type: 'text', text: 'Hello'}], + }, + ], + stream: false, + systemInstruction: undefined, + tools: [ + { + type: 'function', + name: 'my_tool', + }, + ], + generationConfig: { + temperature: 0.7, + top_p: 0.9, + top_k: 40, + max_output_tokens: 100, + stop_sequences: ['STOP'], + presence_penalty: 0.5, + frequency_penalty: 0.5, + }, + previousInteractionId: undefined, + }); + }); + + it('should pass tools in streaming call', async () => { + const mockStream = { + [Symbol.asyncIterator]: async function* () { + yield { + event_type: 'content.delta', + delta: {type: 'text', text: 'Reply'}, + }; + }, + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockStream), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello'}]}], + config: { + tools: [{functionDeclarations: [{name: 'my_tool'}]}], + temperature: 0.5, + } as GenerateContentConfig, + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + true, + ); + for await (const _ of generator) { + // empty + } + + expect(mockApiClient.interactions.create).toHaveBeenCalledWith({ + model: 'gemini-2.5-flash', + input: [ + { + role: 'user', + content: [{type: 'text', text: 'Hello'}], + }, + ], + stream: true, + systemInstruction: undefined, + tools: [ + { + type: 'function', + name: 'my_tool', + }, + ], + generationConfig: { + temperature: 0.5, + }, + previousInteractionId: undefined, + }); + }); + }); + + describe('convertInteractionOutputToPart', () => { + it('should return null for empty or invalid output', () => { + expect(convertInteractionOutputToPart(null)).toBeNull(); + expect(convertInteractionOutputToPart({})).toBeNull(); + expect(convertInteractionOutputToPart({type: 'invalid'})).toBeNull(); + }); + + it('should convert text output', () => { + expect( + convertInteractionOutputToPart({type: 'text', text: 'hello'}), + ).toEqual({ + text: 'hello', + }); + }); + + it('should convert text output with missing text', () => { + expect(convertInteractionOutputToPart({type: 'text'})).toEqual({ + text: '', + }); + }); + + it('should convert function_call output', () => { + const output = { + type: 'function_call', + id: 'call-1', + name: 'my_tool', + arguments: {a: 1}, + }; + expect(convertInteractionOutputToPart(output)).toEqual({ + functionCall: { + id: 'call-1', + name: 'my_tool', + args: {a: 1}, + }, + thoughtSignature: undefined, + }); + }); + + it('should convert function_call output with missing arguments', () => { + const output = { + type: 'function_call', + id: 'call-1', + name: 'my_tool', + }; + expect(convertInteractionOutputToPart(output)).toEqual({ + functionCall: { + id: 'call-1', + name: 'my_tool', + args: {}, + }, + thoughtSignature: undefined, + }); + }); + + it('should convert function_call output with non-string thought_signature', () => { + const output = { + type: 'function_call', + id: 'call-1', + name: 'my_tool', + thought_signature: 123 as any, + }; + const part = convertInteractionOutputToPart(output); + expect(part?.thoughtSignature).toBeUndefined(); + }); + + it('should convert function_call output with thought_signature in browser environment', () => { + const originalWindow = global.window; + (global as any).window = { + atob: (str: string) => Buffer.from(str, 'base64').toString('binary'), + }; + + const output = { + type: 'function_call', + id: 'call-1', + name: 'my_tool', + arguments: {a: 1}, + thought_signature: 'YmFzZTY0ZGF0YQ==', + }; + + const part = convertInteractionOutputToPart(output); + expect(part?.thoughtSignature).toBeInstanceOf(Uint8Array); + expect(Buffer.from(part?.thoughtSignature as any).toString()).toBe( + 'base64data', + ); + + (global as any).window = originalWindow; + }); + + it('should convert function_call output with thought_signature in Node.js environment', () => { + const output = { + type: 'function_call', + id: 'call-1', + name: 'my_tool', + arguments: {a: 1}, + thought_signature: 'YmFzZTY0ZGF0YQ==', + }; + const part = convertInteractionOutputToPart(output); + expect(part?.thoughtSignature).toBeInstanceOf(Buffer); + expect(part?.thoughtSignature?.toString()).toBe('base64data'); + }); + + it('should convert function_result output', () => { + const output = { + type: 'function_result', + call_id: 'call-1', + result: {res: 'ok'}, + }; + expect(convertInteractionOutputToPart(output)).toEqual({ + functionResponse: { + id: 'call-1', + response: {res: 'ok'}, + }, + }); + }); + + it('should convert image output (data)', () => { + const output = { + type: 'image', + data: 'base64data', + mime_type: 'image/png', + }; + expect(convertInteractionOutputToPart(output)).toEqual({ + inlineData: { + data: 'base64data', + mimeType: 'image/png', + }, + }); + }); + + it('should convert image output (uri)', () => { + const output = { + type: 'image', + uri: 'gs://bucket/img.png', + mime_type: 'image/png', + }; + expect(convertInteractionOutputToPart(output)).toEqual({ + fileData: { + fileUri: 'gs://bucket/img.png', + mimeType: 'image/png', + }, + }); + }); + + it('should convert audio output (data)', () => { + const output = { + type: 'audio', + data: 'base64data', + mime_type: 'audio/mp3', + }; + expect(convertInteractionOutputToPart(output)).toEqual({ + inlineData: { + data: 'base64data', + mimeType: 'audio/mp3', + }, + }); + }); + + it('should convert audio output (uri)', () => { + const output = { + type: 'audio', + uri: 'gs://bucket/audio.mp3', + mime_type: 'audio/mp3', + }; + expect(convertInteractionOutputToPart(output)).toEqual({ + fileData: { + fileUri: 'gs://bucket/audio.mp3', + mimeType: 'audio/mp3', + }, + }); + }); + + it('should return null for thought output', () => { + expect(convertInteractionOutputToPart({type: 'thought'})).toBeNull(); + }); + + it('should convert code_execution_result output', () => { + const output = { + type: 'code_execution_result', + result: 'output text', + is_error: false, + }; + expect(convertInteractionOutputToPart(output)).toEqual({ + codeExecutionResult: { + output: 'output text', + outcome: Outcome.OUTCOME_OK, + }, + }); + + const outputError = { + type: 'code_execution_result', + result: 'error text', + is_error: true, + }; + expect(convertInteractionOutputToPart(outputError)).toEqual({ + codeExecutionResult: { + output: 'error text', + outcome: Outcome.OUTCOME_FAILED, + }, + }); + }); + + it('should convert code_execution_result output with missing result', () => { + const output = { + type: 'code_execution_result', + is_error: false, + }; + expect(convertInteractionOutputToPart(output)).toEqual({ + codeExecutionResult: { + output: '', + outcome: Outcome.OUTCOME_OK, + }, + }); + }); + + it('should convert code_execution_call output', () => { + const output = { + type: 'code_execution_call', + arguments: { + code: 'print(1)', + language: 'PYTHON', + }, + }; + expect(convertInteractionOutputToPart(output)).toEqual({ + executableCode: { + code: 'print(1)', + language: 'PYTHON', + }, + }); + }); + + it('should convert code_execution_call output with missing arguments', () => { + const output = { + type: 'code_execution_call', + }; + expect(convertInteractionOutputToPart(output)).toEqual({ + executableCode: { + code: '', + language: 'PYTHON', + }, + }); + }); + + it('should convert google_search_result output', () => { + const output = { + type: 'google_search_result', + result: [{title: 'res1', url: 'url1'}, 'plain text result'], + }; + expect(convertInteractionOutputToPart(output)).toEqual({ + text: '{"title":"res1","url":"url1"}\nplain text result', + }); + }); + }); + + describe('convertInteractionEventToLlmResponse extra cases', () => { + it('should handle content.delta image event (data)', () => { + const event = { + event_type: 'content.delta', + delta: { + type: 'image', + data: 'imgdata', + mime_type: 'image/png', + }, + }; + const aggregatedParts: Part[] = []; + const response = convertInteractionEventToLlmResponse( + event, + aggregatedParts, + 'int-1', + ); + expect(response).toEqual({ + content: { + role: 'model', + parts: [ + { + inlineData: { + data: 'imgdata', + mimeType: 'image/png', + }, + }, + ], + }, + partial: false, + turnComplete: false, + interactionId: 'int-1', + }); + }); + + it('should handle content.delta image event (uri)', () => { + const event = { + event_type: 'content.delta', + delta: { + type: 'image', + uri: 'gs://img.png', + mime_type: 'image/png', + }, + }; + const aggregatedParts: Part[] = []; + const response = convertInteractionEventToLlmResponse( + event, + aggregatedParts, + 'int-1', + ); + expect(response).toEqual({ + content: { + role: 'model', + parts: [ + { + fileData: { + fileUri: 'gs://img.png', + mimeType: 'image/png', + }, + }, + ], + }, + partial: false, + turnComplete: false, + interactionId: 'int-1', + }); + }); + + it('should handle interaction.status_update failed event', () => { + const event = { + event_type: 'interaction.status_update', + status: 'failed', + error: { + code: 'CANCELLED', + message: 'user cancelled', + }, + }; + const response = convertInteractionEventToLlmResponse(event, [], 'int-1'); + expect(response).toEqual({ + errorCode: 'CANCELLED', + errorMessage: 'user cancelled', + turnComplete: true, + interactionId: 'int-1', + }); + }); + + it('should handle interaction.status_update failed event with missing error', () => { + const event = { + event_type: 'interaction.status_update', + status: 'failed', + }; + const response = convertInteractionEventToLlmResponse(event, [], 'int-1'); + expect(response).toEqual({ + errorCode: 'UNKNOWN_ERROR', + errorMessage: 'Unknown error', + turnComplete: true, + interactionId: 'int-1', + }); + }); + + it('should handle interaction.status_update completed event with aggregated parts', () => { + const event = { + event_type: 'interaction.status_update', + status: 'completed', + }; + const parts = [{text: 'part 1'}]; + const response = convertInteractionEventToLlmResponse( + event, + parts, + 'int-1', + ); + expect(response).toEqual({ + content: {role: 'model', parts: [{text: 'part 1'}]}, + partial: false, + turnComplete: true, + finishReason: 'STOP', + interactionId: 'int-1', + }); + }); + + it('should handle error event', () => { + const event = { + event_type: 'error', + code: 'INTERNAL', + message: 'internal error', + }; + const response = convertInteractionEventToLlmResponse(event, [], 'int-1'); + expect(response).toEqual({ + errorCode: 'INTERNAL', + errorMessage: 'internal error', + turnComplete: true, + interactionId: 'int-1', + }); + }); + + it('should handle error event with missing code and message', () => { + const event = { + event_type: 'error', + }; + const response = convertInteractionEventToLlmResponse(event, [], 'int-1'); + expect(response).toEqual({ + errorCode: 'UNKNOWN_ERROR', + errorMessage: 'Unknown error', + turnComplete: true, + interactionId: 'int-1', + }); + }); + + it('should return null if event.delta is missing in content.delta event', () => { + const event = { + event_type: 'content.delta', + }; + expect(convertInteractionEventToLlmResponse(event, [])).toBeNull(); + }); + + it('should handle content.delta function_call with thought_signature in browser environment', () => { + const originalWindow = global.window; + (global as any).window = { + atob: (str: string) => Buffer.from(str, 'base64').toString('binary'), + }; + + const event = { + event_type: 'content.delta', + delta: { + type: 'function_call', + name: 'my_tool', + thought_signature: 'YmFzZTY0ZGF0YQ==', + id: 'call-1', + }, + }; + const aggregatedParts: Part[] = []; + const response = convertInteractionEventToLlmResponse( + event, + aggregatedParts, + 'int-1', + ); + + expect(response).toBeNull(); + expect(aggregatedParts[0].thoughtSignature).toBeDefined(); + expect(aggregatedParts[0].thoughtSignature).toBeInstanceOf(Uint8Array); + expect( + Buffer.from(aggregatedParts[0].thoughtSignature as any).toString(), + ).toBe('base64data'); + + (global as any).window = originalWindow; + }); + + it('should handle content.delta function_call with thought_signature in Node.js environment', () => { + const event = { + event_type: 'content.delta', + delta: { + type: 'function_call', + name: 'my_tool', + thought_signature: 'YmFzZTY0ZGF0YQ==', + id: 'call-1', + }, + }; + const aggregatedParts: Part[] = []; + const response = convertInteractionEventToLlmResponse( + event, + aggregatedParts, + 'int-1', + ); + + expect(response).toBeNull(); + expect(aggregatedParts[0].thoughtSignature).toBeInstanceOf(Buffer); + expect(aggregatedParts[0].thoughtSignature?.toString()).toBe( + 'base64data', + ); + }); + + it('should handle event with camelCase eventType', () => { + const event = { + eventType: 'content.delta', + delta: { + type: 'text', + text: 'camelText', + }, + }; + const aggregatedParts: Part[] = []; + const response = convertInteractionEventToLlmResponse( + event, + aggregatedParts, + 'int-1', + ); + expect(response?.content?.parts?.[0]?.text).toBe('camelText'); + }); + + it('should handle content.delta text event with missing text', () => { + const event = { + event_type: 'content.delta', + delta: { + type: 'text', + }, + }; + const aggregatedParts: Part[] = []; + const response = convertInteractionEventToLlmResponse( + event, + aggregatedParts, + 'int-1', + ); + expect(response).toBeNull(); + }); + + it('should handle interaction event type', () => { + const event = { + event_type: 'interaction', + id: 'int-123', + status: 'completed', + outputs: [{type: 'text', text: 'final'}], + }; + const response = convertInteractionEventToLlmResponse(event, [], 'int-1'); + expect(response).toEqual({ + content: {role: 'model', parts: [{text: 'final'}]}, + turnComplete: true, + finishReason: 'STOP', + interactionId: 'int-123', + usageMetadata: undefined, + }); + }); + + it('should handle interaction.status_update requires_action event', () => { + const event = { + event_type: 'interaction.status_update', + status: 'requires_action', + }; + const response = convertInteractionEventToLlmResponse(event, [], 'int-1'); + expect(response).toEqual({ + content: undefined, + partial: false, + turnComplete: true, + finishReason: 'STOP', + interactionId: 'int-1', + }); + }); + + it('should return null for unknown event type', () => { + const event = {event_type: 'unknown'}; + expect(convertInteractionEventToLlmResponse(event, [])).toBeNull(); + }); + }); + + describe('convertContentToTurn', () => { + it('should convert Content to Turn with default role', () => { + const content: Content = { + parts: [{text: 'Hello'}], + }; + expect(convertContentToTurn(content)).toEqual({ + role: 'user', + content: [{type: 'text', text: 'Hello'}], + }); + }); + }); + + describe('extractSystemInstruction', () => { + it('should return undefined if no systemInstruction', () => { + expect(extractSystemInstruction({})).toBeUndefined(); + }); + + it('should return string instruction directly', () => { + expect(extractSystemInstruction({systemInstruction: 'be helpful'})).toBe( + 'be helpful', + ); + }); + + it('should extract text from Content systemInstruction', () => { + const config = { + systemInstruction: { + role: 'system', + parts: [{text: 'line 1'}, {text: 'line 2'}], + } as Content, + }; + expect(extractSystemInstruction(config)).toBe('line 1\nline 2'); + }); + + it('should return undefined if systemInstruction is object but has no parts', () => { + expect( + extractSystemInstruction({systemInstruction: {} as any}), + ).toBeUndefined(); + }); + + it('should return undefined if Content systemInstruction parts have no text', () => { + const config = { + systemInstruction: { + role: 'system', + parts: [{}], + } as Content, + }; + expect(extractSystemInstruction(config)).toBeUndefined(); + }); + }); + + describe('generateContentViaInteractions extra streaming cases', () => { + it('should handle streaming call with interaction.start event and extract interaction ID from interaction object', async () => { + const mockEvents = [ + { + event_type: 'interaction.start', + interaction: {id: 'int-start-id'}, + }, + { + event_type: 'content.delta', + delta: {type: 'text', text: 'Stream text'}, + }, + { + event_type: 'interaction.status_update', + status: 'completed', + }, + ]; + + const mockStream = { + [Symbol.asyncIterator]: async function* () { + for (const event of mockEvents) { + yield event; + } + }, + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockStream), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello'}]}], + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + true, + ); + const responses = []; + for await (const res of generator) { + responses.push(res); + } + + expect(responses.length).toBe(3); + + expect(responses[0]).toEqual({ + content: {role: 'model', parts: [{text: 'Stream text'}]}, + partial: true, + turnComplete: false, + interactionId: 'int-start-id', + }); + + expect(responses[1].interactionId).toBe('int-start-id'); + expect(responses[1].turnComplete).toBe(true); + }); + + it('should extract interaction ID from interactionId (camelCase) in streaming event', async () => { + const mockEvents = [ + { + event_type: 'content.delta', + delta: {type: 'text', text: 'Reply'}, + interactionId: 'int-camel-case', + }, + ]; + const mockStream = { + [Symbol.asyncIterator]: async function* () { + yield mockEvents[0]; + }, + }; + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockStream), + }, + }; + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello'}]}], + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + true, + ); + const responses = []; + for await (const res of generator) { + responses.push(res); + } + expect(responses[0].interactionId).toBe('int-camel-case'); + }); + }); +}); diff --git a/verify_interactions.ts b/verify_interactions.ts new file mode 100644 index 00000000..8338071d --- /dev/null +++ b/verify_interactions.ts @@ -0,0 +1,97 @@ +import {Gemini} from './core/src/models/google_llm.js'; +import {LlmRequest} from './core/src/models/llm_request.js'; +import {LlmResponse} from './core/src/models/llm_response.js'; + +async function main() { + const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_GENAI_API_KEY; + if (!apiKey) { + console.error( + 'ERROR: GEMINI_API_KEY or GOOGLE_GENAI_API_KEY is not set in environment.', + ); + console.log( + 'Please run this script with: GEMINI_API_KEY=your_key npx tsx verify_interactions.ts', + ); + process.exit(1); + } + + console.log('Initializing Gemini model with useInteractionsApi: true...'); + const model = new Gemini({ + model: 'gemini-2.5-flash', + apiKey: apiKey, + useInteractionsApi: true, + }); + + // Turn 1 + const req1: LlmRequest = { + model: 'gemini-2.5-flash', + contents: [ + { + role: 'user', + parts: [{text: 'My favorite color is deep blue. Remember this.'}], + }, + ], + }; + + console.log('\n--- Turn 1 Request ---'); + console.log(JSON.stringify(req1, null, 2)); + + console.log('Sending Turn 1...'); + const res1List: LlmResponse[] = []; + for await (const chunk of model.generateContentAsync(req1)) { + res1List.push(chunk); + process.stdout.write(chunk.content?.parts?.[0]?.text || ''); + } + console.log('\n'); + + const finalRes1 = res1List[res1List.length - 1]; + console.log('--- Turn 1 Final Response ---'); + console.log(JSON.stringify(finalRes1, null, 2)); + + const interactionId = finalRes1.interactionId; + if (!interactionId) { + console.error('ERROR: No interactionId returned in Turn 1 response!'); + process.exit(1); + } + console.log(`SUCCESS: Got interactionId: ${interactionId}`); + + // Turn 2 + const req2: LlmRequest = { + model: 'gemini-2.5-flash', + contents: [ + { + role: 'user', + parts: [{text: 'What is my favorite color?'}], + }, + ], + previousInteractionId: interactionId, + }; + + console.log('\n--- Turn 2 Request ---'); + console.log(JSON.stringify(req2, null, 2)); + + console.log('Sending Turn 2 (should recall Turn 1)...'); + const res2List: LlmResponse[] = []; + for await (const chunk of model.generateContentAsync(req2)) { + res2List.push(chunk); + process.stdout.write(chunk.content?.parts?.[0]?.text || ''); + } + console.log('\n'); + + const finalRes2 = res2List[res2List.length - 1]; + console.log('--- Turn 2 Final Response ---'); + console.log(JSON.stringify(finalRes2, null, 2)); + + if (finalRes2.content?.parts?.[0]?.text?.toLowerCase().includes('blue')) { + console.log( + '\n🎉 SUCCESS: The model correctly recalled the favorite color from Turn 1 state!', + ); + } else { + console.log( + '\n❌ FAILURE: The model did not seem to recall the favorite color.', + ); + } +} + +main().catch((err) => { + console.error('Unhandled error during verification:', err); +}); From 3c044e8cef4d4d3cb9ff7a8140e9c8a7aa39dbb9 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 20 May 2026 13:18:18 -0700 Subject: [PATCH 06/14] Test: Add unit test edge cases for 100% statement and branch coverage --- .../interactions_request_processor_test.ts | 25 ++++++++++++ core/test/models/interactions_utils_test.ts | 40 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/core/test/agents/processors/interactions_request_processor_test.ts b/core/test/agents/processors/interactions_request_processor_test.ts index ba530bd6..e630fcb9 100644 --- a/core/test/agents/processors/interactions_request_processor_test.ts +++ b/core/test/agents/processors/interactions_request_processor_test.ts @@ -224,4 +224,29 @@ describe('InteractionsRequestProcessor', () => { expect(llmRequest.previousInteractionId).toBe('int-2'); }); + + it('should do nothing if agent is not LlmAgent', async () => { + const rawEvents: Event[] = [ + createMockEvent('1', 'test_agent', 'main', 'int-1'), + ]; + const invocationContext = createMockInvocationContext( + rawEvents, + new MockLlm(), + ); + (invocationContext as any).agent = {name: 'not-an-llm-agent'}; + const llmRequest: LlmRequest = { + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + }; + + for await (const _ of INTERACTIONS_REQUEST_PROCESSOR.runAsync( + invocationContext, + llmRequest, + )) { + // intentionally empty + } + + expect(llmRequest.previousInteractionId).toBeUndefined(); + }); }); diff --git a/core/test/models/interactions_utils_test.ts b/core/test/models/interactions_utils_test.ts index bf6dbce6..c6f4316f 100644 --- a/core/test/models/interactions_utils_test.ts +++ b/core/test/models/interactions_utils_test.ts @@ -496,6 +496,46 @@ describe('interactions_utils', () => { ); }); + it('should convert function declarations without required parameters', () => { + const config = { + tools: [ + { + functionDeclarations: [ + { + name: 'tool1_no_req', + description: 'desc_no_req', + parameters: { + type: 'OBJECT', + properties: { + param1: {type: 'STRING'}, + }, + }, + }, + ], + }, + ], + }; + + const expected = [ + { + type: 'function', + name: 'tool1_no_req', + description: 'desc_no_req', + parameters: { + type: 'object', + properties: { + param1: {type: 'STRING'}, + }, + required: undefined, + }, + }, + ]; + + expect(convertToolsConfigToInteractionsFormat(config as any)).toEqual( + expected, + ); + }); + it('should convert function declarations with parametersJsonSchema and urlContext', () => { const config = { tools: [ From 8aa7b818cb3e1dbd457d7ecdc66e64b3c87c2c43 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 21 May 2026 10:46:15 -0700 Subject: [PATCH 07/14] Fix: Remove MIME type checking code duplication and delete verification scratch file --- core/src/models/interactions_utils.ts | 77 ++++++++------------- verify_interactions.ts | 97 --------------------------- 2 files changed, 27 insertions(+), 147 deletions(-) delete mode 100644 verify_interactions.ts diff --git a/core/src/models/interactions_utils.ts b/core/src/models/interactions_utils.ts index e5e516de..51e01cde 100644 --- a/core/src/models/interactions_utils.ts +++ b/core/src/models/interactions_utils.ts @@ -189,6 +189,23 @@ function encodeToBase64(data: string | Uint8Array): string { return Buffer.from(data).toString('base64'); } +/** + * Helper to determine interaction media type from mimeType string. + */ +function getInteractionMediaType( + mimeType: string, +): 'image' | 'audio' | 'video' | 'document' { + if (mimeType.startsWith('image/')) { + return 'image'; + } else if (mimeType.startsWith('audio/')) { + return 'audio'; + } else if (mimeType.startsWith('video/')) { + return 'video'; + } else { + return 'document'; + } +} + /** * Extracts the latest turn contents for interactions API. */ @@ -300,60 +317,20 @@ export function convertPartToInteractionContent( if (extPart.inlineData !== undefined && extPart.inlineData !== null) { const mimeType = extPart.inlineData.mimeType || ''; - if (mimeType.startsWith('image/')) { - return { - type: 'image', - data: extPart.inlineData.data, - mime_type: mimeType, - }; - } else if (mimeType.startsWith('audio/')) { - return { - type: 'audio', - data: extPart.inlineData.data, - mime_type: mimeType, - }; - } else if (mimeType.startsWith('video/')) { - return { - type: 'video', - data: extPart.inlineData.data, - mime_type: mimeType, - }; - } else { - return { - type: 'document', - data: extPart.inlineData.data, - mime_type: mimeType, - }; - } + return { + type: getInteractionMediaType(mimeType), + data: extPart.inlineData.data, + mime_type: mimeType, + }; } if (extPart.fileData !== undefined && extPart.fileData !== null) { const mimeType = extPart.fileData.mimeType || ''; - if (mimeType.startsWith('image/')) { - return { - type: 'image', - uri: extPart.fileData.fileUri, - mime_type: mimeType, - }; - } else if (mimeType.startsWith('audio/')) { - return { - type: 'audio', - uri: extPart.fileData.fileUri, - mime_type: mimeType, - }; - } else if (mimeType.startsWith('video/')) { - return { - type: 'video', - uri: extPart.fileData.fileUri, - mime_type: mimeType, - }; - } else { - return { - type: 'document', - uri: extPart.fileData.fileUri, - mime_type: mimeType, - }; - } + return { + type: getInteractionMediaType(mimeType), + uri: extPart.fileData.fileUri, + mime_type: mimeType, + }; } if (extPart.thought) { diff --git a/verify_interactions.ts b/verify_interactions.ts deleted file mode 100644 index 8338071d..00000000 --- a/verify_interactions.ts +++ /dev/null @@ -1,97 +0,0 @@ -import {Gemini} from './core/src/models/google_llm.js'; -import {LlmRequest} from './core/src/models/llm_request.js'; -import {LlmResponse} from './core/src/models/llm_response.js'; - -async function main() { - const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_GENAI_API_KEY; - if (!apiKey) { - console.error( - 'ERROR: GEMINI_API_KEY or GOOGLE_GENAI_API_KEY is not set in environment.', - ); - console.log( - 'Please run this script with: GEMINI_API_KEY=your_key npx tsx verify_interactions.ts', - ); - process.exit(1); - } - - console.log('Initializing Gemini model with useInteractionsApi: true...'); - const model = new Gemini({ - model: 'gemini-2.5-flash', - apiKey: apiKey, - useInteractionsApi: true, - }); - - // Turn 1 - const req1: LlmRequest = { - model: 'gemini-2.5-flash', - contents: [ - { - role: 'user', - parts: [{text: 'My favorite color is deep blue. Remember this.'}], - }, - ], - }; - - console.log('\n--- Turn 1 Request ---'); - console.log(JSON.stringify(req1, null, 2)); - - console.log('Sending Turn 1...'); - const res1List: LlmResponse[] = []; - for await (const chunk of model.generateContentAsync(req1)) { - res1List.push(chunk); - process.stdout.write(chunk.content?.parts?.[0]?.text || ''); - } - console.log('\n'); - - const finalRes1 = res1List[res1List.length - 1]; - console.log('--- Turn 1 Final Response ---'); - console.log(JSON.stringify(finalRes1, null, 2)); - - const interactionId = finalRes1.interactionId; - if (!interactionId) { - console.error('ERROR: No interactionId returned in Turn 1 response!'); - process.exit(1); - } - console.log(`SUCCESS: Got interactionId: ${interactionId}`); - - // Turn 2 - const req2: LlmRequest = { - model: 'gemini-2.5-flash', - contents: [ - { - role: 'user', - parts: [{text: 'What is my favorite color?'}], - }, - ], - previousInteractionId: interactionId, - }; - - console.log('\n--- Turn 2 Request ---'); - console.log(JSON.stringify(req2, null, 2)); - - console.log('Sending Turn 2 (should recall Turn 1)...'); - const res2List: LlmResponse[] = []; - for await (const chunk of model.generateContentAsync(req2)) { - res2List.push(chunk); - process.stdout.write(chunk.content?.parts?.[0]?.text || ''); - } - console.log('\n'); - - const finalRes2 = res2List[res2List.length - 1]; - console.log('--- Turn 2 Final Response ---'); - console.log(JSON.stringify(finalRes2, null, 2)); - - if (finalRes2.content?.parts?.[0]?.text?.toLowerCase().includes('blue')) { - console.log( - '\n🎉 SUCCESS: The model correctly recalled the favorite color from Turn 1 state!', - ); - } else { - console.log( - '\n❌ FAILURE: The model did not seem to recall the favorite color.', - ); - } -} - -main().catch((err) => { - console.error('Unhandled error during verification:', err); -}); From a7fe7070ea8bb842bb2a715a3e9592971aa7e0fa Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 21 May 2026 11:02:02 -0700 Subject: [PATCH 08/14] Chore: Upgrade @google/genai SDK to version 2.0.0 --- core/package.json | 2 +- package-lock.json | 26 +++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/core/package.json b/core/package.json index 0bfe329b..f048027e 100644 --- a/core/package.json +++ b/core/package.json @@ -45,7 +45,7 @@ "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", "@google-cloud/storage": "^7.17.1", "@google-cloud/vertexai": "^1.12.0", - "@google/genai": "^1.37.0", + "@google/genai": "^2.0.0", "@mikro-orm/core": "^6.6.10", "@mikro-orm/reflection": "^6.6.6", "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package-lock.json b/package-lock.json index d4123123..937d4752 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,7 @@ "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", "@google-cloud/storage": "^7.17.1", "@google-cloud/vertexai": "^1.12.0", - "@google/genai": "^1.37.0", + "@google/genai": "^2.0.0", "@mikro-orm/core": "^6.6.10", "@mikro-orm/reflection": "^6.6.6", "@modelcontextprotocol/sdk": "^1.26.0", @@ -85,6 +85,30 @@ "@mikro-orm/sqlite": "^6.6.6" } }, + "core/node_modules/@google/genai": { + "version": "2.5.0", + "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/@google/genai/-/genai-2.5.0.tgz", + "integrity": "sha512-qDi3LLh9I3llJK0f9uV8kZ8EdT9oHPxGJJ9yOJ/i5YXYrVwRCs8jHo9x4e99uOeKYDvD3TZwT70p/H/LS3BixQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "dev": { "name": "@google/adk-devtools", "version": "1.1.0", From b181159778834e0005a50f8505346b9795777591 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 21 May 2026 11:17:42 -0700 Subject: [PATCH 09/14] Fix: Update copyright year to 2026 and reuse base64Encode utility --- .../interactions_request_processor.ts | 2 +- core/src/models/interactions_utils.ts | 23 ++----------------- core/src/utils/env_aware_utils.ts | 17 ++++++++++---- 3 files changed, 16 insertions(+), 26 deletions(-) diff --git a/core/src/agents/processors/interactions_request_processor.ts b/core/src/agents/processors/interactions_request_processor.ts index 258c8ef7..20c96d5d 100644 --- a/core/src/agents/processors/interactions_request_processor.ts +++ b/core/src/agents/processors/interactions_request_processor.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ diff --git a/core/src/models/interactions_utils.ts b/core/src/models/interactions_utils.ts index 51e01cde..d5f85f55 100644 --- a/core/src/models/interactions_utils.ts +++ b/core/src/models/interactions_utils.ts @@ -170,25 +170,6 @@ interface GoogleGenAIWithInteractions extends GoogleGenAI { // --- Helper Functions --- -/** - * Helper to encode string or Uint8Array to base64. - */ -function encodeToBase64(data: string | Uint8Array): string { - if (typeof data === 'string') { - return base64Encode(data); - } - if (isBrowser()) { - let binary = ''; - const len = data.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(data[i]); - } - // eslint-disable-next-line no-undef - return window.btoa(binary); - } - return Buffer.from(data).toString('base64'); -} - /** * Helper to determine interaction media type from mimeType string. */ @@ -287,7 +268,7 @@ export function convertPartToInteractionContent( extPart.thoughtSignature !== undefined && extPart.thoughtSignature !== null ) { - result['thought_signature'] = encodeToBase64(extPart.thoughtSignature); + result['thought_signature'] = base64Encode(extPart.thoughtSignature); } return result; } @@ -339,7 +320,7 @@ export function convertPartToInteractionContent( extPart.thoughtSignature !== undefined && extPart.thoughtSignature !== null ) { - result['signature'] = encodeToBase64(extPart.thoughtSignature); + result['signature'] = base64Encode(extPart.thoughtSignature); } return result; } diff --git a/core/src/utils/env_aware_utils.ts b/core/src/utils/env_aware_utils.ts index 70931eef..08d9aa49 100644 --- a/core/src/utils/env_aware_utils.ts +++ b/core/src/utils/env_aware_utils.ts @@ -34,15 +34,24 @@ export function randomUUID() { } /** - * Encodes the given string to base64. + * Encodes the given string or Uint8Array to base64. * - * @param data The string to encode. + * @param data The data to encode. * @return The base64-encoded string. */ -export function base64Encode(data: string): string { +export function base64Encode(data: string | Uint8Array): string { if (isBrowser()) { + let strData = ''; + if (typeof data === 'string') { + strData = data; + } else { + const len = data.byteLength; + for (let i = 0; i < len; i++) { + strData += String.fromCharCode(data[i]); + } + } // eslint-disable-next-line no-undef - return window.btoa(data); + return window.btoa(strData); } return Buffer.from(data).toString('base64'); From c2190010ae4497d2165d4b2d16b6aead1e3c13c5 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 21 May 2026 13:00:31 -0700 Subject: [PATCH 10/14] Fix: Point @google/genai package resolution to public registry to resolve CI E401 error --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 937d4752..250f3097 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,7 +87,7 @@ }, "core/node_modules/@google/genai": { "version": "2.5.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/@google/genai/-/genai-2.5.0.tgz", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.5.0.tgz", "integrity": "sha512-qDi3LLh9I3llJK0f9uV8kZ8EdT9oHPxGJJ9yOJ/i5YXYrVwRCs8jHo9x4e99uOeKYDvD3TZwT70p/H/LS3BixQ==", "hasInstallScript": true, "license": "Apache-2.0", From 205a9063974f03fc0491a06d653e11f38e65f545 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 21 May 2026 13:08:52 -0700 Subject: [PATCH 11/14] Fix: Make structural interfaces independent to resolve strict GenAI SDK 2.0 type inheritance checks in CI --- core/src/models/interactions_utils.ts | 34 ++++++++++++++++++++------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/core/src/models/interactions_utils.ts b/core/src/models/interactions_utils.ts index d5f85f55..b1dbb9a4 100644 --- a/core/src/models/interactions_utils.ts +++ b/core/src/models/interactions_utils.ts @@ -21,9 +21,23 @@ import {LlmResponse} from './llm_response.js'; // --- Helper Interfaces for Strong Typing --- -interface ExtendedPart extends Part { - thoughtSignature?: string | Uint8Array | null; +interface ExtendedPart { + text?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + functionCall?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + functionResponse?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inlineData?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fileData?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + thoughtSignature?: any; thought?: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + codeExecutionResult?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + executableCode?: any; } interface ExtendedTool { @@ -153,7 +167,7 @@ interface InteractionSSEEvent { id?: string; } -interface GoogleGenAIWithInteractions extends GoogleGenAI { +interface GoogleGenAIWithInteractions { interactions: { create(params: { model?: string; @@ -251,7 +265,7 @@ export function getLatestUserContents(contents: Content[]): Content[] { export function convertPartToInteractionContent( part: Part, ): InteractionContent | null { - const extPart = part as ExtendedPart; + const extPart = part as unknown as ExtendedPart; if (extPart.text !== undefined && extPart.text !== null) { return {type: 'text', text: extPart.text}; @@ -261,7 +275,7 @@ export function convertPartToInteractionContent( const result: InteractionFunctionCall = { type: 'function_call', id: extPart.functionCall.id || '', - name: extPart.functionCall.name, + name: extPart.functionCall.name || '', arguments: (extPart.functionCall.args as Record) || {}, }; if ( @@ -345,8 +359,8 @@ export function convertPartToInteractionContent( type: 'code_execution_call', id: '', arguments: { - code: extPart.executableCode.code, - language: extPart.executableCode.language, + code: extPart.executableCode.code || '', + language: extPart.executableCode.language || 'PYTHON', }, }; } @@ -560,7 +574,8 @@ export function convertInteractionOutputToPart( return { executableCode: { code: args.code || '', - language: args.language || 'PYTHON', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + language: (args.language || 'PYTHON') as any, }, }; } @@ -891,7 +906,8 @@ export async function* generateContentViaInteractions( ); let currentInteractionId = previousInteractionId; - const clientWithInteractions = apiClient as GoogleGenAIWithInteractions; + const clientWithInteractions = + apiClient as unknown as GoogleGenAIWithInteractions; if (stream) { const responses = await clientWithInteractions.interactions.create({ From c4561d4966ed3055306c287701243f7bbf811941 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 1 Jun 2026 12:50:24 -0700 Subject: [PATCH 12/14] Refactor interactions API integration to use native SDK types and resolve PR comments --- core/src/models/interactions_utils.ts | 335 +++++++------------- core/test/models/interactions_utils_test.ts | 36 +-- 2 files changed, 126 insertions(+), 245 deletions(-) diff --git a/core/src/models/interactions_utils.ts b/core/src/models/interactions_utils.ts index b1dbb9a4..d6af7f5b 100644 --- a/core/src/models/interactions_utils.ts +++ b/core/src/models/interactions_utils.ts @@ -11,6 +11,7 @@ import { FunctionResponse, GenerateContentConfig, GoogleGenAI, + Interactions, Outcome, Part, } from '@google/genai'; @@ -21,124 +22,24 @@ import {LlmResponse} from './llm_response.js'; // --- Helper Interfaces for Strong Typing --- -interface ExtendedPart { - text?: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - functionCall?: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - functionResponse?: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - inlineData?: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fileData?: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - thoughtSignature?: any; - thought?: boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - codeExecutionResult?: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - executableCode?: any; -} - -interface ExtendedTool { - functionDeclarations?: Array<{ - name: string; - description?: string; - parameters?: { - properties?: Record; - required?: string[]; - }; - parametersJsonSchema?: unknown; - }>; - googleSearch?: unknown; - codeExecution?: unknown; - urlContext?: unknown; -} - -interface InteractionTextContent { - type: 'text'; - text: string; -} - -interface InteractionFunctionCall { - type: 'function_call'; - id: string; - name: string; - arguments: Record; - thought_signature?: string; -} - -interface InteractionFunctionResult { - type: 'function_result'; - name: string; - call_id: string; - result: unknown; -} - -interface InteractionMediaContent { - type: 'image' | 'audio' | 'video' | 'document'; - data?: string; - uri?: string; - mime_type: string; -} - -interface InteractionThought { - type: 'thought'; - signature?: string; -} - -interface InteractionCodeExecutionCall { - type: 'code_execution_call'; - id: string; - arguments: { +export interface ExtendedInteraction extends Interactions.Interaction { + error?: { code: string; - language: string; + message: string; }; } -interface InteractionCodeExecutionResult { - type: 'code_execution_result'; - call_id: string; - result: string; - is_error: boolean; -} - -type InteractionContent = - | InteractionTextContent - | InteractionFunctionCall - | InteractionFunctionResult - | InteractionMediaContent - | InteractionThought - | InteractionCodeExecutionCall - | InteractionCodeExecutionResult; - -interface InteractionTurn { - role: string; - content: InteractionContent[]; -} - -interface InteractionTool { - type: 'function' | 'google_search' | 'code_execution' | 'url_context'; - name?: string; - description?: string; - parameters?: unknown; -} - -interface InteractionResponse { - id: string; - status: 'completed' | 'requires_action' | 'failed' | string; +export interface ExtendedInteractionStatusUpdate + extends Omit { error?: { code: string; message: string; }; - outputs?: Record[]; - usage?: { - total_input_tokens?: number; - total_output_tokens?: number; - }; } -interface InteractionSSEEvent { +// Runtime event types can be more relaxed than compile-time +export interface ExtendedInteractionSSEEvent + extends Omit { event_type?: string; eventType?: string; delta?: { @@ -148,6 +49,7 @@ interface InteractionSSEEvent { id?: string; arguments?: Record; thought_signature?: string; + signature?: string; data?: string; uri?: string; mime_type: string; @@ -167,21 +69,6 @@ interface InteractionSSEEvent { id?: string; } -interface GoogleGenAIWithInteractions { - interactions: { - create(params: { - model?: string; - input: InteractionTurn[]; - stream: boolean; - systemInstruction?: string; - tools?: InteractionTool[]; - generationConfig?: Record; - previousInteractionId?: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }): Promise; // We keep 'any' here as the SDK return type is complex (stream vs non-stream) - }; -} - // --- Helper Functions --- /** @@ -190,14 +77,15 @@ interface GoogleGenAIWithInteractions { function getInteractionMediaType( mimeType: string, ): 'image' | 'audio' | 'video' | 'document' { - if (mimeType.startsWith('image/')) { - return 'image'; - } else if (mimeType.startsWith('audio/')) { - return 'audio'; - } else if (mimeType.startsWith('video/')) { - return 'video'; - } else { - return 'document'; + switch (mimeType.split('/')[0]) { + case 'image': + return 'image'; + case 'audio': + return 'audio'; + case 'video': + return 'video'; + default: + return 'document'; } } @@ -264,34 +152,32 @@ export function getLatestUserContents(contents: Content[]): Content[] { */ export function convertPartToInteractionContent( part: Part, -): InteractionContent | null { - const extPart = part as unknown as ExtendedPart; - - if (extPart.text !== undefined && extPart.text !== null) { - return {type: 'text', text: extPart.text}; +): Interactions.Content | null { + if (part.text !== undefined && part.text !== null) { + return {type: 'text', text: part.text}; } - if (extPart.functionCall !== undefined && extPart.functionCall !== null) { - const result: InteractionFunctionCall = { + if (part.functionCall !== undefined && part.functionCall !== null) { + const result: Interactions.FunctionCallContent = { type: 'function_call', - id: extPart.functionCall.id || '', - name: extPart.functionCall.name || '', - arguments: (extPart.functionCall.args as Record) || {}, + id: part.functionCall.id || '', + name: part.functionCall.name || '', + arguments: (part.functionCall.args as Record) || {}, }; if ( - extPart.thoughtSignature !== undefined && - extPart.thoughtSignature !== null + part.thoughtSignature !== undefined && + part.thoughtSignature !== null ) { - result['thought_signature'] = base64Encode(extPart.thoughtSignature); + result.signature = base64Encode(part.thoughtSignature); } return result; } if ( - extPart.functionResponse !== undefined && - extPart.functionResponse !== null + part.functionResponse !== undefined && + part.functionResponse !== null ) { - let resultValue: unknown = extPart.functionResponse.response; + let resultValue: unknown = part.functionResponse.response; if ( typeof resultValue !== 'object' && typeof resultValue !== 'string' && @@ -300,67 +186,67 @@ export function convertPartToInteractionContent( resultValue = String(resultValue); } logger.debug( - `Converting function_response: name=${extPart.functionResponse.name}, call_id=${extPart.functionResponse.id}`, + `Converting function_response: name=${part.functionResponse.name}, call_id=${part.functionResponse.id}`, ); return { type: 'function_result', - name: extPart.functionResponse.name || '', - call_id: extPart.functionResponse.id || '', + name: part.functionResponse.name || '', + call_id: part.functionResponse.id || '', result: resultValue, }; } - if (extPart.inlineData !== undefined && extPart.inlineData !== null) { - const mimeType = extPart.inlineData.mimeType || ''; + if (part.inlineData !== undefined && part.inlineData !== null) { + const mimeType = part.inlineData.mimeType || ''; return { type: getInteractionMediaType(mimeType), - data: extPart.inlineData.data, + data: part.inlineData.data, mime_type: mimeType, - }; + } as Interactions.Content; } - if (extPart.fileData !== undefined && extPart.fileData !== null) { - const mimeType = extPart.fileData.mimeType || ''; + if (part.fileData !== undefined && part.fileData !== null) { + const mimeType = part.fileData.mimeType || ''; return { type: getInteractionMediaType(mimeType), - uri: extPart.fileData.fileUri, + uri: part.fileData.fileUri, mime_type: mimeType, - }; + } as Interactions.Content; } - if (extPart.thought) { - const result: InteractionThought = {type: 'thought'}; + if (part.thought) { + const result: Interactions.ThoughtContent = {type: 'thought'}; if ( - extPart.thoughtSignature !== undefined && - extPart.thoughtSignature !== null + part.thoughtSignature !== undefined && + part.thoughtSignature !== null ) { - result['signature'] = base64Encode(extPart.thoughtSignature); + result.signature = base64Encode(part.thoughtSignature); } return result; } if ( - extPart.codeExecutionResult !== undefined && - extPart.codeExecutionResult !== null + part.codeExecutionResult !== undefined && + part.codeExecutionResult !== null ) { const isError = - extPart.codeExecutionResult.outcome === Outcome.OUTCOME_FAILED || - extPart.codeExecutionResult.outcome === Outcome.OUTCOME_DEADLINE_EXCEEDED; + part.codeExecutionResult.outcome === Outcome.OUTCOME_FAILED || + part.codeExecutionResult.outcome === Outcome.OUTCOME_DEADLINE_EXCEEDED; return { type: 'code_execution_result', call_id: '', - result: extPart.codeExecutionResult.output || '', + result: part.codeExecutionResult.output || '', is_error: isError, }; } - if (extPart.executableCode !== undefined && extPart.executableCode !== null) { + if (part.executableCode !== undefined && part.executableCode !== null) { return { type: 'code_execution_call', id: '', arguments: { - code: extPart.executableCode.code || '', - language: extPart.executableCode.language || 'PYTHON', + code: part.executableCode.code || '', + language: part.executableCode.language || 'PYTHON', }, }; } @@ -371,8 +257,8 @@ export function convertPartToInteractionContent( /** * Convert a Content to a TurnParam object. */ -export function convertContentToTurn(content: Content): InteractionTurn { - const contents: InteractionContent[] = []; +export function convertContentToTurn(content: Content): Interactions.Turn { + const contents: Interactions.Content[] = []; if (content.parts) { for (const part of content.parts) { const interactionContent = convertPartToInteractionContent(part); @@ -391,8 +277,8 @@ export function convertContentToTurn(content: Content): InteractionTurn { /** * Convert a list of Content objects to turns. */ -export function convertContentsToTurns(contents: Content[]): InteractionTurn[] { - const turns: InteractionTurn[] = []; +export function convertContentsToTurns(contents: Content[]): Interactions.Turn[] { + const turns: Interactions.Turn[] = []; for (const content of contents) { const turn = convertContentToTurn(content); if (turn.content && turn.content.length > 0) { @@ -407,17 +293,17 @@ export function convertContentsToTurns(contents: Content[]): InteractionTurn[] { */ export function convertToolsConfigToInteractionsFormat( config: GenerateContentConfig, -): InteractionTool[] { +): Interactions.Tool[] { if (!config.tools) { return []; } - const interactionTools: InteractionTool[] = []; + const interactionTools: Interactions.Tool[] = []; for (const tool of config.tools) { - const t = tool as ExtendedTool; + const t = tool as any; if (t.functionDeclarations) { for (const funcDecl of t.functionDeclarations) { - const funcTool: InteractionTool = { + const funcTool: any = { type: 'function', name: funcDecl.name, }; @@ -443,20 +329,20 @@ export function convertToolsConfigToInteractionsFormat( } else if (funcDecl.parametersJsonSchema) { funcTool['parameters'] = funcDecl.parametersJsonSchema; } - interactionTools.push(funcTool); + interactionTools.push(funcTool as Interactions.Tool); } } if (t.googleSearch) { - interactionTools.push({type: 'google_search'}); + interactionTools.push({type: 'google_search'} as Interactions.Tool); } if (t.codeExecution) { - interactionTools.push({type: 'code_execution'}); + interactionTools.push({type: 'code_execution'} as Interactions.Tool); } if (t.urlContext) { - interactionTools.push({type: 'url_context'}); + interactionTools.push({type: 'url_context'} as Interactions.Tool); } } @@ -467,16 +353,16 @@ export function convertToolsConfigToInteractionsFormat( * Convert interaction output to a Part. */ export function convertInteractionOutputToPart( - output: Record, + output: Interactions.Content, ): Part | null { if (!output || !output.type) { return null; } - const outputType = output.type as string; + const outputType = output.type; if (outputType === 'text') { - return {text: (output.text as string) || ''}; + return {text: output.text || ''}; } if (outputType === 'function_call') { @@ -484,7 +370,7 @@ export function convertInteractionOutputToPart( `Converting function_call output: name=${output.name}, id=${output.id}`, ); let thoughtSignature: Uint8Array | undefined = undefined; - const thoughtSigValue = output.thought_signature; + const thoughtSigValue = output.signature; if (thoughtSigValue && typeof thoughtSigValue === 'string') { if (isBrowser()) { // eslint-disable-next-line no-undef @@ -501,12 +387,11 @@ export function convertInteractionOutputToPart( } return { functionCall: { - id: output.id as string, - name: output.name as string, - args: (output.arguments as Record) || {}, + id: output.id, + name: output.name, + args: output.arguments || {}, } as FunctionCall, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - thoughtSignature: thoughtSignature as any, // Keep any here if Part thoughtSignature is strict + thoughtSignature: thoughtSignature as any, }; } @@ -514,8 +399,8 @@ export function convertInteractionOutputToPart( const result = output.result; return { functionResponse: { - id: output.call_id as string, - response: result, + id: output.call_id, + response: typeof result === 'object' && result !== null ? (result as Record) : {output: result}, } as FunctionResponse, }; } @@ -524,15 +409,15 @@ export function convertInteractionOutputToPart( if (output.data) { return { inlineData: { - data: output.data as string, - mimeType: output.mime_type as string, + data: output.data, + mimeType: output.mime_type || '', }, }; } else if (output.uri) { return { fileData: { - fileUri: output.uri as string, - mimeType: output.mime_type as string, + fileUri: output.uri, + mimeType: output.mime_type || '', }, }; } @@ -542,15 +427,15 @@ export function convertInteractionOutputToPart( if (output.data) { return { inlineData: { - data: output.data as string, - mimeType: output.mime_type as string, + data: output.data, + mimeType: output.mime_type || '', }, }; } else if (output.uri) { return { fileData: { - fileUri: output.uri as string, - mimeType: output.mime_type as string, + fileUri: output.uri, + mimeType: output.mime_type || '', }, }; } @@ -563,18 +448,17 @@ export function convertInteractionOutputToPart( if (outputType === 'code_execution_result') { return { codeExecutionResult: { - output: (output.result as string) || '', + output: output.result || '', outcome: output.is_error ? Outcome.OUTCOME_FAILED : Outcome.OUTCOME_OK, }, }; } if (outputType === 'code_execution_call') { - const args = (output.arguments as Record) || {}; + const args = output.arguments || {}; return { executableCode: { code: args.code || '', - // eslint-disable-next-line @typescript-eslint/no-explicit-any language: (args.language || 'PYTHON') as any, }, }; @@ -597,7 +481,7 @@ export function convertInteractionOutputToPart( * Convert Interaction response to an LlmResponse. */ export function convertInteractionToLlmResponse( - interaction: InteractionResponse, + interaction: ExtendedInteraction, ): LlmResponse { if (interaction.status === 'failed') { let errorMsg = 'Unknown error'; @@ -662,7 +546,7 @@ export function convertInteractionToLlmResponse( * Convert InteractionSSEEvent to LlmResponse. */ export function convertInteractionEventToLlmResponse( - event: InteractionSSEEvent, + event: ExtendedInteractionSSEEvent, aggregatedParts: Part[], interactionId?: string, ): LlmResponse | null { @@ -691,7 +575,7 @@ export function convertInteractionEventToLlmResponse( } else if (deltaType === 'function_call') { if (delta.name) { let thoughtSignature: Uint8Array | undefined = undefined; - const thoughtSigValue = delta.thought_signature; + const thoughtSigValue = delta.signature || delta.thought_signature; if (thoughtSigValue && typeof thoughtSigValue === 'string') { if (isBrowser()) { // eslint-disable-next-line no-undef @@ -712,7 +596,6 @@ export function convertInteractionEventToLlmResponse( name: delta.name, args: delta.arguments || {}, } as FunctionCall, - // eslint-disable-next-line @typescript-eslint/no-explicit-any thoughtSignature: thoughtSignature as any, }; aggregatedParts.push(part); @@ -756,7 +639,7 @@ export function convertInteractionEventToLlmResponse( } } else if (eventType === 'interaction') { return convertInteractionToLlmResponse( - event as unknown as InteractionResponse, + event as unknown as ExtendedInteraction, ); } else if (eventType === 'interaction.status_update') { const status = event.status; @@ -782,8 +665,8 @@ export function convertInteractionEventToLlmResponse( } } else if (eventType === 'error') { return { - errorCode: event.code || 'UNKNOWN_ERROR', - errorMessage: event.message || 'Unknown error', + errorCode: event.error?.code || event.code || 'UNKNOWN_ERROR', + errorMessage: event.error?.message || event.message || 'Unknown error', turnComplete: true, interactionId: interactionId, }; @@ -863,7 +746,7 @@ export function extractSystemInstruction( * Extract stream interaction ID helper. */ function extractStreamInteractionId( - event: InteractionSSEEvent, + event: ExtendedInteractionSSEEvent, ): string | undefined { if (event.interaction_id || event.interactionId) { return event.interaction_id || event.interactionId; @@ -906,24 +789,22 @@ export async function* generateContentViaInteractions( ); let currentInteractionId = previousInteractionId; - const clientWithInteractions = - apiClient as unknown as GoogleGenAIWithInteractions; if (stream) { - const responses = await clientWithInteractions.interactions.create({ + const responses = await apiClient.interactions.create({ model: llmRequest.model, input: inputTurns, stream: true, - systemInstruction: systemInstruction, + system_instruction: systemInstruction, tools: interactionTools.length > 0 ? interactionTools : undefined, - generationConfig: + generation_config: Object.keys(generationConfig).length > 0 ? generationConfig : undefined, - previousInteractionId: previousInteractionId, - }); + previous_interaction_id: previousInteractionId, + } as any); // cast to any because SDK typings might still be tricky under some conditions const aggregatedParts: Part[] = []; for await (const event of responses) { - const sseEvent = event as InteractionSSEEvent; + const sseEvent = event as ExtendedInteractionSSEEvent; const interactionId = extractStreamInteractionId(sseEvent); if (interactionId) { currentInteractionId = interactionId; @@ -948,18 +829,18 @@ export async function* generateContentViaInteractions( }; } } else { - const interaction = await clientWithInteractions.interactions.create({ + const interaction = await apiClient.interactions.create({ model: llmRequest.model, input: inputTurns, stream: false, - systemInstruction: systemInstruction, + system_instruction: systemInstruction, tools: interactionTools.length > 0 ? interactionTools : undefined, - generationConfig: + generation_config: Object.keys(generationConfig).length > 0 ? generationConfig : undefined, - previousInteractionId: previousInteractionId, - }); + previous_interaction_id: previousInteractionId, + } as any); logger.info('Interaction response received from the model.'); - yield convertInteractionToLlmResponse(interaction as InteractionResponse); + yield convertInteractionToLlmResponse(interaction as ExtendedInteraction); } } diff --git a/core/test/models/interactions_utils_test.ts b/core/test/models/interactions_utils_test.ts index c6f4316f..1a80481d 100644 --- a/core/test/models/interactions_utils_test.ts +++ b/core/test/models/interactions_utils_test.ts @@ -161,7 +161,7 @@ describe('interactions_utils', () => { id: 'call-123', name: 'test_tool', arguments: {a: 1}, - thought_signature: 'c2lnLWRhdGE=', + signature: 'c2lnLWRhdGE=', }); }); @@ -380,7 +380,7 @@ describe('interactions_utils', () => { id: 'call-123', name: 'test_tool', arguments: {a: 1}, - thought_signature: 'c2lnLWRhdGEtc3RyaW5n', + signature: 'c2lnLWRhdGEtc3RyaW5n', }); }); @@ -400,7 +400,7 @@ describe('interactions_utils', () => { }; const result = convertPartToInteractionContent(part); - expect(result?.thought_signature).toBe('c2lnLWRhdGEtYnJvd3Nlcg=='); + expect(result?.signature).toBe('c2lnLWRhdGEtYnJvd3Nlcg=='); (global as any).window = originalWindow; }); @@ -806,10 +806,10 @@ describe('interactions_utils', () => { }, ], stream: false, - systemInstruction: undefined, + system_instruction: undefined, tools: undefined, - generationConfig: undefined, - previousInteractionId: undefined, + generation_config: undefined, + previous_interaction_id: undefined, }); }); @@ -941,10 +941,10 @@ describe('interactions_utils', () => { }, ], stream: false, - systemInstruction: undefined, + system_instruction: undefined, tools: undefined, - generationConfig: undefined, - previousInteractionId: 'int-prev', + generation_config: undefined, + previous_interaction_id: 'int-prev', }); }); @@ -1052,14 +1052,14 @@ describe('interactions_utils', () => { }, ], stream: false, - systemInstruction: undefined, + system_instruction: undefined, tools: [ { type: 'function', name: 'my_tool', }, ], - generationConfig: { + generation_config: { temperature: 0.7, top_p: 0.9, top_k: 40, @@ -1068,7 +1068,7 @@ describe('interactions_utils', () => { presence_penalty: 0.5, frequency_penalty: 0.5, }, - previousInteractionId: undefined, + previous_interaction_id: undefined, }); }); @@ -1115,17 +1115,17 @@ describe('interactions_utils', () => { }, ], stream: true, - systemInstruction: undefined, + system_instruction: undefined, tools: [ { type: 'function', name: 'my_tool', }, ], - generationConfig: { + generation_config: { temperature: 0.5, }, - previousInteractionId: undefined, + previous_interaction_id: undefined, }); }); }); @@ -1189,7 +1189,7 @@ describe('interactions_utils', () => { type: 'function_call', id: 'call-1', name: 'my_tool', - thought_signature: 123 as any, + signature: 123 as any, }; const part = convertInteractionOutputToPart(output); expect(part?.thoughtSignature).toBeUndefined(); @@ -1206,7 +1206,7 @@ describe('interactions_utils', () => { id: 'call-1', name: 'my_tool', arguments: {a: 1}, - thought_signature: 'YmFzZTY0ZGF0YQ==', + signature: 'YmFzZTY0ZGF0YQ==', }; const part = convertInteractionOutputToPart(output); @@ -1224,7 +1224,7 @@ describe('interactions_utils', () => { id: 'call-1', name: 'my_tool', arguments: {a: 1}, - thought_signature: 'YmFzZTY0ZGF0YQ==', + signature: 'YmFzZTY0ZGF0YQ==', }; const part = convertInteractionOutputToPart(output); expect(part?.thoughtSignature).toBeInstanceOf(Buffer); From f9d74a62568ad680ee6f95f48a73f6fce8431d43 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 1 Jun 2026 13:26:06 -0700 Subject: [PATCH 13/14] refactor(test): optimize interactions utils and fix agent loader test timeouts --- core/src/models/interactions_utils.ts | 693 ++++---- core/test/models/interactions_utils_test.ts | 1592 ++++++++++++------- dev/test/utils/agent_loader_test.ts | 69 +- 3 files changed, 1435 insertions(+), 919 deletions(-) diff --git a/core/src/models/interactions_utils.ts b/core/src/models/interactions_utils.ts index d6af7f5b..c3ba56d0 100644 --- a/core/src/models/interactions_utils.ts +++ b/core/src/models/interactions_utils.ts @@ -4,18 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import { Content, FinishReason, - FunctionCall, - FunctionResponse, GenerateContentConfig, GoogleGenAI, Interactions, Outcome, Part, } from '@google/genai'; -import {base64Encode, isBrowser} from '../utils/env_aware_utils.js'; import {logger} from '../utils/logger.js'; import {LlmRequest} from './llm_request.js'; import {LlmResponse} from './llm_response.js'; @@ -29,8 +28,10 @@ export interface ExtendedInteraction extends Interactions.Interaction { }; } -export interface ExtendedInteractionStatusUpdate - extends Omit { +export interface ExtendedInteractionStatusUpdate extends Omit< + Interactions.InteractionStatusUpdate, + 'error' +> { error?: { code: string; message: string; @@ -38,8 +39,10 @@ export interface ExtendedInteractionStatusUpdate } // Runtime event types can be more relaxed than compile-time -export interface ExtendedInteractionSSEEvent - extends Omit { +export interface ExtendedInteractionSSEEvent extends Omit< + Interactions.InteractionSSEEvent, + 'error' | 'interaction_id' | 'status' | 'event_type' +> { event_type?: string; eventType?: string; delta?: { @@ -148,61 +151,20 @@ export function getLatestUserContents(contents: Content[]): Content[] { } /** - * Convert a Part to an interaction content object. + * Convert a Part to a media content object (Interactions.Content). */ -export function convertPartToInteractionContent( - part: Part, -): Interactions.Content | null { +function convertPartToMediaContent(part: Part): Interactions.Content | null { if (part.text !== undefined && part.text !== null) { return {type: 'text', text: part.text}; } - if (part.functionCall !== undefined && part.functionCall !== null) { - const result: Interactions.FunctionCallContent = { - type: 'function_call', - id: part.functionCall.id || '', - name: part.functionCall.name || '', - arguments: (part.functionCall.args as Record) || {}, - }; - if ( - part.thoughtSignature !== undefined && - part.thoughtSignature !== null - ) { - result.signature = base64Encode(part.thoughtSignature); - } - return result; - } - - if ( - part.functionResponse !== undefined && - part.functionResponse !== null - ) { - let resultValue: unknown = part.functionResponse.response; - if ( - typeof resultValue !== 'object' && - typeof resultValue !== 'string' && - !Array.isArray(resultValue) - ) { - resultValue = String(resultValue); - } - logger.debug( - `Converting function_response: name=${part.functionResponse.name}, call_id=${part.functionResponse.id}`, - ); - return { - type: 'function_result', - name: part.functionResponse.name || '', - call_id: part.functionResponse.id || '', - result: resultValue, - }; - } - if (part.inlineData !== undefined && part.inlineData !== null) { const mimeType = part.inlineData.mimeType || ''; return { type: getInteractionMediaType(mimeType), data: part.inlineData.data, mime_type: mimeType, - } as Interactions.Content; + } as any; } if (part.fileData !== undefined && part.fileData !== null) { @@ -211,81 +173,245 @@ export function convertPartToInteractionContent( type: getInteractionMediaType(mimeType), uri: part.fileData.fileUri, mime_type: mimeType, - } as Interactions.Content; + } as any; } - if (part.thought) { - const result: Interactions.ThoughtContent = {type: 'thought'}; - if ( - part.thoughtSignature !== undefined && - part.thoughtSignature !== null - ) { - result.signature = base64Encode(part.thoughtSignature); - } - return result; - } + return null; +} - if ( - part.codeExecutionResult !== undefined && - part.codeExecutionResult !== null - ) { - const isError = - part.codeExecutionResult.outcome === Outcome.OUTCOME_FAILED || - part.codeExecutionResult.outcome === Outcome.OUTCOME_DEADLINE_EXCEEDED; - return { - type: 'code_execution_result', - call_id: '', - result: part.codeExecutionResult.output || '', - is_error: isError, - }; - } +/** + * Convert a Content to a list of Steps. + */ +export function convertContentToSteps(content: Content): Interactions.Step[] { + const steps: Interactions.Step[] = []; + const role = content.role || 'user'; - if (part.executableCode !== undefined && part.executableCode !== null) { - return { - type: 'code_execution_call', - id: '', - arguments: { - code: part.executableCode.code || '', - language: part.executableCode.language || 'PYTHON', - }, - }; + if (role === 'user') { + const mediaContents: Interactions.Content[] = []; + if (content.parts) { + for (const part of content.parts) { + if (part.functionResponse) { + steps.push({ + type: 'function_result', + call_id: part.functionResponse.id || '', + name: part.functionResponse.name || '', + result: part.functionResponse.response, + } as Interactions.FunctionResultStep); + } else if (part.codeExecutionResult) { + const isError = + part.codeExecutionResult.outcome === Outcome.OUTCOME_FAILED || + part.codeExecutionResult.outcome === + Outcome.OUTCOME_DEADLINE_EXCEEDED; + steps.push({ + type: 'code_execution_result', + call_id: '', + result: part.codeExecutionResult.output || '', + is_error: isError, + } as Interactions.CodeExecutionResultStep); + } else { + const mediaContent = convertPartToMediaContent(part); + if (mediaContent) { + mediaContents.push(mediaContent); + } + } + } + } + if (mediaContents.length > 0) { + steps.push({ + type: 'user_input', + content: mediaContents, + } as Interactions.UserInputStep); + } + } else if (role === 'model') { + const mediaContents: Interactions.Content[] = []; + if (content.parts) { + for (const part of content.parts) { + if (part.functionCall) { + const step: Interactions.FunctionCallStep = { + type: 'function_call', + id: part.functionCall.id || '', + name: part.functionCall.name || '', + arguments: + (part.functionCall.args as Record) || {}, + }; + if (part.thoughtSignature) { + step.signature = part.thoughtSignature; + } + steps.push(step); + } else if (part.executableCode) { + steps.push({ + type: 'code_execution_call', + id: '', + arguments: { + code: part.executableCode.code || '', + language: 'python', + }, + } as Interactions.CodeExecutionCallStep); + } else if (part.thought) { + const step: Interactions.ThoughtStep = { + type: 'thought', + }; + if (part.thoughtSignature) { + step.signature = part.thoughtSignature; + } + steps.push(step); + } else { + const mediaContent = convertPartToMediaContent(part); + if (mediaContent) { + mediaContents.push(mediaContent); + } + } + } + } + if (mediaContents.length > 0) { + steps.push({ + type: 'model_output', + content: mediaContents, + } as Interactions.ModelOutputStep); + } } - return null; + return steps; } /** - * Convert a Content to a TurnParam object. + * Convert a media content (Interactions.Content) to a Part. */ -export function convertContentToTurn(content: Content): Interactions.Turn { - const contents: Interactions.Content[] = []; - if (content.parts) { - for (const part of content.parts) { - const interactionContent = convertPartToInteractionContent(part); - if (interactionContent) { - contents.push(interactionContent); - } - } +function convertMediaContentToPart(content: Interactions.Content): Part | null { + if (content.type === 'text') { + return {text: content.text || ''}; } - return { - role: content.role || 'user', - content: contents, - }; + if ( + content.type === 'image' || + content.type === 'audio' || + content.type === 'video' || + content.type === 'document' + ) { + const media = content as any; + if (media.data) { + return { + inlineData: { + data: media.data, + mimeType: media.mime_type || '', + }, + }; + } else if (media.uri) { + return { + fileData: { + fileUri: media.uri, + mimeType: media.mime_type || '', + }, + }; + } + } + return null; } /** - * Convert a list of Content objects to turns. + * Convert a Step to a list of Parts. */ -export function convertContentsToTurns(contents: Content[]): Interactions.Turn[] { - const turns: Interactions.Turn[] = []; - for (const content of contents) { - const turn = convertContentToTurn(content); - if (turn.content && turn.content.length > 0) { - turns.push(turn); +export function convertStepToParts(step: Interactions.Step): Part[] { + if (!step || !step.type) { + return []; + } + + switch (step.type) { + case 'model_output': { + const modelOutputStep = step as Interactions.ModelOutputStep; + const parts: Part[] = []; + if (modelOutputStep.content) { + for (const content of modelOutputStep.content) { + const part = convertMediaContentToPart(content); + if (part) { + parts.push(part); + } + } + } + return parts; + } + case 'user_input': { + const userInputStep = step as Interactions.UserInputStep; + const parts: Part[] = []; + if (userInputStep.content) { + for (const content of userInputStep.content) { + const part = convertMediaContentToPart(content); + if (part) { + parts.push(part); + } + } + } + return parts; + } + case 'function_call': { + const functionCallStep = step as Interactions.FunctionCallStep; + const part: Part = { + functionCall: { + id: functionCallStep.id, + name: functionCallStep.name, + args: functionCallStep.arguments || {}, + }, + }; + if (functionCallStep.signature) { + part.thoughtSignature = functionCallStep.signature; + } + return [part]; + } + case 'function_result': { + const functionResultStep = step as Interactions.FunctionResultStep; + const result = functionResultStep.result; + return [ + { + functionResponse: { + id: functionResultStep.call_id, + name: functionResultStep.name || '', + response: + typeof result === 'object' && result !== null + ? (result as Record) + : {output: result}, + }, + }, + ]; + } + case 'code_execution_call': { + const codeExecutionCallStep = step as Interactions.CodeExecutionCallStep; + const args = codeExecutionCallStep.arguments || {}; + return [ + { + executableCode: { + code: args.code || '', + language: (args.language || 'PYTHON').toUpperCase() as any, + }, + }, + ]; + } + case 'code_execution_result': { + const codeExecutionResultStep = + step as Interactions.CodeExecutionResultStep; + return [ + { + codeExecutionResult: { + output: codeExecutionResultStep.result || '', + outcome: codeExecutionResultStep.is_error + ? Outcome.OUTCOME_FAILED + : Outcome.OUTCOME_OK, + }, + }, + ]; + } + case 'thought': { + const thoughtStep = step as Interactions.ThoughtStep; + const part: Part = { + thought: true, + }; + if (thoughtStep.signature) { + part.thoughtSignature = thoughtStep.signature; + } + return [part]; } + default: + return []; } - return turns; } /** @@ -350,131 +476,42 @@ export function convertToolsConfigToInteractionsFormat( } /** - * Convert interaction output to a Part. + * Helper to find the last element in an array matching a predicate. */ -export function convertInteractionOutputToPart( - output: Interactions.Content, -): Part | null { - if (!output || !output.type) { - return null; - } - - const outputType = output.type; - - if (outputType === 'text') { - return {text: output.text || ''}; - } - - if (outputType === 'function_call') { - logger.debug( - `Converting function_call output: name=${output.name}, id=${output.id}`, - ); - let thoughtSignature: Uint8Array | undefined = undefined; - const thoughtSigValue = output.signature; - if (thoughtSigValue && typeof thoughtSigValue === 'string') { - if (isBrowser()) { - // eslint-disable-next-line no-undef - const binaryString = window.atob(thoughtSigValue); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - thoughtSignature = bytes; - } else { - thoughtSignature = Buffer.from(thoughtSigValue, 'base64'); - } +function findLastPart( + parts: Part[], + predicate: (p: Part) => boolean, +): Part | undefined { + for (let i = parts.length - 1; i >= 0; i--) { + if (predicate(parts[i])) { + return parts[i]; } - return { - functionCall: { - id: output.id, - name: output.name, - args: output.arguments || {}, - } as FunctionCall, - thoughtSignature: thoughtSignature as any, - }; - } - - if (outputType === 'function_result') { - const result = output.result; - return { - functionResponse: { - id: output.call_id, - response: typeof result === 'object' && result !== null ? (result as Record) : {output: result}, - } as FunctionResponse, - }; - } - - if (outputType === 'image') { - if (output.data) { - return { - inlineData: { - data: output.data, - mimeType: output.mime_type || '', - }, - }; - } else if (output.uri) { - return { - fileData: { - fileUri: output.uri, - mimeType: output.mime_type || '', - }, - }; - } - } - - if (outputType === 'audio') { - if (output.data) { - return { - inlineData: { - data: output.data, - mimeType: output.mime_type || '', - }, - }; - } else if (output.uri) { - return { - fileData: { - fileUri: output.uri, - mimeType: output.mime_type || '', - }, - }; - } - } - - if (outputType === 'thought') { - return null; - } - - if (outputType === 'code_execution_result') { - return { - codeExecutionResult: { - output: output.result || '', - outcome: output.is_error ? Outcome.OUTCOME_FAILED : Outcome.OUTCOME_OK, - }, - }; } + return undefined; +} - if (outputType === 'code_execution_call') { - const args = output.arguments || {}; - return { - executableCode: { - code: args.code || '', - language: (args.language || 'PYTHON') as any, - }, - }; +/** + * Extract the latest model generated parts from a list of steps. + */ +export function getLatestModelParts(steps: Interactions.Step[]): Part[] { + if (!steps || steps.length === 0) { + return []; } - if (outputType === 'google_search_result') { - if (output.result && Array.isArray(output.result)) { - const resultsText = output.result - .filter((r) => r) - .map((r) => (typeof r === 'object' ? JSON.stringify(r) : String(r))) - .join('\n'); - return {text: resultsText}; + const latestParts: Part[] = []; + for (let i = steps.length - 1; i >= 0; i--) { + const step = steps[i]; + if ( + step.type === 'user_input' || + step.type === 'function_result' || + step.type === 'code_execution_result' + ) { + break; } + const parts = convertStepToParts(step); + latestParts.unshift(...parts); } - - return null; + return latestParts; } /** @@ -497,15 +534,7 @@ export function convertInteractionToLlmResponse( }; } - const parts: Part[] = []; - if (interaction.outputs) { - for (const output of interaction.outputs) { - const part = convertInteractionOutputToPart(output); - if (part) { - parts.push(part); - } - } - } + const parts = getLatestModelParts(interaction.steps || []); let content: Content | undefined = undefined; if (parts.length > 0) { @@ -552,15 +581,48 @@ export function convertInteractionEventToLlmResponse( ): LlmResponse | null { const eventType = event.event_type || event.eventType; - if (eventType === 'content.delta') { - const delta = event.delta; + if (eventType === 'step.start') { + const stepStart = event as unknown as Interactions.StepStart; + const step = stepStart.step; + if (step.type === 'function_call') { + const part: Part = { + functionCall: { + id: step.id, + name: step.name, + args: {}, + }, + partMetadata: { + accumulatedArgs: '', + isComplete: false, + }, + }; + if (step.signature) { + part.thoughtSignature = step.signature; + } + aggregatedParts.push(part); + return null; + } + if (step.type === 'thought') { + const part: Part = { + thought: true, + partMetadata: { + isComplete: false, + }, + }; + if (step.signature) { + part.thoughtSignature = step.signature; + } + aggregatedParts.push(part); + return null; + } + } else if (eventType === 'step.delta') { + const stepDelta = event as unknown as Interactions.StepDelta; + const delta = stepDelta.delta; if (!delta) { return null; } - const deltaType = delta.type; - - if (deltaType === 'text') { + if (delta.type === 'text') { const text = delta.text || ''; if (text) { const part: Part = {text: text}; @@ -572,53 +634,39 @@ export function convertInteractionEventToLlmResponse( interactionId: interactionId, }; } - } else if (deltaType === 'function_call') { - if (delta.name) { - let thoughtSignature: Uint8Array | undefined = undefined; - const thoughtSigValue = delta.signature || delta.thought_signature; - if (thoughtSigValue && typeof thoughtSigValue === 'string') { - if (isBrowser()) { - // eslint-disable-next-line no-undef - const binaryString = window.atob(thoughtSigValue); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - thoughtSignature = bytes; - } else { - thoughtSignature = Buffer.from(thoughtSigValue, 'base64'); - } - } - const part: Part = { - functionCall: { - id: delta.id || '', - name: delta.name, - args: delta.arguments || {}, - } as FunctionCall, - thoughtSignature: thoughtSignature as any, - }; - aggregatedParts.push(part); - return null; + } else if (delta.type === 'arguments_delta') { + const activePart = findLastPart( + aggregatedParts, + (p) => + !!(p.functionCall && p.partMetadata && !p.partMetadata.isComplete), + ); + if (activePart && activePart.partMetadata && delta.arguments) { + activePart.partMetadata.accumulatedArgs = + (activePart.partMetadata.accumulatedArgs as string) + delta.arguments; } - } else if (deltaType === 'image') { - if (delta.data || delta.uri) { - let part: Part; - if (delta.data) { - part = { - inlineData: { - data: delta.data, - mimeType: delta.mime_type, - }, - }; - } else { - part = { - fileData: { - fileUri: delta.uri, - mimeType: delta.mime_type, - }, - }; - } + return null; + } else if (delta.type === 'thought_signature') { + const activePart = findLastPart( + aggregatedParts, + (p) => + !!( + (p.thought || p.functionCall) && + p.partMetadata && + !p.partMetadata.isComplete + ), + ); + if (activePart && delta.signature) { + activePart.thoughtSignature = delta.signature; + } + return null; + } else if ( + delta.type === 'image' || + delta.type === 'audio' || + delta.type === 'video' || + delta.type === 'document' + ) { + const part = convertMediaContentToPart(delta as any); + if (part) { aggregatedParts.push(part); return { content: {role: 'model', parts: [part]}, @@ -628,21 +676,55 @@ export function convertInteractionEventToLlmResponse( }; } } - } else if (eventType === 'content.stop') { - if (aggregatedParts.length > 0) { - return { - content: {role: 'model', parts: [...aggregatedParts]}, - partial: false, - turnComplete: false, - interactionId: interactionId, - }; - } - } else if (eventType === 'interaction') { - return convertInteractionToLlmResponse( - event as unknown as ExtendedInteraction, + } else if (eventType === 'step.stop') { + const activePart = findLastPart( + aggregatedParts, + (p) => !!(p.partMetadata && !p.partMetadata.isComplete), ); + if (activePart && activePart.partMetadata) { + activePart.partMetadata.isComplete = true; + if (activePart.functionCall) { + const accumulatedArgs = activePart.partMetadata + .accumulatedArgs as string; + try { + activePart.functionCall.args = accumulatedArgs + ? JSON.parse(accumulatedArgs) + : {}; + } catch (e) { + logger.error( + `Failed to parse accumulated arguments: ${accumulatedArgs}`, + e, + ); + activePart.functionCall.args = {}; + } + delete activePart.partMetadata; + + return { + content: {role: 'model', parts: [activePart]}, + partial: false, + turnComplete: false, + interactionId: interactionId, + }; + } + if (activePart.thought) { + delete activePart.partMetadata; + return null; + } + } + } else if (eventType === 'interaction.completed') { + return { + content: + aggregatedParts.length > 0 + ? {role: 'model', parts: [...aggregatedParts]} + : undefined, + partial: false, + turnComplete: true, + finishReason: 'STOP' as FinishReason, + interactionId: interactionId, + }; } else if (eventType === 'interaction.status_update') { - const status = event.status; + const statusUpdate = event as unknown as ExtendedInteractionStatusUpdate; + const status = statusUpdate.status; if (status === 'completed' || status === 'requires_action') { return { content: @@ -655,7 +737,7 @@ export function convertInteractionEventToLlmResponse( interactionId: interactionId, }; } else if (status === 'failed') { - const error = event.error; + const error = statusUpdate.error; return { errorCode: error ? error.code : 'UNKNOWN_ERROR', errorMessage: error ? error.message : 'Unknown error', @@ -776,7 +858,12 @@ export async function* generateContentViaInteractions( contents = getLatestUserContents(contents); } - const inputTurns = convertContentsToTurns(contents); + const inputSteps: Interactions.Step[] = []; + if (contents) { + for (const content of contents) { + inputSteps.push(...convertContentToSteps(content)); + } + } const interactionTools = convertToolsConfigToInteractionsFormat( llmRequest.config || {}, ); @@ -791,9 +878,9 @@ export async function* generateContentViaInteractions( let currentInteractionId = previousInteractionId; if (stream) { - const responses = await apiClient.interactions.create({ + const responses: any = await apiClient.interactions.create({ model: llmRequest.model, - input: inputTurns, + input: inputSteps, stream: true, system_instruction: systemInstruction, tools: interactionTools.length > 0 ? interactionTools : undefined, @@ -831,7 +918,7 @@ export async function* generateContentViaInteractions( } else { const interaction = await apiClient.interactions.create({ model: llmRequest.model, - input: inputTurns, + input: inputSteps, stream: false, system_instruction: systemInstruction, tools: interactionTools.length > 0 ? interactionTools : undefined, diff --git a/core/test/models/interactions_utils_test.ts b/core/test/models/interactions_utils_test.ts index 1a80481d..1d141e69 100644 --- a/core/test/models/interactions_utils_test.ts +++ b/core/test/models/interactions_utils_test.ts @@ -12,16 +12,16 @@ import { FunctionCall, FunctionResponse, GenerateContentConfig, + Interactions, Outcome, Part, } from '@google/genai'; import {describe, expect, it, vi} from 'vitest'; import { - convertContentToTurn, + convertContentToSteps, convertInteractionEventToLlmResponse, - convertInteractionOutputToPart, convertInteractionToLlmResponse, - convertPartToInteractionContent, + convertStepToParts, convertToolsConfigToInteractionsFormat, extractSystemInstruction, generateContentViaInteractions, @@ -108,345 +108,479 @@ describe('interactions_utils', () => { }); }); - describe('convertPartToInteractionContent', () => { - it('should convert text part', () => { - const part: Part = {text: 'Hello'}; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'text', - text: 'Hello', - }); - }); - - it('should convert function call part', () => { - const part: Part = { - functionCall: { - name: 'test_tool', - args: {a: 1}, - id: 'call-123', - } as FunctionCall, + describe('convertContentToSteps', () => { + it('should convert text part to user_input step', () => { + const content: Content = { + role: 'user', + parts: [{text: 'Hello'}], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'function_call', - id: 'call-123', - name: 'test_tool', - arguments: {a: 1}, - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [{type: 'text', text: 'Hello'}], + }, + ]); }); - it('should convert function call part with missing id and args', () => { - const part: Part = { - functionCall: { - name: 'test_tool', - } as FunctionCall, + it('should convert function call part to function_call step', () => { + const content: Content = { + role: 'model', + parts: [ + { + functionCall: { + name: 'test_tool', + args: {a: 1}, + id: 'call-123', + } as FunctionCall, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'function_call', - id: '', - name: 'test_tool', - arguments: {}, - }); - }); - - it('should convert function call part with thought signature', () => { - const part: Part = { - functionCall: { - name: 'test_tool', - args: {a: 1}, + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_call', id: 'call-123', - } as FunctionCall, - thoughtSignature: Buffer.from('sig-data'), - }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'function_call', - id: 'call-123', - name: 'test_tool', - arguments: {a: 1}, - signature: 'c2lnLWRhdGE=', - }); - }); - - it('should convert function response part', () => { - const part: Part = { - functionResponse: { name: 'test_tool', - response: {result: 'ok'}, - id: 'call-123', - } as FunctionResponse, - }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'function_result', - name: 'test_tool', - call_id: 'call-123', - result: {result: 'ok'}, - }); + arguments: {a: 1}, + }, + ]); }); - it('should convert function response part with missing name and id', () => { - const part: Part = { - functionResponse: { - response: {result: 'ok'}, - } as FunctionResponse, + it('should convert function call part with missing id and args to function_call step', () => { + const content: Content = { + role: 'model', + parts: [ + { + functionCall: { + name: 'test_tool', + } as FunctionCall, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'function_result', - name: '', - call_id: '', - result: {result: 'ok'}, - }); - }); - - it('should convert function response part with primitive response', () => { - const part: Part = { - functionResponse: { + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_call', + id: '', name: 'test_tool', - response: true, - id: 'call-123', - } as FunctionResponse, - }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'function_result', - name: 'test_tool', - call_id: 'call-123', - result: 'true', - }); + arguments: {}, + }, + ]); }); - it('should convert inline image data', () => { - const part: Part = { - inlineData: { - data: 'base64data', - mimeType: 'image/png', - }, + it('should convert function call part with thought signature to function_call step', () => { + const content: Content = { + role: 'model', + parts: [ + { + functionCall: { + name: 'test_tool', + args: {a: 1}, + id: 'call-123', + } as FunctionCall, + thoughtSignature: 'sig-data-string', + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'image', - data: 'base64data', - mime_type: 'image/png', - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_call', + id: 'call-123', + name: 'test_tool', + arguments: {a: 1}, + signature: 'sig-data-string', + }, + ]); }); - it('should convert file image data', () => { - const part: Part = { - fileData: { - fileUri: 'gs://bucket/img.png', - mimeType: 'image/png', - }, + it('should convert function response part to function_result step', () => { + const content: Content = { + role: 'user', + parts: [ + { + functionResponse: { + name: 'test_tool', + response: {result: 'ok'}, + id: 'call-123', + } as FunctionResponse, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'image', - uri: 'gs://bucket/img.png', - mime_type: 'image/png', - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_result', + name: 'test_tool', + call_id: 'call-123', + result: {result: 'ok'}, + }, + ]); }); - it('should convert code execution result', () => { - const part: Part = { - codeExecutionResult: { - output: 'success output', - outcome: Outcome.OUTCOME_OK, - }, + it('should convert function response part with missing name and id to function_result step', () => { + const content: Content = { + role: 'user', + parts: [ + { + functionResponse: { + response: {result: 'ok'}, + } as FunctionResponse, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'code_execution_result', - call_id: '', - result: 'success output', - is_error: false, - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_result', + name: '', + call_id: '', + result: {result: 'ok'}, + }, + ]); }); - it('should convert executable code', () => { - const part: Part = { - executableCode: { - code: 'print("hello")', - language: 'PYTHON', - }, + it('should convert inline image data to user_input step with image content', () => { + const content: Content = { + role: 'user', + parts: [ + { + inlineData: { + data: 'base64data', + mimeType: 'image/png', + }, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'code_execution_call', - id: '', - arguments: { - code: 'print("hello")', - language: 'PYTHON', + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'image', + data: 'base64data', + mime_type: 'image/png', + } as any, + ], }, - }); + ]); }); - it('should convert thought part', () => { - const part: Part = { - thought: true, - thoughtSignature: Buffer.from('base64data'), - } as any; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'thought', - signature: 'YmFzZTY0ZGF0YQ==', - }); + it('should convert file image data to user_input step with image content', () => { + const content: Content = { + role: 'user', + parts: [ + { + fileData: { + fileUri: 'gs://bucket/img.png', + mimeType: 'image/png', + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'image', + uri: 'gs://bucket/img.png', + mime_type: 'image/png', + } as any, + ], + }, + ]); }); - it('should convert inline audio data', () => { - const part: Part = { - inlineData: { - data: 'audiodata', - mimeType: 'audio/mp3', - }, + it('should convert code execution result to code_execution_result step', () => { + const content: Content = { + role: 'user', + parts: [ + { + codeExecutionResult: { + output: 'success output', + outcome: Outcome.OUTCOME_OK, + }, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'audio', - data: 'audiodata', - mime_type: 'audio/mp3', - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'code_execution_result', + call_id: '', + result: 'success output', + is_error: false, + }, + ]); }); - it('should convert inline video data', () => { - const part: Part = { - inlineData: { - data: 'videodata', - mimeType: 'video/mp4', - }, + it('should convert executable code to code_execution_call step', () => { + const content: Content = { + role: 'model', + parts: [ + { + executableCode: { + code: 'print("hello")', + language: 'PYTHON', + }, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'video', - data: 'videodata', - mime_type: 'video/mp4', - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'code_execution_call', + id: '', + arguments: { + code: 'print("hello")', + language: 'python', + }, + }, + ]); }); - it('should convert inline document data', () => { - const part: Part = { - inlineData: { - data: 'docdata', - mimeType: 'application/pdf', - }, + it('should convert thought part to thought step', () => { + const content: Content = { + role: 'model', + parts: [ + { + thought: true, + thoughtSignature: 'sig-data-string', + } as any, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'document', - data: 'docdata', - mime_type: 'application/pdf', - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'thought', + signature: 'sig-data-string', + }, + ]); }); - it('should convert file audio data', () => { - const part: Part = { - fileData: { - fileUri: 'gs://bucket/audio.mp3', - mimeType: 'audio/mp3', - }, + it('should convert inline audio data to user_input step with audio content', () => { + const content: Content = { + role: 'user', + parts: [ + { + inlineData: { + data: 'audiodata', + mimeType: 'audio/mp3', + }, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'audio', - uri: 'gs://bucket/audio.mp3', - mime_type: 'audio/mp3', - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'audio', + data: 'audiodata', + mime_type: 'audio/mp3', + } as any, + ], + }, + ]); }); - it('should convert file video data', () => { - const part: Part = { - fileData: { - fileUri: 'gs://bucket/video.mp4', - mimeType: 'video/mp4', - }, + it('should convert inline video data to user_input step with video content', () => { + const content: Content = { + role: 'user', + parts: [ + { + inlineData: { + data: 'videodata', + mimeType: 'video/mp4', + }, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'video', - uri: 'gs://bucket/video.mp4', - mime_type: 'video/mp4', - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'video', + data: 'videodata', + mime_type: 'video/mp4', + } as any, + ], + }, + ]); }); - it('should convert file document data', () => { - const part: Part = { - fileData: { - fileUri: 'gs://bucket/doc.pdf', - mimeType: 'application/pdf', - }, + it('should convert inline document data to user_input step with document content', () => { + const content: Content = { + role: 'user', + parts: [ + { + inlineData: { + data: 'docdata', + mimeType: 'application/pdf', + }, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'document', - uri: 'gs://bucket/doc.pdf', - mime_type: 'application/pdf', - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'document', + data: 'docdata', + mime_type: 'application/pdf', + } as any, + ], + }, + ]); }); - it('should convert function call part with string thought signature', () => { - const part: Part = { - functionCall: { - name: 'test_tool', - args: {a: 1}, - id: 'call-123', - } as FunctionCall, - thoughtSignature: 'sig-data-string' as any, + it('should convert file audio data to user_input step with audio content', () => { + const content: Content = { + role: 'user', + parts: [ + { + fileData: { + fileUri: 'gs://bucket/audio.mp3', + mimeType: 'audio/mp3', + }, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'function_call', - id: 'call-123', - name: 'test_tool', - arguments: {a: 1}, - signature: 'c2lnLWRhdGEtc3RyaW5n', - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'audio', + uri: 'gs://bucket/audio.mp3', + mime_type: 'audio/mp3', + } as any, + ], + }, + ]); }); - it('should convert function call part with thought signature in browser environment', () => { - const originalWindow = global.window; - (global as any).window = { - btoa: (str: string) => Buffer.from(str, 'binary').toString('base64'), + it('should convert file video data to user_input step with video content', () => { + const content: Content = { + role: 'user', + parts: [ + { + fileData: { + fileUri: 'gs://bucket/video.mp4', + mimeType: 'video/mp4', + }, + }, + ], }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'video', + uri: 'gs://bucket/video.mp4', + mime_type: 'video/mp4', + } as any, + ], + }, + ]); + }); - const part: Part = { - functionCall: { - name: 'test_tool', - args: {a: 1}, - id: 'call-123', - } as FunctionCall, - thoughtSignature: new TextEncoder().encode('sig-data-browser') as any, + it('should convert file document data to user_input step with document content', () => { + const content: Content = { + role: 'user', + parts: [ + { + fileData: { + fileUri: 'gs://bucket/doc.pdf', + mimeType: 'application/pdf', + }, + }, + ], }; - - const result = convertPartToInteractionContent(part); - expect(result?.signature).toBe('c2lnLWRhdGEtYnJvd3Nlcg=='); - - (global as any).window = originalWindow; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'document', + uri: 'gs://bucket/doc.pdf', + mime_type: 'application/pdf', + } as any, + ], + }, + ]); }); it('should convert inlineData with missing mimeType to document', () => { - const part: Part = { - inlineData: { - data: 'docdata', - }, + const content: Content = { + role: 'user', + parts: [ + { + inlineData: { + data: 'docdata', + }, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'document', - data: 'docdata', - mime_type: '', - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'document', + data: 'docdata', + mime_type: '', + } as any, + ], + }, + ]); }); it('should convert fileData with missing mimeType to document', () => { - const part: Part = { - fileData: { - fileUri: 'gs://bucket/doc.pdf', - }, + const content: Content = { + role: 'user', + parts: [ + { + fileData: { + fileUri: 'gs://bucket/doc.pdf', + }, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'document', - uri: 'gs://bucket/doc.pdf', - mime_type: '', - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'document', + uri: 'gs://bucket/doc.pdf', + mime_type: '', + } as any, + ], + }, + ]); }); it('should convert codeExecutionResult with missing output', () => { - const part: Part = { - codeExecutionResult: { - outcome: Outcome.OUTCOME_OK, - }, + const content: Content = { + role: 'user', + parts: [ + { + codeExecutionResult: { + outcome: Outcome.OUTCOME_OK, + }, + }, + ], }; - expect(convertPartToInteractionContent(part)).toEqual({ - type: 'code_execution_result', - call_id: '', - result: '', - is_error: false, - }); + expect(convertContentToSteps(content)).toEqual([ + { + type: 'code_execution_result', + call_id: '', + result: '', + is_error: false, + }, + ]); }); - it('should return null for empty or invalid part', () => { - expect(convertPartToInteractionContent({})).toBeNull(); + it('should return empty steps for empty or invalid content', () => { + expect(convertContentToSteps({})).toEqual([]); + expect(convertContentToSteps({parts: []})).toEqual([]); }); }); @@ -581,14 +715,19 @@ describe('interactions_utils', () => { const interaction = { id: 'int-123', status: 'completed', - outputs: [{type: 'text', text: 'Response text'}], + steps: [ + { + type: 'model_output', + content: [{type: 'text', text: 'Response text'}], + } as Interactions.ModelOutputStep, + ], usage: { total_input_tokens: 10, total_output_tokens: 20, }, }; - const response = convertInteractionToLlmResponse(interaction); + const response = convertInteractionToLlmResponse(interaction as any); expect(response.interactionId).toBe('int-123'); expect(response.turnComplete).toBe(true); @@ -612,7 +751,7 @@ describe('interactions_utils', () => { }, }; - const response = convertInteractionToLlmResponse(interaction); + const response = convertInteractionToLlmResponse(interaction as any); expect(response.interactionId).toBe('int-123'); expect(response.errorCode).toBe('RESOURCE_EXHAUSTED'); @@ -625,7 +764,7 @@ describe('interactions_utils', () => { status: 'failed', error: {}, }; - const response = convertInteractionToLlmResponse(interaction); + const response = convertInteractionToLlmResponse(interaction as any); expect(response.errorCode).toBe('UNKNOWN_ERROR'); expect(response.errorMessage).toBe('Unknown error'); }); @@ -636,7 +775,7 @@ describe('interactions_utils', () => { status: 'completed', usage: {}, }; - const response = convertInteractionToLlmResponse(interaction); + const response = convertInteractionToLlmResponse(interaction as any); expect(response.usageMetadata).toEqual({ promptTokenCount: 0, candidatesTokenCount: 0, @@ -649,16 +788,16 @@ describe('interactions_utils', () => { id: 'int-123', status: 'requires_action', }; - const response = convertInteractionToLlmResponse(interaction); + const response = convertInteractionToLlmResponse(interaction as any); expect(response.turnComplete).toBe(true); expect(response.finishReason).toBe('STOP'); }); }); describe('convertInteractionEventToLlmResponse', () => { - it('should handle content.delta text event', () => { + it('should handle step.delta text event', () => { const event = { - event_type: 'content.delta', + event_type: 'step.delta', delta: { type: 'text', text: 'hello', @@ -666,7 +805,7 @@ describe('interactions_utils', () => { }; const aggregatedParts: Part[] = []; const response = convertInteractionEventToLlmResponse( - event, + event as any, aggregatedParts, 'int-1', ); @@ -680,64 +819,122 @@ describe('interactions_utils', () => { expect(aggregatedParts).toEqual([{text: 'hello'}]); }); - it('should accumulate function call delta without yielding immediately', () => { - const event = { - event_type: 'content.delta', - delta: { + it('should handle step.start, step.delta (arguments), and step.stop sequence for function call', () => { + const aggregatedParts: Part[] = []; + + // 1. Step Start + const startEvent = { + event_type: 'step.start', + step: { type: 'function_call', - name: 'my_tool', - arguments: {x: 1}, id: 'call-1', + name: 'my_tool', }, }; - const aggregatedParts: Part[] = []; - const response = convertInteractionEventToLlmResponse( - event, + let response = convertInteractionEventToLlmResponse( + startEvent as any, aggregatedParts, 'int-1', ); - expect(response).toBeNull(); - expect(aggregatedParts).toEqual([ - { - functionCall: { - id: 'call-1', - name: 'my_tool', - args: {x: 1}, - } as FunctionCall, - thoughtSignature: undefined, + expect(aggregatedParts.length).toBe(1); + expect(aggregatedParts[0].functionCall).toEqual({ + id: 'call-1', + name: 'my_tool', + args: {}, + }); + expect(aggregatedParts[0].partMetadata).toEqual({ + accumulatedArgs: '', + isComplete: false, + }); + + // 2. Step Delta (arguments chunk 1) + const deltaEvent1 = { + event_type: 'step.delta', + delta: { + type: 'arguments_delta', + arguments: '{"x":', }, - ]); - }); + }; + response = convertInteractionEventToLlmResponse( + deltaEvent1 as any, + aggregatedParts, + 'int-1', + ); + expect(response).toBeNull(); + expect(aggregatedParts[0].partMetadata?.accumulatedArgs).toBe('{"x":'); - it('should handle content.delta function_call with missing delta id', () => { - const event = { - event_type: 'content.delta', + // 3. Step Delta (arguments chunk 2) + const deltaEvent2 = { + event_type: 'step.delta', delta: { - type: 'function_call', - name: 'my_tool', + type: 'arguments_delta', + arguments: ' 1}', }, }; - const aggregatedParts: Part[] = []; - convertInteractionEventToLlmResponse(event, aggregatedParts, 'int-1'); - expect(aggregatedParts[0].functionCall?.id).toBe(''); - }); + response = convertInteractionEventToLlmResponse( + deltaEvent2 as any, + aggregatedParts, + 'int-1', + ); + expect(response).toBeNull(); + expect(aggregatedParts[0].partMetadata?.accumulatedArgs).toBe('{"x": 1}'); - it('should handle content.stop event and return aggregated parts', () => { - const event = {event_type: 'content.stop'}; - const aggregatedParts: Part[] = [{text: 'hello '}, {text: 'world'}]; - const response = convertInteractionEventToLlmResponse( - event, + // 4. Step Stop + const stopEvent = { + event_type: 'step.stop', + }; + response = convertInteractionEventToLlmResponse( + stopEvent as any, aggregatedParts, 'int-1', ); expect(response).toEqual({ - content: {role: 'model', parts: [{text: 'hello '}, {text: 'world'}]}, + content: { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-1', + name: 'my_tool', + args: {x: 1}, + }, + }, + ], + }, partial: false, turnComplete: false, interactionId: 'int-1', }); + expect(aggregatedParts[0].partMetadata).toBeUndefined(); // metadata should be cleaned up + expect(aggregatedParts[0].functionCall?.args).toEqual({x: 1}); + }); + + it('should handle step.start thought event', () => { + const aggregatedParts: Part[] = []; + const event = { + event_type: 'step.start', + step: { + type: 'thought', + signature: 'sig-123', + }, + }; + const response = convertInteractionEventToLlmResponse( + event as any, + aggregatedParts, + 'int-1', + ); + expect(response).toBeNull(); + expect(aggregatedParts).toEqual([ + { + thought: true, + thoughtSignature: 'sig-123', + partMetadata: { + isComplete: false, + }, + }, + ]); }); it('should handle interaction.status_update completed event', () => { @@ -747,7 +944,7 @@ describe('interactions_utils', () => { }; const aggregatedParts: Part[] = [{text: 'final text'}]; const response = convertInteractionEventToLlmResponse( - event, + event as any, aggregatedParts, 'int-1', ); @@ -767,7 +964,12 @@ describe('interactions_utils', () => { const mockInteraction = { id: 'int-999', status: 'completed', - outputs: [{type: 'text', text: 'Mocked static response'}], + steps: [ + { + type: 'model_output', + content: [{type: 'text', text: 'Mocked static response'}], + }, + ], }; const mockApiClient = { @@ -801,7 +1003,7 @@ describe('interactions_utils', () => { model: 'gemini-2.5-flash', input: [ { - role: 'user', + type: 'user_input', content: [{type: 'text', text: 'Hello'}], }, ], @@ -816,21 +1018,29 @@ describe('interactions_utils', () => { it('should handle streaming call', async () => { const mockEvents = [ { - event_type: 'content.delta', - delta: {type: 'text', text: 'Part 1'}, + event_type: 'step.start', + step: { + type: 'model_output', + content: [], + }, interaction_id: 'int-stream', }, { - event_type: 'content.delta', + event_type: 'step.delta', + delta: {type: 'text', text: 'Part 1'}, + }, + { + event_type: 'step.delta', delta: {type: 'text', text: 'Part 2'}, }, { - event_type: 'interaction.status_update', - status: 'completed', + event_type: 'step.stop', + }, + { + event_type: 'interaction.completed', }, ]; - // Create an async iterable mock const mockStream = { [Symbol.asyncIterator]: async function* () { for (const event of mockEvents) { @@ -860,11 +1070,6 @@ describe('interactions_utils', () => { responses.push(res); } - // We expect: - // 1. Response for Part 1 delta (partial: true) - // 2. Response for Part 2 delta (partial: true) - // 3. Response for status_update completed (turnComplete: true) - // 4. Final aggregated response yielded at the end of generator expect(responses.length).toBe(4); expect(responses[0]).toEqual({ @@ -902,7 +1107,12 @@ describe('interactions_utils', () => { const mockInteraction = { id: 'int-999', status: 'completed', - outputs: [{type: 'text', text: 'Mocked response'}], + steps: [ + { + type: 'model_output', + content: [{type: 'text', text: 'Mocked response'}], + }, + ], }; const mockApiClient = { @@ -936,7 +1146,7 @@ describe('interactions_utils', () => { model: 'gemini-2.5-flash', input: [ { - role: 'user', + type: 'user_input', content: [{type: 'text', text: 'Turn 2'}], }, ], @@ -951,14 +1161,22 @@ describe('interactions_utils', () => { it('should handle streaming call with interaction event and extract interaction ID', async () => { const mockEvents = [ { - event_type: 'content.delta', + event_type: 'step.start', + step: { + type: 'model_output', + content: [], + }, + }, + { + event_type: 'step.delta', delta: {type: 'text', text: 'Stream text'}, + interaction_id: 'int-from-event', + }, + { + event_type: 'step.stop', }, { - event_type: 'interaction', - id: 'int-from-event', - status: 'completed', - outputs: [{type: 'text', text: 'Stream text'}], + event_type: 'interaction.completed', }, ]; @@ -997,7 +1215,7 @@ describe('interactions_utils', () => { content: {role: 'model', parts: [{text: 'Stream text'}]}, partial: true, turnComplete: false, - interactionId: undefined, + interactionId: 'int-from-event', }); expect(responses[1].interactionId).toBe('int-from-event'); @@ -1010,7 +1228,12 @@ describe('interactions_utils', () => { const mockInteraction = { id: 'int-999', status: 'completed', - outputs: [{type: 'text', text: 'Mocked response'}], + steps: [ + { + type: 'model_output', + content: [{type: 'text', text: 'Mocked response'}], + }, + ], }; const mockApiClient = { @@ -1047,7 +1270,7 @@ describe('interactions_utils', () => { model: 'gemini-2.5-flash', input: [ { - role: 'user', + type: 'user_input', content: [{type: 'text', text: 'Hello'}], }, ], @@ -1076,7 +1299,14 @@ describe('interactions_utils', () => { const mockStream = { [Symbol.asyncIterator]: async function* () { yield { - event_type: 'content.delta', + event_type: 'step.start', + step: { + type: 'model_output', + content: [], + }, + }; + yield { + event_type: 'step.delta', delta: {type: 'text', text: 'Reply'}, }; }, @@ -1110,7 +1340,7 @@ describe('interactions_utils', () => { model: 'gemini-2.5-flash', input: [ { - role: 'user', + type: 'user_input', content: [{type: 'text', text: 'Hello'}], }, ], @@ -1130,263 +1360,288 @@ describe('interactions_utils', () => { }); }); - describe('convertInteractionOutputToPart', () => { - it('should return null for empty or invalid output', () => { - expect(convertInteractionOutputToPart(null)).toBeNull(); - expect(convertInteractionOutputToPart({})).toBeNull(); - expect(convertInteractionOutputToPart({type: 'invalid'})).toBeNull(); - }); - - it('should convert text output', () => { - expect( - convertInteractionOutputToPart({type: 'text', text: 'hello'}), - ).toEqual({ - text: 'hello', - }); - }); - - it('should convert text output with missing text', () => { - expect(convertInteractionOutputToPart({type: 'text'})).toEqual({ - text: '', - }); + describe('convertStepToParts', () => { + it('should return empty array for empty or invalid step', () => { + expect(convertStepToParts(null as any)).toEqual([]); + expect(convertStepToParts({} as any)).toEqual([]); + expect(convertStepToParts({type: 'invalid'} as any)).toEqual([]); }); - it('should convert function_call output', () => { - const output = { - type: 'function_call', - id: 'call-1', - name: 'my_tool', - arguments: {a: 1}, + it('should convert model_output step with text content', () => { + const step = { + type: 'model_output', + content: [{type: 'text', text: 'hello'}], }; - expect(convertInteractionOutputToPart(output)).toEqual({ - functionCall: { - id: 'call-1', - name: 'my_tool', - args: {a: 1}, + expect(convertStepToParts(step as any)).toEqual([ + { + text: 'hello', }, - thoughtSignature: undefined, - }); + ]); }); - it('should convert function_call output with missing arguments', () => { - const output = { - type: 'function_call', - id: 'call-1', - name: 'my_tool', + it('should convert model_output step with text content missing text', () => { + const step = { + type: 'model_output', + content: [{type: 'text'}], }; - expect(convertInteractionOutputToPart(output)).toEqual({ - functionCall: { - id: 'call-1', - name: 'my_tool', - args: {}, + expect(convertStepToParts(step as any)).toEqual([ + { + text: '', }, - thoughtSignature: undefined, - }); + ]); }); - it('should convert function_call output with non-string thought_signature', () => { - const output = { + it('should convert function_call step', () => { + const step = { type: 'function_call', id: 'call-1', name: 'my_tool', - signature: 123 as any, + arguments: {a: 1}, }; - const part = convertInteractionOutputToPart(output); - expect(part?.thoughtSignature).toBeUndefined(); + expect(convertStepToParts(step as any)).toEqual([ + { + functionCall: { + id: 'call-1', + name: 'my_tool', + args: {a: 1}, + }, + }, + ]); }); - it('should convert function_call output with thought_signature in browser environment', () => { - const originalWindow = global.window; - (global as any).window = { - atob: (str: string) => Buffer.from(str, 'base64').toString('binary'), - }; - - const output = { + it('should convert function_call step with missing arguments', () => { + const step = { type: 'function_call', id: 'call-1', name: 'my_tool', - arguments: {a: 1}, - signature: 'YmFzZTY0ZGF0YQ==', }; - - const part = convertInteractionOutputToPart(output); - expect(part?.thoughtSignature).toBeInstanceOf(Uint8Array); - expect(Buffer.from(part?.thoughtSignature as any).toString()).toBe( - 'base64data', - ); - - (global as any).window = originalWindow; + expect(convertStepToParts(step as any)).toEqual([ + { + functionCall: { + id: 'call-1', + name: 'my_tool', + args: {}, + }, + }, + ]); }); - it('should convert function_call output with thought_signature in Node.js environment', () => { - const output = { + it('should convert function_call step with signature', () => { + const step = { type: 'function_call', id: 'call-1', name: 'my_tool', arguments: {a: 1}, - signature: 'YmFzZTY0ZGF0YQ==', + signature: 'sig-123', }; - const part = convertInteractionOutputToPart(output); - expect(part?.thoughtSignature).toBeInstanceOf(Buffer); - expect(part?.thoughtSignature?.toString()).toBe('base64data'); + expect(convertStepToParts(step as any)).toEqual([ + { + functionCall: { + id: 'call-1', + name: 'my_tool', + args: {a: 1}, + }, + thoughtSignature: 'sig-123', + }, + ]); }); - it('should convert function_result output', () => { - const output = { + it('should convert function_result step', () => { + const step = { type: 'function_result', call_id: 'call-1', result: {res: 'ok'}, }; - expect(convertInteractionOutputToPart(output)).toEqual({ - functionResponse: { - id: 'call-1', - response: {res: 'ok'}, + expect(convertStepToParts(step as any)).toEqual([ + { + functionResponse: { + id: 'call-1', + name: '', + response: {res: 'ok'}, + }, }, - }); + ]); }); - it('should convert image output (data)', () => { - const output = { - type: 'image', - data: 'base64data', - mime_type: 'image/png', + it('should convert model_output step with image content (data)', () => { + const step = { + type: 'model_output', + content: [ + { + type: 'image', + data: 'base64data', + mime_type: 'image/png', + }, + ], }; - expect(convertInteractionOutputToPart(output)).toEqual({ - inlineData: { - data: 'base64data', - mimeType: 'image/png', + expect(convertStepToParts(step as any)).toEqual([ + { + inlineData: { + data: 'base64data', + mimeType: 'image/png', + }, }, - }); + ]); }); - it('should convert image output (uri)', () => { - const output = { - type: 'image', - uri: 'gs://bucket/img.png', - mime_type: 'image/png', + it('should convert model_output step with image content (uri)', () => { + const step = { + type: 'model_output', + content: [ + { + type: 'image', + uri: 'gs://bucket/img.png', + mime_type: 'image/png', + }, + ], }; - expect(convertInteractionOutputToPart(output)).toEqual({ - fileData: { - fileUri: 'gs://bucket/img.png', - mimeType: 'image/png', + expect(convertStepToParts(step as any)).toEqual([ + { + fileData: { + fileUri: 'gs://bucket/img.png', + mimeType: 'image/png', + }, }, - }); + ]); }); - it('should convert audio output (data)', () => { - const output = { - type: 'audio', - data: 'base64data', - mime_type: 'audio/mp3', + it('should convert model_output step with audio content (data)', () => { + const step = { + type: 'model_output', + content: [ + { + type: 'audio', + data: 'base64data', + mime_type: 'audio/mp3', + }, + ], }; - expect(convertInteractionOutputToPart(output)).toEqual({ - inlineData: { - data: 'base64data', - mimeType: 'audio/mp3', + expect(convertStepToParts(step as any)).toEqual([ + { + inlineData: { + data: 'base64data', + mimeType: 'audio/mp3', + }, }, - }); + ]); }); - it('should convert audio output (uri)', () => { - const output = { - type: 'audio', - uri: 'gs://bucket/audio.mp3', - mime_type: 'audio/mp3', + it('should convert model_output step with audio content (uri)', () => { + const step = { + type: 'model_output', + content: [ + { + type: 'audio', + uri: 'gs://bucket/audio.mp3', + mime_type: 'audio/mp3', + }, + ], }; - expect(convertInteractionOutputToPart(output)).toEqual({ - fileData: { - fileUri: 'gs://bucket/audio.mp3', - mimeType: 'audio/mp3', + expect(convertStepToParts(step as any)).toEqual([ + { + fileData: { + fileUri: 'gs://bucket/audio.mp3', + mimeType: 'audio/mp3', + }, }, - }); + ]); }); - it('should return null for thought output', () => { - expect(convertInteractionOutputToPart({type: 'thought'})).toBeNull(); + it('should convert thought step', () => { + const step = { + type: 'thought', + signature: 'sig-123', + }; + expect(convertStepToParts(step as any)).toEqual([ + { + thought: true, + thoughtSignature: 'sig-123', + }, + ]); }); - it('should convert code_execution_result output', () => { - const output = { + it('should convert code_execution_result step', () => { + const step = { type: 'code_execution_result', result: 'output text', is_error: false, }; - expect(convertInteractionOutputToPart(output)).toEqual({ - codeExecutionResult: { - output: 'output text', - outcome: Outcome.OUTCOME_OK, + expect(convertStepToParts(step as any)).toEqual([ + { + codeExecutionResult: { + output: 'output text', + outcome: Outcome.OUTCOME_OK, + }, }, - }); + ]); - const outputError = { + const stepError = { type: 'code_execution_result', result: 'error text', is_error: true, }; - expect(convertInteractionOutputToPart(outputError)).toEqual({ - codeExecutionResult: { - output: 'error text', - outcome: Outcome.OUTCOME_FAILED, + expect(convertStepToParts(stepError as any)).toEqual([ + { + codeExecutionResult: { + output: 'error text', + outcome: Outcome.OUTCOME_FAILED, + }, }, - }); + ]); }); - it('should convert code_execution_result output with missing result', () => { - const output = { + it('should convert code_execution_result step with missing result', () => { + const step = { type: 'code_execution_result', is_error: false, }; - expect(convertInteractionOutputToPart(output)).toEqual({ - codeExecutionResult: { - output: '', - outcome: Outcome.OUTCOME_OK, + expect(convertStepToParts(step as any)).toEqual([ + { + codeExecutionResult: { + output: '', + outcome: Outcome.OUTCOME_OK, + }, }, - }); + ]); }); - it('should convert code_execution_call output', () => { - const output = { + it('should convert code_execution_call step', () => { + const step = { type: 'code_execution_call', arguments: { code: 'print(1)', language: 'PYTHON', }, }; - expect(convertInteractionOutputToPart(output)).toEqual({ - executableCode: { - code: 'print(1)', - language: 'PYTHON', + expect(convertStepToParts(step as any)).toEqual([ + { + executableCode: { + code: 'print(1)', + language: 'PYTHON', + }, }, - }); + ]); }); - it('should convert code_execution_call output with missing arguments', () => { - const output = { + it('should convert code_execution_call step with missing arguments', () => { + const step = { type: 'code_execution_call', }; - expect(convertInteractionOutputToPart(output)).toEqual({ - executableCode: { - code: '', - language: 'PYTHON', + expect(convertStepToParts(step as any)).toEqual([ + { + executableCode: { + code: '', + language: 'PYTHON', + }, }, - }); - }); - - it('should convert google_search_result output', () => { - const output = { - type: 'google_search_result', - result: [{title: 'res1', url: 'url1'}, 'plain text result'], - }; - expect(convertInteractionOutputToPart(output)).toEqual({ - text: '{"title":"res1","url":"url1"}\nplain text result', - }); + ]); }); }); describe('convertInteractionEventToLlmResponse extra cases', () => { - it('should handle content.delta image event (data)', () => { + it('should handle step.delta image event (data)', () => { const event = { - event_type: 'content.delta', + event_type: 'step.delta', delta: { type: 'image', data: 'imgdata', @@ -1395,7 +1650,7 @@ describe('interactions_utils', () => { }; const aggregatedParts: Part[] = []; const response = convertInteractionEventToLlmResponse( - event, + event as any, aggregatedParts, 'int-1', ); @@ -1417,9 +1672,9 @@ describe('interactions_utils', () => { }); }); - it('should handle content.delta image event (uri)', () => { + it('should handle step.delta image event (uri)', () => { const event = { - event_type: 'content.delta', + event_type: 'step.delta', delta: { type: 'image', uri: 'gs://img.png', @@ -1428,7 +1683,7 @@ describe('interactions_utils', () => { }; const aggregatedParts: Part[] = []; const response = convertInteractionEventToLlmResponse( - event, + event as any, aggregatedParts, 'int-1', ); @@ -1459,7 +1714,11 @@ describe('interactions_utils', () => { message: 'user cancelled', }, }; - const response = convertInteractionEventToLlmResponse(event, [], 'int-1'); + const response = convertInteractionEventToLlmResponse( + event as any, + [], + 'int-1', + ); expect(response).toEqual({ errorCode: 'CANCELLED', errorMessage: 'user cancelled', @@ -1473,7 +1732,11 @@ describe('interactions_utils', () => { event_type: 'interaction.status_update', status: 'failed', }; - const response = convertInteractionEventToLlmResponse(event, [], 'int-1'); + const response = convertInteractionEventToLlmResponse( + event as any, + [], + 'int-1', + ); expect(response).toEqual({ errorCode: 'UNKNOWN_ERROR', errorMessage: 'Unknown error', @@ -1489,7 +1752,7 @@ describe('interactions_utils', () => { }; const parts = [{text: 'part 1'}]; const response = convertInteractionEventToLlmResponse( - event, + event as any, parts, 'int-1', ); @@ -1508,7 +1771,11 @@ describe('interactions_utils', () => { code: 'INTERNAL', message: 'internal error', }; - const response = convertInteractionEventToLlmResponse(event, [], 'int-1'); + const response = convertInteractionEventToLlmResponse( + event as any, + [], + 'int-1', + ); expect(response).toEqual({ errorCode: 'INTERNAL', errorMessage: 'internal error', @@ -1521,7 +1788,11 @@ describe('interactions_utils', () => { const event = { event_type: 'error', }; - const response = convertInteractionEventToLlmResponse(event, [], 'int-1'); + const response = convertInteractionEventToLlmResponse( + event as any, + [], + 'int-1', + ); expect(response).toEqual({ errorCode: 'UNKNOWN_ERROR', errorMessage: 'Unknown error', @@ -1530,72 +1801,51 @@ describe('interactions_utils', () => { }); }); - it('should return null if event.delta is missing in content.delta event', () => { + it('should return null if event.delta is missing in step.delta event', () => { const event = { - event_type: 'content.delta', + event_type: 'step.delta', }; - expect(convertInteractionEventToLlmResponse(event, [])).toBeNull(); + expect(convertInteractionEventToLlmResponse(event as any, [])).toBeNull(); }); - it('should handle content.delta function_call with thought_signature in browser environment', () => { - const originalWindow = global.window; - (global as any).window = { - atob: (str: string) => Buffer.from(str, 'base64').toString('binary'), - }; - - const event = { - event_type: 'content.delta', - delta: { + it('should handle step.delta thought_signature event', () => { + // 1. Start the function call step + const startEvent = { + event_type: 'step.start', + step: { type: 'function_call', - name: 'my_tool', - thought_signature: 'YmFzZTY0ZGF0YQ==', id: 'call-1', + name: 'my_tool', }, }; const aggregatedParts: Part[] = []; - const response = convertInteractionEventToLlmResponse( - event, - aggregatedParts, - 'int-1', - ); - - expect(response).toBeNull(); - expect(aggregatedParts[0].thoughtSignature).toBeDefined(); - expect(aggregatedParts[0].thoughtSignature).toBeInstanceOf(Uint8Array); - expect( - Buffer.from(aggregatedParts[0].thoughtSignature as any).toString(), - ).toBe('base64data'); + convertInteractionEventToLlmResponse(startEvent as any, aggregatedParts); - (global as any).window = originalWindow; - }); + expect(aggregatedParts.length).toBe(1); + expect(aggregatedParts[0].functionCall).toBeDefined(); + expect(aggregatedParts[0].thoughtSignature).toBeUndefined(); - it('should handle content.delta function_call with thought_signature in Node.js environment', () => { - const event = { - event_type: 'content.delta', + // 2. Stream the signature delta + const deltaEvent = { + event_type: 'step.delta', delta: { - type: 'function_call', - name: 'my_tool', - thought_signature: 'YmFzZTY0ZGF0YQ==', - id: 'call-1', + type: 'thought_signature', + signature: 'my-signature-data', }, }; - const aggregatedParts: Part[] = []; const response = convertInteractionEventToLlmResponse( - event, + deltaEvent as any, aggregatedParts, 'int-1', ); expect(response).toBeNull(); - expect(aggregatedParts[0].thoughtSignature).toBeInstanceOf(Buffer); - expect(aggregatedParts[0].thoughtSignature?.toString()).toBe( - 'base64data', - ); + expect(aggregatedParts[0].thoughtSignature).toBe('my-signature-data'); }); it('should handle event with camelCase eventType', () => { const event = { - eventType: 'content.delta', + eventType: 'step.delta', delta: { type: 'text', text: 'camelText', @@ -1603,52 +1853,39 @@ describe('interactions_utils', () => { }; const aggregatedParts: Part[] = []; const response = convertInteractionEventToLlmResponse( - event, + event as any, aggregatedParts, 'int-1', ); expect(response?.content?.parts?.[0]?.text).toBe('camelText'); }); - it('should handle content.delta text event with missing text', () => { + it('should handle step.delta text event with missing text', () => { const event = { - event_type: 'content.delta', + event_type: 'step.delta', delta: { type: 'text', }, }; const aggregatedParts: Part[] = []; const response = convertInteractionEventToLlmResponse( - event, + event as any, aggregatedParts, 'int-1', ); expect(response).toBeNull(); }); - it('should handle interaction event type', () => { - const event = { - event_type: 'interaction', - id: 'int-123', - status: 'completed', - outputs: [{type: 'text', text: 'final'}], - }; - const response = convertInteractionEventToLlmResponse(event, [], 'int-1'); - expect(response).toEqual({ - content: {role: 'model', parts: [{text: 'final'}]}, - turnComplete: true, - finishReason: 'STOP', - interactionId: 'int-123', - usageMetadata: undefined, - }); - }); - it('should handle interaction.status_update requires_action event', () => { const event = { event_type: 'interaction.status_update', status: 'requires_action', }; - const response = convertInteractionEventToLlmResponse(event, [], 'int-1'); + const response = convertInteractionEventToLlmResponse( + event as any, + [], + 'int-1', + ); expect(response).toEqual({ content: undefined, partial: false, @@ -1660,19 +1897,150 @@ describe('interactions_utils', () => { it('should return null for unknown event type', () => { const event = {event_type: 'unknown'}; - expect(convertInteractionEventToLlmResponse(event, [])).toBeNull(); + expect(convertInteractionEventToLlmResponse(event as any, [])).toBeNull(); }); }); - describe('convertContentToTurn', () => { - it('should convert Content to Turn with default role', () => { + describe('convertContentToSteps', () => { + it('should convert user Content with text parts to user_input step', () => { const content: Content = { - parts: [{text: 'Hello'}], + role: 'user', + parts: [{text: 'Hello'}, {text: 'World'}], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + {type: 'text', text: 'Hello'}, + {type: 'text', text: 'World'}, + ], + }, + ]); + }); + + it('should convert user Content with functionResponse to function_result step', () => { + const content: Content = { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call-1', + name: 'my_tool', + response: {result: 'ok'}, + }, + }, + ], }; - expect(convertContentToTurn(content)).toEqual({ + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_result', + call_id: 'call-1', + name: 'my_tool', + result: {result: 'ok'}, + }, + ]); + }); + + it('should convert user Content with codeExecutionResult to code_execution_result step', () => { + const content: Content = { role: 'user', - content: [{type: 'text', text: 'Hello'}], - }); + parts: [ + { + codeExecutionResult: { + output: 'compiled output', + outcome: Outcome.OUTCOME_OK, + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'code_execution_result', + call_id: '', + result: 'compiled output', + is_error: false, + }, + ]); + }); + + it('should convert model Content with text parts to model_output step', () => { + const content: Content = { + role: 'model', + parts: [{text: 'Hello'}], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'model_output', + content: [{type: 'text', text: 'Hello'}], + }, + ]); + }); + + it('should convert model Content with functionCall to function_call step', () => { + const content: Content = { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-1', + name: 'my_tool', + args: {a: 1}, + }, + thoughtSignature: 'sig-123', + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_call', + id: 'call-1', + name: 'my_tool', + arguments: {a: 1}, + signature: 'sig-123', + }, + ]); + }); + + it('should convert model Content with executableCode to code_execution_call step', () => { + const content: Content = { + role: 'model', + parts: [ + { + executableCode: { + code: 'print(1)', + language: 'python', + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'code_execution_call', + id: '', + arguments: { + code: 'print(1)', + language: 'python', + }, + }, + ]); + }); + + it('should convert model Content with thought to thought step', () => { + const content: Content = { + role: 'model', + parts: [ + { + thought: true, + thoughtSignature: 'sig-123', + } as any, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'thought', + signature: 'sig-123', + }, + ]); }); }); @@ -1715,19 +2083,28 @@ describe('interactions_utils', () => { }); describe('generateContentViaInteractions extra streaming cases', () => { - it('should handle streaming call with interaction.start event and extract interaction ID from interaction object', async () => { + it('should handle streaming call with interaction.created event and extract interaction ID from interaction object', async () => { const mockEvents = [ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: {id: 'int-start-id'}, }, { - event_type: 'content.delta', + event_type: 'step.start', + step: { + type: 'model_output', + content: [], + }, + }, + { + event_type: 'step.delta', delta: {type: 'text', text: 'Stream text'}, }, { - event_type: 'interaction.status_update', - status: 'completed', + event_type: 'step.stop', + }, + { + event_type: 'interaction.completed', }, ]; @@ -1760,7 +2137,7 @@ describe('interactions_utils', () => { responses.push(res); } - expect(responses.length).toBe(3); + expect(responses.length).toBe(3); // delta, completed, end-of-generator expect(responses[0]).toEqual({ content: {role: 'model', parts: [{text: 'Stream text'}]}, @@ -1776,14 +2153,23 @@ describe('interactions_utils', () => { it('should extract interaction ID from interactionId (camelCase) in streaming event', async () => { const mockEvents = [ { - event_type: 'content.delta', - delta: {type: 'text', text: 'Reply'}, + event_type: 'step.start', + step: { + type: 'model_output', + content: [], + }, interactionId: 'int-camel-case', }, + { + event_type: 'step.delta', + delta: {type: 'text', text: 'Reply'}, + }, ]; const mockStream = { [Symbol.asyncIterator]: async function* () { - yield mockEvents[0]; + for (const event of mockEvents) { + yield event; + } }, }; const mockApiClient = { diff --git a/dev/test/utils/agent_loader_test.ts b/dev/test/utils/agent_loader_test.ts index e0fc3cf0..ca62d5ec 100644 --- a/dev/test/utils/agent_loader_test.ts +++ b/dev/test/utils/agent_loader_test.ts @@ -4,13 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ import esbuild from 'esbuild'; -import {exec} from 'node:child_process'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import {pathToFileURL} from 'node:url'; -import {promisify} from 'node:util'; -import {afterEach, beforeEach, describe, expect, it, Mock, vi} from 'vitest'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + Mock, + vi, +} from 'vitest'; import { AgentFile, @@ -19,8 +27,6 @@ import { } from '../../src/utils/agent_loader.js'; import * as fileUtils from '../../src/utils/file_utils.js'; -const execAsync = promisify(exec); - vi.mock('../../src/utils/file_utils.js', () => ({ getTempDir: vi.fn(), isFile: vi.fn(), @@ -114,13 +120,22 @@ describe('AgentLoader', () => { const compiledPath = (fileName: string) => path.join(tempLoaderDir, fileName); - beforeEach(async () => { + beforeAll(async () => { tempAgentsDir = await fs.mkdtemp( path.join(os.tmpdir(), 'agent-loader-test'), ); tempLoaderDir = await fs.mkdtemp( path.join(os.tmpdir(), 'agent-loader-output-test'), ); + await initNpmProject(); + }, 60000); + + afterAll(async () => { + await fs.rm(tempAgentsDir, {recursive: true, force: true}); + await fs.rm(tempLoaderDir, {recursive: true, force: true}); + }); + + beforeEach(async () => { (fileUtils.getTempDir as Mock).mockImplementation(() => tempLoaderDir); (fileUtils.isFileExists as Mock).mockImplementation(() => true); (fileUtils.isFolderExists as Mock).mockImplementation( @@ -139,12 +154,35 @@ describe('AgentLoader', () => { (fileUtils.tryToFindFileRecursively as Mock).mockImplementation( async (_sourceFolder, fileName) => path.join(tempAgentsDir, fileName), ); - await initNpmProject(); }); afterEach(async () => { - await fs.rm(tempAgentsDir, {recursive: true, force: true}); - await fs.rm(tempLoaderDir, {recursive: true, force: true}); + try { + const files = await fs.readdir(tempAgentsDir); + for (const file of files) { + if (file !== 'package.json' && file !== 'node_modules') { + await fs.rm(path.join(tempAgentsDir, file), { + recursive: true, + force: true, + }); + } + } + } catch { + // ignore + } + + try { + const files = await fs.readdir(tempLoaderDir); + for (const file of files) { + await fs.rm(path.join(tempLoaderDir, file), { + recursive: true, + force: true, + }); + } + } catch { + // ignore + } + vi.clearAllMocks(); }); @@ -154,13 +192,18 @@ describe('AgentLoader', () => { JSON.stringify({ name: 'test-agents', version: '1.0.0', - dependencies: { - '@google/adk': `file:${path.dirname(require.resolve('@google/adk'))}`, - }, }), ); - await execAsync('npm install', {cwd: tempAgentsDir}); + const adkPath = path.resolve( + path.dirname(require.resolve('@google/adk')), + '..', + '..', + ); + const nodeModulesDir = path.join(tempAgentsDir, 'node_modules'); + const googleDir = path.join(nodeModulesDir, '@google'); + await fs.mkdir(googleDir, {recursive: true}); + await fs.symlink(adkPath, path.join(googleDir, 'adk'), 'dir'); } describe('AgentFile', () => { From 50e0152b4b77ffab056b69c5fcd7e30888de2f4a Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 1 Jun 2026 14:08:46 -0700 Subject: [PATCH 14/14] refactor(types): remove explicit any and eslint-disable from interactions_utils --- core/src/models/interactions_utils.ts | 62 ++++++++++++++------- core/test/models/interactions_utils_test.ts | 5 +- 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/core/src/models/interactions_utils.ts b/core/src/models/interactions_utils.ts index c3ba56d0..a23c6385 100644 --- a/core/src/models/interactions_utils.ts +++ b/core/src/models/interactions_utils.ts @@ -3,15 +3,13 @@ * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ - import { Content, FinishReason, GenerateContentConfig, GoogleGenAI, Interactions, + Language, Outcome, Part, } from '@google/genai'; @@ -21,6 +19,21 @@ import {LlmResponse} from './llm_response.js'; // --- Helper Interfaces for Strong Typing --- +interface ExtendedTool { + functionDeclarations?: Array<{ + name: string; + description?: string; + parameters?: { + properties?: Record; + required?: string[]; + }; + parametersJsonSchema?: unknown; + }>; + googleSearch?: unknown; + codeExecution?: unknown; + urlContext?: unknown; +} + export interface ExtendedInteraction extends Interactions.Interaction { error?: { code: string; @@ -164,7 +177,7 @@ function convertPartToMediaContent(part: Part): Interactions.Content | null { type: getInteractionMediaType(mimeType), data: part.inlineData.data, mime_type: mimeType, - } as any; + } as Interactions.Content; } if (part.fileData !== undefined && part.fileData !== null) { @@ -173,7 +186,7 @@ function convertPartToMediaContent(part: Part): Interactions.Content | null { type: getInteractionMediaType(mimeType), uri: part.fileData.fileUri, mime_type: mimeType, - } as any; + } as Interactions.Content; } return null; @@ -288,7 +301,11 @@ function convertMediaContentToPart(content: Interactions.Content): Part | null { content.type === 'video' || content.type === 'document' ) { - const media = content as any; + const media = content as { + data?: string; + uri?: string; + mime_type?: string; + }; if (media.data) { return { inlineData: { @@ -380,7 +397,7 @@ export function convertStepToParts(step: Interactions.Step): Part[] { { executableCode: { code: args.code || '', - language: (args.language || 'PYTHON').toUpperCase() as any, + language: (args.language || 'PYTHON').toUpperCase() as Language, }, }, ]; @@ -426,15 +443,20 @@ export function convertToolsConfigToInteractionsFormat( const interactionTools: Interactions.Tool[] = []; for (const tool of config.tools) { - const t = tool as any; + const t = tool as ExtendedTool; if (t.functionDeclarations) { for (const funcDecl of t.functionDeclarations) { - const funcTool: any = { + const funcTool: { + type: 'function'; + name: string; + description?: string; + parameters?: unknown; + } = { type: 'function', name: funcDecl.name, }; if (funcDecl.description) { - funcTool['description'] = funcDecl.description; + funcTool.description = funcDecl.description; } if (funcDecl.parameters) { if (funcDecl.parameters.properties) { @@ -444,7 +466,7 @@ export function convertToolsConfigToInteractionsFormat( )) { props[k] = JSON.parse(JSON.stringify(v)); } - funcTool['parameters'] = { + funcTool.parameters = { type: 'object', properties: props, required: funcDecl.parameters.required @@ -453,7 +475,7 @@ export function convertToolsConfigToInteractionsFormat( }; } } else if (funcDecl.parametersJsonSchema) { - funcTool['parameters'] = funcDecl.parametersJsonSchema; + funcTool.parameters = funcDecl.parametersJsonSchema; } interactionTools.push(funcTool as Interactions.Tool); } @@ -665,7 +687,7 @@ export function convertInteractionEventToLlmResponse( delta.type === 'video' || delta.type === 'document' ) { - const part = convertMediaContentToPart(delta as any); + const part = convertMediaContentToPart(delta as Interactions.Content); if (part) { aggregatedParts.push(part); return { @@ -878,8 +900,8 @@ export async function* generateContentViaInteractions( let currentInteractionId = previousInteractionId; if (stream) { - const responses: any = await apiClient.interactions.create({ - model: llmRequest.model, + const responses = (await apiClient.interactions.create({ + model: (llmRequest.model || 'gemini-2.5-flash') as 'gemini-2.5-flash', input: inputSteps, stream: true, system_instruction: systemInstruction, @@ -887,7 +909,7 @@ export async function* generateContentViaInteractions( generation_config: Object.keys(generationConfig).length > 0 ? generationConfig : undefined, previous_interaction_id: previousInteractionId, - } as any); // cast to any because SDK typings might still be tricky under some conditions + })) as AsyncIterable; const aggregatedParts: Part[] = []; for await (const event of responses) { @@ -916,8 +938,8 @@ export async function* generateContentViaInteractions( }; } } else { - const interaction = await apiClient.interactions.create({ - model: llmRequest.model, + const interaction = (await apiClient.interactions.create({ + model: (llmRequest.model || 'gemini-2.5-flash') as 'gemini-2.5-flash', input: inputSteps, stream: false, system_instruction: systemInstruction, @@ -925,9 +947,9 @@ export async function* generateContentViaInteractions( generation_config: Object.keys(generationConfig).length > 0 ? generationConfig : undefined, previous_interaction_id: previousInteractionId, - } as any); + })) as ExtendedInteraction; logger.info('Interaction response received from the model.'); - yield convertInteractionToLlmResponse(interaction as ExtendedInteraction); + yield convertInteractionToLlmResponse(interaction); } } diff --git a/core/test/models/interactions_utils_test.ts b/core/test/models/interactions_utils_test.ts index 1d141e69..8403c2ac 100644 --- a/core/test/models/interactions_utils_test.ts +++ b/core/test/models/interactions_utils_test.ts @@ -13,6 +13,7 @@ import { FunctionResponse, GenerateContentConfig, Interactions, + Language, Outcome, Part, } from '@google/genai'; @@ -316,7 +317,7 @@ describe('interactions_utils', () => { { executableCode: { code: 'print("hello")', - language: 'PYTHON', + language: Language.PYTHON, }, }, ], @@ -2008,7 +2009,7 @@ describe('interactions_utils', () => { { executableCode: { code: 'print(1)', - language: 'python', + language: Language.PYTHON, }, }, ],