diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 1268e8c2..8a9fceab 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -27,8 +27,8 @@ import { combinedUsageMetadata, } from '../telemetry/loggers.js'; import { - getResponseText, - getResponseTextFromParts, + getStructuredResponse, + getStructuredResponseFromParts, } from '../utils/generateContentResponseUtilities.js'; /** @@ -239,7 +239,7 @@ export class GeminiChat { await this._logApiResponse( durationMs, response.usageMetadata, - getResponseText(response), + getStructuredResponse(response), ); this.sendPromise = (async () => { @@ -437,7 +437,7 @@ export class GeminiChat { allParts.push(...content.parts); } } - const fullText = getResponseTextFromParts(allParts); + const fullText = getStructuredResponseFromParts(allParts); await this._logApiResponse( durationMs, combinedUsageMetadata(chunks), diff --git a/packages/core/src/utils/generateContentResponseUtilities.test.ts b/packages/core/src/utils/generateContentResponseUtilities.test.ts new file mode 100644 index 00000000..5dadab25 --- /dev/null +++ b/packages/core/src/utils/generateContentResponseUtilities.test.ts @@ -0,0 +1,323 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + getResponseText, + getResponseTextFromParts, + getFunctionCalls, + getFunctionCallsFromParts, + getFunctionCallsAsJson, + getFunctionCallsFromPartsAsJson, + getStructuredResponse, + getStructuredResponseFromParts, +} from './generateContentResponseUtilities.js'; +import { + GenerateContentResponse, + Part, + FinishReason, + SafetyRating, +} from '@google/genai'; + +const mockTextPart = (text: string): Part => ({ text }); +const mockFunctionCallPart = ( + name: string, + args?: Record, +): Part => ({ + functionCall: { name, args: args ?? {} }, +}); + +const mockResponse = ( + parts: Part[], + finishReason: FinishReason = FinishReason.STOP, + safetyRatings: SafetyRating[] = [], +): GenerateContentResponse => ({ + candidates: [ + { + content: { + parts, + role: 'model', + }, + index: 0, + finishReason, + safetyRatings, + }, + ], + promptFeedback: { + safetyRatings: [], + }, + text: undefined, + data: undefined, + functionCalls: undefined, + executableCode: undefined, + codeExecutionResult: undefined, +}); + +const minimalMockResponse = ( + candidates: GenerateContentResponse['candidates'], +): GenerateContentResponse => ({ + candidates, + promptFeedback: { safetyRatings: [] }, + text: undefined, + data: undefined, + functionCalls: undefined, + executableCode: undefined, + codeExecutionResult: undefined, +}); + +describe('generateContentResponseUtilities', () => { + describe('getResponseText', () => { + it('should return undefined for no candidates', () => { + expect(getResponseText(minimalMockResponse(undefined))).toBeUndefined(); + }); + it('should return undefined for empty candidates array', () => { + expect(getResponseText(minimalMockResponse([]))).toBeUndefined(); + }); + it('should return undefined for no parts', () => { + const response = mockResponse([]); + expect(getResponseText(response)).toBeUndefined(); + }); + it('should extract text from a single text part', () => { + const response = mockResponse([mockTextPart('Hello')]); + expect(getResponseText(response)).toBe('Hello'); + }); + it('should concatenate text from multiple text parts', () => { + const response = mockResponse([ + mockTextPart('Hello '), + mockTextPart('World'), + ]); + expect(getResponseText(response)).toBe('Hello World'); + }); + it('should ignore function call parts', () => { + const response = mockResponse([ + mockTextPart('Hello '), + mockFunctionCallPart('testFunc'), + mockTextPart('World'), + ]); + expect(getResponseText(response)).toBe('Hello World'); + }); + it('should return undefined if only function call parts exist', () => { + const response = mockResponse([ + mockFunctionCallPart('testFunc'), + mockFunctionCallPart('anotherFunc'), + ]); + expect(getResponseText(response)).toBeUndefined(); + }); + }); + + describe('getResponseTextFromParts', () => { + it('should return undefined for no parts', () => { + expect(getResponseTextFromParts([])).toBeUndefined(); + }); + it('should extract text from a single text part', () => { + expect(getResponseTextFromParts([mockTextPart('Hello')])).toBe('Hello'); + }); + it('should concatenate text from multiple text parts', () => { + expect( + getResponseTextFromParts([ + mockTextPart('Hello '), + mockTextPart('World'), + ]), + ).toBe('Hello World'); + }); + it('should ignore function call parts', () => { + expect( + getResponseTextFromParts([ + mockTextPart('Hello '), + mockFunctionCallPart('testFunc'), + mockTextPart('World'), + ]), + ).toBe('Hello World'); + }); + it('should return undefined if only function call parts exist', () => { + expect( + getResponseTextFromParts([ + mockFunctionCallPart('testFunc'), + mockFunctionCallPart('anotherFunc'), + ]), + ).toBeUndefined(); + }); + }); + + describe('getFunctionCalls', () => { + it('should return undefined for no candidates', () => { + expect(getFunctionCalls(minimalMockResponse(undefined))).toBeUndefined(); + }); + it('should return undefined for empty candidates array', () => { + expect(getFunctionCalls(minimalMockResponse([]))).toBeUndefined(); + }); + it('should return undefined for no parts', () => { + const response = mockResponse([]); + expect(getFunctionCalls(response)).toBeUndefined(); + }); + it('should extract a single function call', () => { + const func = { name: 'testFunc', args: { a: 1 } }; + const response = mockResponse([ + mockFunctionCallPart(func.name, func.args), + ]); + expect(getFunctionCalls(response)).toEqual([func]); + }); + it('should extract multiple function calls', () => { + const func1 = { name: 'testFunc1', args: { a: 1 } }; + const func2 = { name: 'testFunc2', args: { b: 2 } }; + const response = mockResponse([ + mockFunctionCallPart(func1.name, func1.args), + mockFunctionCallPart(func2.name, func2.args), + ]); + expect(getFunctionCalls(response)).toEqual([func1, func2]); + }); + it('should ignore text parts', () => { + const func = { name: 'testFunc', args: { a: 1 } }; + const response = mockResponse([ + mockTextPart('Some text'), + mockFunctionCallPart(func.name, func.args), + mockTextPart('More text'), + ]); + expect(getFunctionCalls(response)).toEqual([func]); + }); + it('should return undefined if only text parts exist', () => { + const response = mockResponse([ + mockTextPart('Some text'), + mockTextPart('More text'), + ]); + expect(getFunctionCalls(response)).toBeUndefined(); + }); + }); + + describe('getFunctionCallsFromParts', () => { + it('should return undefined for no parts', () => { + expect(getFunctionCallsFromParts([])).toBeUndefined(); + }); + it('should extract a single function call', () => { + const func = { name: 'testFunc', args: { a: 1 } }; + expect( + getFunctionCallsFromParts([mockFunctionCallPart(func.name, func.args)]), + ).toEqual([func]); + }); + it('should extract multiple function calls', () => { + const func1 = { name: 'testFunc1', args: { a: 1 } }; + const func2 = { name: 'testFunc2', args: { b: 2 } }; + expect( + getFunctionCallsFromParts([ + mockFunctionCallPart(func1.name, func1.args), + mockFunctionCallPart(func2.name, func2.args), + ]), + ).toEqual([func1, func2]); + }); + it('should ignore text parts', () => { + const func = { name: 'testFunc', args: { a: 1 } }; + expect( + getFunctionCallsFromParts([ + mockTextPart('Some text'), + mockFunctionCallPart(func.name, func.args), + mockTextPart('More text'), + ]), + ).toEqual([func]); + }); + it('should return undefined if only text parts exist', () => { + expect( + getFunctionCallsFromParts([ + mockTextPart('Some text'), + mockTextPart('More text'), + ]), + ).toBeUndefined(); + }); + }); + + describe('getFunctionCallsAsJson', () => { + it('should return JSON string of function calls', () => { + const func1 = { name: 'testFunc1', args: { a: 1 } }; + const func2 = { name: 'testFunc2', args: { b: 2 } }; + const response = mockResponse([ + mockFunctionCallPart(func1.name, func1.args), + mockTextPart('text in between'), + mockFunctionCallPart(func2.name, func2.args), + ]); + const expectedJson = JSON.stringify([func1, func2], null, 2); + expect(getFunctionCallsAsJson(response)).toBe(expectedJson); + }); + it('should return undefined if no function calls', () => { + const response = mockResponse([mockTextPart('Hello')]); + expect(getFunctionCallsAsJson(response)).toBeUndefined(); + }); + }); + + describe('getFunctionCallsFromPartsAsJson', () => { + it('should return JSON string of function calls from parts', () => { + const func1 = { name: 'testFunc1', args: { a: 1 } }; + const func2 = { name: 'testFunc2', args: { b: 2 } }; + const parts = [ + mockFunctionCallPart(func1.name, func1.args), + mockTextPart('text in between'), + mockFunctionCallPart(func2.name, func2.args), + ]; + const expectedJson = JSON.stringify([func1, func2], null, 2); + expect(getFunctionCallsFromPartsAsJson(parts)).toBe(expectedJson); + }); + it('should return undefined if no function calls in parts', () => { + const parts = [mockTextPart('Hello')]; + expect(getFunctionCallsFromPartsAsJson(parts)).toBeUndefined(); + }); + }); + + describe('getStructuredResponse', () => { + it('should return only text if only text exists', () => { + const response = mockResponse([mockTextPart('Hello World')]); + expect(getStructuredResponse(response)).toBe('Hello World'); + }); + it('should return only function call JSON if only function calls exist', () => { + const func = { name: 'testFunc', args: { data: 'payload' } }; + const response = mockResponse([ + mockFunctionCallPart(func.name, func.args), + ]); + const expectedJson = JSON.stringify([func], null, 2); + expect(getStructuredResponse(response)).toBe(expectedJson); + }); + it('should return text and function call JSON if both exist', () => { + const text = 'Consider this data:'; + const func = { name: 'processData', args: { item: 42 } }; + const response = mockResponse([ + mockTextPart(text), + mockFunctionCallPart(func.name, func.args), + ]); + const expectedJson = JSON.stringify([func], null, 2); + expect(getStructuredResponse(response)).toBe(`${text}\n${expectedJson}`); + }); + it('should return undefined if neither text nor function calls exist', () => { + const response = mockResponse([]); + expect(getStructuredResponse(response)).toBeUndefined(); + }); + }); + + describe('getStructuredResponseFromParts', () => { + it('should return only text if only text exists in parts', () => { + const parts = [mockTextPart('Hello World')]; + expect(getStructuredResponseFromParts(parts)).toBe('Hello World'); + }); + it('should return only function call JSON if only function calls exist in parts', () => { + const func = { name: 'testFunc', args: { data: 'payload' } }; + const parts = [mockFunctionCallPart(func.name, func.args)]; + const expectedJson = JSON.stringify([func], null, 2); + expect(getStructuredResponseFromParts(parts)).toBe(expectedJson); + }); + it('should return text and function call JSON if both exist in parts', () => { + const text = 'Consider this data:'; + const func = { name: 'processData', args: { item: 42 } }; + const parts = [ + mockTextPart(text), + mockFunctionCallPart(func.name, func.args), + ]; + const expectedJson = JSON.stringify([func], null, 2); + expect(getStructuredResponseFromParts(parts)).toBe( + `${text}\n${expectedJson}`, + ); + }); + it('should return undefined if neither text nor function calls exist in parts', () => { + const parts: Part[] = []; + expect(getStructuredResponseFromParts(parts)).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/utils/generateContentResponseUtilities.ts b/packages/core/src/utils/generateContentResponseUtilities.ts index d575bca8..c5125753 100644 --- a/packages/core/src/utils/generateContentResponseUtilities.ts +++ b/packages/core/src/utils/generateContentResponseUtilities.ts @@ -4,18 +4,116 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { GenerateContentResponse, Part } from '@google/genai'; +import { GenerateContentResponse, Part, FunctionCall } from '@google/genai'; export function getResponseText( response: GenerateContentResponse, ): string | undefined { - return ( - response.candidates?.[0]?.content?.parts - ?.map((part) => part.text) - .join('') || undefined - ); + const parts = response.candidates?.[0]?.content?.parts; + if (!parts) { + return undefined; + } + const textSegments = parts + .map((part) => part.text) + .filter((text): text is string => typeof text === 'string'); + + if (textSegments.length === 0) { + return undefined; + } + return textSegments.join(''); } export function getResponseTextFromParts(parts: Part[]): string | undefined { - return parts?.map((part) => part.text).join('') || undefined; + if (!parts) { + return undefined; + } + const textSegments = parts + .map((part) => part.text) + .filter((text): text is string => typeof text === 'string'); + + if (textSegments.length === 0) { + return undefined; + } + return textSegments.join(''); +} + +export function getFunctionCalls( + response: GenerateContentResponse, +): FunctionCall[] | undefined { + const parts = response.candidates?.[0]?.content?.parts; + if (!parts) { + return undefined; + } + const functionCallParts = parts + .filter((part) => !!part.functionCall) + .map((part) => part.functionCall as FunctionCall); + return functionCallParts.length > 0 ? functionCallParts : undefined; +} + +export function getFunctionCallsFromParts( + parts: Part[], +): FunctionCall[] | undefined { + if (!parts) { + return undefined; + } + const functionCallParts = parts + .filter((part) => !!part.functionCall) + .map((part) => part.functionCall as FunctionCall); + return functionCallParts.length > 0 ? functionCallParts : undefined; +} + +export function getFunctionCallsAsJson( + response: GenerateContentResponse, +): string | undefined { + const functionCalls = getFunctionCalls(response); + if (!functionCalls) { + return undefined; + } + return JSON.stringify(functionCalls, null, 2); +} + +export function getFunctionCallsFromPartsAsJson( + parts: Part[], +): string | undefined { + const functionCalls = getFunctionCallsFromParts(parts); + if (!functionCalls) { + return undefined; + } + return JSON.stringify(functionCalls, null, 2); +} + +export function getStructuredResponse( + response: GenerateContentResponse, +): string | undefined { + const textContent = getResponseText(response); + const functionCallsJson = getFunctionCallsAsJson(response); + + if (textContent && functionCallsJson) { + return `${textContent}\n${functionCallsJson}`; + } + if (textContent) { + return textContent; + } + if (functionCallsJson) { + return functionCallsJson; + } + return undefined; +} + +export function getStructuredResponseFromParts( + parts: Part[], +): string | undefined { + const textContent = getResponseTextFromParts(parts); + const functionCallsJson = getFunctionCallsFromPartsAsJson(parts); + + if (textContent && functionCallsJson) { + return `${textContent}\n${functionCallsJson}`; + } + if (textContent) { + return textContent; + } + if (functionCallsJson) { + return functionCallsJson; + } + return undefined; }