diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 30880ba6..e49039f8 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -11,12 +11,7 @@ import { useReactToolScheduler, mapToDisplay, } from './useReactToolScheduler.js'; -import { - Part, - PartListUnion, - PartUnion, - FunctionResponse, -} from '@google/genai'; +import { PartUnion, FunctionResponse } from '@google/genai'; import { Config, ToolCallRequestInfo, @@ -26,7 +21,6 @@ import { ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolCallResponseInfo, - formatLlmContentForFunctionResponse, // Import from core ToolCall, // Import from core Status as ToolCallStatusType, ApprovalMode, // Import from core @@ -93,120 +87,6 @@ const mockToolRequiresConfirmation: Tool = { ), }; -describe('formatLlmContentForFunctionResponse', () => { - it('should handle simple string llmContent', () => { - const llmContent = 'Simple text output'; - const { functionResponseJson, additionalParts } = - formatLlmContentForFunctionResponse(llmContent); - expect(functionResponseJson).toEqual({ output: 'Simple text output' }); - expect(additionalParts).toEqual([]); - }); - - it('should handle llmContent as a single Part with text', () => { - const llmContent: Part = { text: 'Text from Part object' }; - const { functionResponseJson, additionalParts } = - formatLlmContentForFunctionResponse(llmContent); - expect(functionResponseJson).toEqual({ output: 'Text from Part object' }); - expect(additionalParts).toEqual([]); - }); - - it('should handle llmContent as a PartListUnion array with a single text Part', () => { - const llmContent: PartListUnion = [{ text: 'Text from array' }]; - const { functionResponseJson, additionalParts } = - formatLlmContentForFunctionResponse(llmContent); - expect(functionResponseJson).toEqual({ output: 'Text from array' }); - expect(additionalParts).toEqual([]); - }); - - it('should handle llmContent with inlineData', () => { - const llmContent: Part = { - inlineData: { mimeType: 'image/png', data: 'base64...' }, - }; - const { functionResponseJson, additionalParts } = - formatLlmContentForFunctionResponse(llmContent); - expect(functionResponseJson).toEqual({ - status: 'Binary content of type image/png was processed.', - }); - expect(additionalParts).toEqual([llmContent]); - }); - - it('should handle llmContent with fileData', () => { - const llmContent: Part = { - fileData: { mimeType: 'application/pdf', fileUri: 'gs://...' }, - }; - const { functionResponseJson, additionalParts } = - formatLlmContentForFunctionResponse(llmContent); - expect(functionResponseJson).toEqual({ - status: 'Binary content of type application/pdf was processed.', - }); - expect(additionalParts).toEqual([llmContent]); - }); - - it('should handle llmContent as an array of multiple Parts (text and inlineData)', () => { - const llmContent: PartListUnion = [ - { text: 'Some textual description' }, - { inlineData: { mimeType: 'image/jpeg', data: 'base64data...' } }, - { text: 'Another text part' }, - ]; - const { functionResponseJson, additionalParts } = - formatLlmContentForFunctionResponse(llmContent); - expect(functionResponseJson).toEqual({ - status: 'Tool execution succeeded.', - }); - expect(additionalParts).toEqual(llmContent); - }); - - it('should handle llmContent as an array with a single inlineData Part', () => { - const llmContent: PartListUnion = [ - { inlineData: { mimeType: 'image/gif', data: 'gifdata...' } }, - ]; - const { functionResponseJson, additionalParts } = - formatLlmContentForFunctionResponse(llmContent); - expect(functionResponseJson).toEqual({ - status: 'Binary content of type image/gif was processed.', - }); - expect(additionalParts).toEqual(llmContent); - }); - - it('should handle llmContent as a generic Part (not text, inlineData, or fileData)', () => { - const llmContent: Part = { functionCall: { name: 'test', args: {} } }; - const { functionResponseJson, additionalParts } = - formatLlmContentForFunctionResponse(llmContent); - expect(functionResponseJson).toEqual({ - status: 'Tool execution succeeded.', - }); - expect(additionalParts).toEqual([llmContent]); - }); - - it('should handle empty string llmContent', () => { - const llmContent = ''; - const { functionResponseJson, additionalParts } = - formatLlmContentForFunctionResponse(llmContent); - expect(functionResponseJson).toEqual({ output: '' }); - expect(additionalParts).toEqual([]); - }); - - it('should handle llmContent as an empty array', () => { - const llmContent: PartListUnion = []; - const { functionResponseJson, additionalParts } = - formatLlmContentForFunctionResponse(llmContent); - expect(functionResponseJson).toEqual({ - status: 'Tool execution succeeded.', - }); - expect(additionalParts).toEqual([]); - }); - - it('should handle llmContent as a Part with undefined inlineData/fileData/text', () => { - const llmContent: Part = {}; // An empty part object - const { functionResponseJson, additionalParts } = - formatLlmContentForFunctionResponse(llmContent); - expect(functionResponseJson).toEqual({ - status: 'Tool execution succeeded.', - }); - expect(additionalParts).toEqual([llmContent]); - }); -}); - describe('useReactToolScheduler in YOLO Mode', () => { let onComplete: Mock; let setPendingHistoryItem: Mock; @@ -289,13 +169,13 @@ describe('useReactToolScheduler in YOLO Mode', () => { request, response: expect.objectContaining({ resultDisplay: 'YOLO Formatted tool output', - responseParts: expect.arrayContaining([ - expect.objectContaining({ - functionResponse: expect.objectContaining({ - response: { output: expectedOutput }, - }), - }), - ]), + responseParts: { + functionResponse: { + id: 'yoloCall', + name: 'mockToolRequiresConfirmation', + response: { output: expectedOutput }, + }, + }, }), }), ]); @@ -433,13 +313,13 @@ describe('useReactToolScheduler', () => { request, response: expect.objectContaining({ resultDisplay: 'Formatted tool output', - responseParts: expect.arrayContaining([ - expect.objectContaining({ - functionResponse: expect.objectContaining({ - response: { output: 'Tool output' }, - }), - }), - ]), + responseParts: { + functionResponse: { + id: 'call1', + name: 'mockTool', + response: { output: 'Tool output' }, + }, + }, }), }), ]); @@ -917,13 +797,13 @@ describe('useReactToolScheduler', () => { request: requests[0], response: expect.objectContaining({ resultDisplay: 'Display 1', - responseParts: expect.arrayContaining([ - expect.objectContaining({ - functionResponse: expect.objectContaining({ - response: { output: 'Output 1' }, - }), - }), - ]), + responseParts: { + functionResponse: { + id: 'multi1', + name: 'tool1', + response: { output: 'Output 1' }, + }, + }, }), }); expect(call2Result).toMatchObject({ @@ -931,13 +811,13 @@ describe('useReactToolScheduler', () => { request: requests[1], response: expect.objectContaining({ resultDisplay: 'Display 2', - responseParts: expect.arrayContaining([ - expect.objectContaining({ - functionResponse: expect.objectContaining({ - response: { output: 'Output 2' }, - }), - }), - ]), + responseParts: { + functionResponse: { + id: 'multi2', + name: 'tool2', + response: { output: 'Output 2' }, + }, + }, }), }); expect(result.current[0]).toEqual([]); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts new file mode 100644 index 00000000..be42bb24 --- /dev/null +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { convertToFunctionResponse } from './coreToolScheduler.js'; +import { Part, PartListUnion } from '@google/genai'; + +describe('convertToFunctionResponse', () => { + const toolName = 'testTool'; + const callId = 'call1'; + + it('should handle simple string llmContent', () => { + const llmContent = 'Simple text output'; + const result = convertToFunctionResponse(toolName, callId, llmContent); + expect(result).toEqual({ + functionResponse: { + name: toolName, + id: callId, + response: { output: 'Simple text output' }, + }, + }); + }); + + it('should handle llmContent as a single Part with text', () => { + const llmContent: Part = { text: 'Text from Part object' }; + const result = convertToFunctionResponse(toolName, callId, llmContent); + expect(result).toEqual({ + functionResponse: { + name: toolName, + id: callId, + response: { output: 'Text from Part object' }, + }, + }); + }); + + it('should handle llmContent as a PartListUnion array with a single text Part', () => { + const llmContent: PartListUnion = [{ text: 'Text from array' }]; + const result = convertToFunctionResponse(toolName, callId, llmContent); + expect(result).toEqual({ + functionResponse: { + name: toolName, + id: callId, + response: { output: 'Text from array' }, + }, + }); + }); + + it('should handle llmContent with inlineData', () => { + const llmContent: Part = { + inlineData: { mimeType: 'image/png', data: 'base64...' }, + }; + const result = convertToFunctionResponse(toolName, callId, llmContent); + expect(result).toEqual([ + { + functionResponse: { + name: toolName, + id: callId, + response: { + output: 'Binary content of type image/png was processed.', + }, + }, + }, + llmContent, + ]); + }); + + it('should handle llmContent with fileData', () => { + const llmContent: Part = { + fileData: { mimeType: 'application/pdf', fileUri: 'gs://...' }, + }; + const result = convertToFunctionResponse(toolName, callId, llmContent); + expect(result).toEqual([ + { + functionResponse: { + name: toolName, + id: callId, + response: { + output: 'Binary content of type application/pdf was processed.', + }, + }, + }, + llmContent, + ]); + }); + + it('should handle llmContent as an array of multiple Parts (text and inlineData)', () => { + const llmContent: PartListUnion = [ + { text: 'Some textual description' }, + { inlineData: { mimeType: 'image/jpeg', data: 'base64data...' } }, + { text: 'Another text part' }, + ]; + const result = convertToFunctionResponse(toolName, callId, llmContent); + expect(result).toEqual([ + { + functionResponse: { + name: toolName, + id: callId, + response: { output: 'Tool execution succeeded.' }, + }, + }, + ...llmContent, + ]); + }); + + it('should handle llmContent as an array with a single inlineData Part', () => { + const llmContent: PartListUnion = [ + { inlineData: { mimeType: 'image/gif', data: 'gifdata...' } }, + ]; + const result = convertToFunctionResponse(toolName, callId, llmContent); + expect(result).toEqual([ + { + functionResponse: { + name: toolName, + id: callId, + response: { + output: 'Binary content of type image/gif was processed.', + }, + }, + }, + ...llmContent, + ]); + }); + + it('should handle llmContent as a generic Part (not text, inlineData, or fileData)', () => { + const llmContent: Part = { functionCall: { name: 'test', args: {} } }; + const result = convertToFunctionResponse(toolName, callId, llmContent); + expect(result).toEqual({ + functionResponse: { + name: toolName, + id: callId, + response: { output: 'Tool execution succeeded.' }, + }, + }); + }); + + it('should handle empty string llmContent', () => { + const llmContent = ''; + const result = convertToFunctionResponse(toolName, callId, llmContent); + expect(result).toEqual({ + functionResponse: { + name: toolName, + id: callId, + response: { output: '' }, + }, + }); + }); + + it('should handle llmContent as an empty array', () => { + const llmContent: PartListUnion = []; + const result = convertToFunctionResponse(toolName, callId, llmContent); + expect(result).toEqual([ + { + functionResponse: { + name: toolName, + id: callId, + response: { output: 'Tool execution succeeded.' }, + }, + }, + ]); + }); + + it('should handle llmContent as a Part with undefined inlineData/fileData/text', () => { + const llmContent: Part = {}; // An empty part object + const result = convertToFunctionResponse(toolName, callId, llmContent); + expect(result).toEqual({ + functionResponse: { + name: toolName, + id: callId, + response: { output: 'Tool execution succeeded.' }, + }, + }); + }); +}); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index c3735366..f82676f1 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -14,7 +14,8 @@ import { ToolRegistry, ApprovalMode, } from '../index.js'; -import { Part, PartUnion, PartListUnion } from '@google/genai'; +import { Part, PartListUnion } from '@google/genai'; +import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js'; export type ValidatingToolCall = { status: 'validating'; @@ -96,51 +97,79 @@ export type ToolCallsUpdateHandler = (toolCalls: ToolCall[]) => void; /** * Formats tool output for a Gemini FunctionResponse. */ -export function formatLlmContentForFunctionResponse( - llmContent: PartListUnion, -): { - functionResponseJson: Record; - additionalParts: PartUnion[]; -} { - const additionalParts: PartUnion[] = []; - let functionResponseJson: Record; +function createFunctionResponsePart( + callId: string, + toolName: string, + output: string, +): Part { + return { + functionResponse: { + id: callId, + name: toolName, + response: { output }, + }, + }; +} +export function convertToFunctionResponse( + toolName: string, + callId: string, + llmContent: PartListUnion, +): PartListUnion { const contentToProcess = Array.isArray(llmContent) && llmContent.length === 1 ? llmContent[0] : llmContent; if (typeof contentToProcess === 'string') { - functionResponseJson = { output: contentToProcess }; - } else if (Array.isArray(contentToProcess)) { - functionResponseJson = { - status: 'Tool execution succeeded.', - }; - additionalParts.push(...contentToProcess); - } else if (contentToProcess.inlineData || contentToProcess.fileData) { + return createFunctionResponsePart(callId, toolName, contentToProcess); + } + + if (Array.isArray(contentToProcess)) { + const functionResponse = createFunctionResponsePart( + callId, + toolName, + 'Tool execution succeeded.', + ); + return [functionResponse, ...contentToProcess]; + } + + // After this point, contentToProcess is a single Part object. + if (contentToProcess.functionResponse) { + if (contentToProcess.functionResponse.response?.content) { + const stringifiedOutput = + getResponseTextFromParts( + contentToProcess.functionResponse.response.content as Part[], + ) || ''; + return createFunctionResponsePart(callId, toolName, stringifiedOutput); + } + // It's a functionResponse that we should pass through as is. + return contentToProcess; + } + + if (contentToProcess.inlineData || contentToProcess.fileData) { const mimeType = contentToProcess.inlineData?.mimeType || contentToProcess.fileData?.mimeType || 'unknown'; - functionResponseJson = { - status: `Binary content of type ${mimeType} was processed.`, - }; - additionalParts.push(contentToProcess); - } else if (contentToProcess.text !== undefined) { - functionResponseJson = { output: contentToProcess.text }; - } else if (contentToProcess.functionResponse) { - functionResponseJson = JSON.parse( - JSON.stringify(contentToProcess.functionResponse), + const functionResponse = createFunctionResponsePart( + callId, + toolName, + `Binary content of type ${mimeType} was processed.`, ); - } else { - functionResponseJson = { status: 'Tool execution succeeded.' }; - additionalParts.push(contentToProcess); + return [functionResponse, contentToProcess]; } - return { - functionResponseJson, - additionalParts, - }; + if (contentToProcess.text !== undefined) { + return createFunctionResponsePart(callId, toolName, contentToProcess.text); + } + + // Default case for other kinds of parts. + return createFunctionResponsePart( + callId, + toolName, + 'Tool execution succeeded.', + ); } const createErrorResponse = ( @@ -452,20 +481,15 @@ export class CoreToolScheduler { return; } - const { functionResponseJson, additionalParts } = - formatLlmContentForFunctionResponse(toolResult.llmContent); - - const functionResponsePart: Part = { - functionResponse: { - name: toolName, - id: callId, - response: functionResponseJson, - }, - }; + const response = convertToFunctionResponse( + toolName, + callId, + toolResult.llmContent, + ); const successResponse: ToolCallResponseInfo = { callId, - responseParts: [functionResponsePart, ...additionalParts], + responseParts: response, resultDisplay: toolResult.returnDisplay, error: undefined, }; diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index 3d7dc1a2..be9294a8 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -81,15 +81,13 @@ describe('executeToolCall', () => { expect(response.callId).toBe('call1'); expect(response.error).toBeUndefined(); expect(response.resultDisplay).toBe('Success!'); - expect(response.responseParts).toEqual([ - { - functionResponse: { - name: 'testTool', - id: 'call1', - response: { output: 'Tool executed successfully' }, - }, + expect(response.responseParts).toEqual({ + functionResponse: { + name: 'testTool', + id: 'call1', + response: { output: 'Tool executed successfully' }, }, - ]); + }); }); it('should return an error if tool is not found', async () => { @@ -225,7 +223,7 @@ describe('executeToolCall', () => { name: 'testTool', id: 'call5', response: { - status: 'Binary content of type image/png was processed.', + output: 'Binary content of type image/png was processed.', }, }, }, diff --git a/packages/core/src/core/nonInteractiveToolExecutor.ts b/packages/core/src/core/nonInteractiveToolExecutor.ts index 5b5c9a13..87d17c2b 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.ts @@ -4,14 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Part } from '@google/genai'; import { ToolCallRequestInfo, ToolCallResponseInfo, ToolRegistry, ToolResult, } from '../index.js'; -import { formatLlmContentForFunctionResponse } from './coreToolScheduler.js'; +import { convertToFunctionResponse } from './coreToolScheduler.js'; /** * Executes a single tool call non-interactively. @@ -54,20 +53,15 @@ export async function executeToolCall( // No live output callback for non-interactive mode ); - const { functionResponseJson, additionalParts } = - formatLlmContentForFunctionResponse(toolResult.llmContent); - - const functionResponsePart: Part = { - functionResponse: { - name: toolCallRequest.name, - id: toolCallRequest.callId, - response: functionResponseJson, - }, - }; + const response = convertToFunctionResponse( + toolCallRequest.name, + toolCallRequest.callId, + toolResult.llmContent, + ); return { callId: toolCallRequest.callId, - responseParts: [functionResponsePart, ...additionalParts], + responseParts: response, resultDisplay: toolResult.returnDisplay, error: undefined, }; diff --git a/packages/core/src/tools/mcp-tool.test.ts b/packages/core/src/tools/mcp-tool.test.ts index 86968b3d..fc6ce6be 100644 --- a/packages/core/src/tools/mcp-tool.test.ts +++ b/packages/core/src/tools/mcp-tool.test.ts @@ -138,12 +138,7 @@ describe('DiscoveredMCPTool', () => { const stringifiedResponseContent = JSON.stringify( mockToolSuccessResultObject, ); - // getStringifiedResultForDisplay joins text parts, then wraps the array of processed parts in JSON - const expectedDisplayOutput = - '```json\n' + - JSON.stringify([stringifiedResponseContent], null, 2) + - '\n```'; - expect(toolResult.returnDisplay).toBe(expectedDisplayOutput); + expect(toolResult.returnDisplay).toBe(stringifiedResponseContent); }); it('should handle empty result from getStringifiedResultForDisplay', async () => { diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 65c0cae8..8a7694d8 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -149,6 +149,13 @@ function getStringifiedResultForDisplay(result: Part[]) { return part; // Fallback for unexpected structure or non-FunctionResponsePart }; - const processedResults = result.map(processFunctionResponse); + const processedResults = + result.length === 1 + ? processFunctionResponse(result[0]) + : result.map(processFunctionResponse); + if (typeof processedResults === 'string') { + return processedResults; + } + return '```json\n' + JSON.stringify(processedResults, null, 2) + '\n```'; } diff --git a/packages/core/src/utils/generateContentResponseUtilities.ts b/packages/core/src/utils/generateContentResponseUtilities.ts index a1d62124..d575bca8 100644 --- a/packages/core/src/utils/generateContentResponseUtilities.ts +++ b/packages/core/src/utils/generateContentResponseUtilities.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { GenerateContentResponse } from '@google/genai'; +import { GenerateContentResponse, Part } from '@google/genai'; export function getResponseText( response: GenerateContentResponse, @@ -15,3 +15,7 @@ export function getResponseText( .join('') || undefined ); } + +export function getResponseTextFromParts(parts: Part[]): string | undefined { + return parts?.map((part) => part.text).join('') || undefined; +}