feat(core): add partUtils module with unit tests (#4575)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
HyeongHo Jun 2025-07-23 08:57:06 +09:00 committed by GitHub
parent 487debe525
commit a00f1bb916
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 255 additions and 175 deletions

View File

@ -1,85 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { partListUnionToString } from './geminiRequest.js';
import { type Part } from '@google/genai';
describe('partListUnionToString', () => {
it('should return the string value if the input is a string', () => {
const result = partListUnionToString('hello');
expect(result).toBe('hello');
});
it('should return a concatenated string if the input is an array of strings', () => {
const result = partListUnionToString(['hello', ' ', 'world']);
expect(result).toBe('hello world');
});
it('should handle videoMetadata', () => {
const part: Part = { videoMetadata: {} };
const result = partListUnionToString(part);
expect(result).toBe('[Video Metadata]');
});
it('should handle thought', () => {
const part: Part = { thought: true };
const result = partListUnionToString(part);
expect(result).toBe('[Thought: true]');
});
it('should handle codeExecutionResult', () => {
const part: Part = { codeExecutionResult: {} };
const result = partListUnionToString(part);
expect(result).toBe('[Code Execution Result]');
});
it('should handle executableCode', () => {
const part: Part = { executableCode: {} };
const result = partListUnionToString(part);
expect(result).toBe('[Executable Code]');
});
it('should handle fileData', () => {
const part: Part = {
fileData: { mimeType: 'text/plain', fileUri: 'file.txt' },
};
const result = partListUnionToString(part);
expect(result).toBe('[File Data]');
});
it('should handle functionCall', () => {
const part: Part = { functionCall: { name: 'myFunction' } };
const result = partListUnionToString(part);
expect(result).toBe('[Function Call: myFunction]');
});
it('should handle functionResponse', () => {
const part: Part = {
functionResponse: { name: 'myFunction', response: {} },
};
const result = partListUnionToString(part);
expect(result).toBe('[Function Response: myFunction]');
});
it('should handle inlineData', () => {
const part: Part = { inlineData: { mimeType: 'image/png', data: '...' } };
const result = partListUnionToString(part);
expect(result).toBe('<image/png>');
});
it('should handle text', () => {
const part: Part = { text: 'hello' };
const result = partListUnionToString(part);
expect(result).toBe('hello');
});
it('should return an empty string for an unknown part type', () => {
const part: Part = {};
const result = partListUnionToString(part);
expect(result).toBe('');
});
});

View File

@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type PartListUnion, type Part } from '@google/genai';
import { type PartListUnion } from '@google/genai';
import { partToString } from '../utils/partUtils.js';
/**
* Represents a request to be sent to the Gemini API.
@ -14,58 +15,5 @@ import { type PartListUnion, type Part } from '@google/genai';
export type GeminiCodeRequest = PartListUnion;
export function partListUnionToString(value: PartListUnion): string {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return value.map(partListUnionToString).join('');
}
// Cast to Part, assuming it might contain project-specific fields
const part = value as Part & {
videoMetadata?: unknown;
thought?: string;
codeExecutionResult?: unknown;
executableCode?: unknown;
};
if (part.videoMetadata !== undefined) {
return `[Video Metadata]`;
}
if (part.thought !== undefined) {
return `[Thought: ${part.thought}]`;
}
if (part.codeExecutionResult !== undefined) {
return `[Code Execution Result]`;
}
if (part.executableCode !== undefined) {
return `[Executable Code]`;
}
// Standard Part fields
if (part.fileData !== undefined) {
return `[File Data]`;
}
if (part.functionCall !== undefined) {
return `[Function Call: ${part.functionCall.name}]`;
}
if (part.functionResponse !== undefined) {
return `[Function Response: ${part.functionResponse.name}]`;
}
if (part.inlineData !== undefined) {
return `<${part.inlineData.mimeType}>`;
}
if (part.text !== undefined) {
return part.text;
}
return '';
return partToString(value, { verbose: true });
}

View File

@ -0,0 +1,166 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { partToString, getResponseText } from './partUtils.js';
import { GenerateContentResponse, Part } from '@google/genai';
const mockResponse = (
parts?: Array<{ text?: string; functionCall?: unknown }>,
): GenerateContentResponse => ({
candidates: parts
? [{ content: { parts: parts as Part[], role: 'model' }, index: 0 }]
: [],
promptFeedback: { safetyRatings: [] },
text: undefined,
data: undefined,
functionCalls: undefined,
executableCode: undefined,
codeExecutionResult: undefined,
});
describe('partUtils', () => {
describe('partToString (default behavior)', () => {
it('should return empty string for undefined or null', () => {
// @ts-expect-error Testing invalid input
expect(partToString(undefined)).toBe('');
// @ts-expect-error Testing invalid input
expect(partToString(null)).toBe('');
});
it('should return string input unchanged', () => {
expect(partToString('hello')).toBe('hello');
});
it('should concatenate strings from an array', () => {
expect(partToString(['a', 'b'])).toBe('ab');
});
it('should return text property when provided a text part', () => {
expect(partToString({ text: 'hi' })).toBe('hi');
});
it('should return empty string for non-text parts', () => {
const part: Part = { inlineData: { mimeType: 'image/png', data: '' } };
expect(partToString(part)).toBe('');
const part2: Part = { functionCall: { name: 'test' } };
expect(partToString(part2)).toBe('');
});
});
describe('partToString (verbose)', () => {
const verboseOptions = { verbose: true };
it('should return empty string for undefined or null', () => {
// @ts-expect-error Testing invalid input
expect(partToString(undefined, verboseOptions)).toBe('');
// @ts-expect-error Testing invalid input
expect(partToString(null, verboseOptions)).toBe('');
});
it('should return string input unchanged', () => {
expect(partToString('hello', verboseOptions)).toBe('hello');
});
it('should join parts if the value is an array', () => {
const parts = ['hello', { text: ' world' }];
expect(partToString(parts, verboseOptions)).toBe('hello world');
});
it('should return the text property if the part is an object with text', () => {
const part: Part = { text: 'hello world' };
expect(partToString(part, verboseOptions)).toBe('hello world');
});
it('should return descriptive string for videoMetadata part', () => {
const part = { videoMetadata: {} } as Part;
expect(partToString(part, verboseOptions)).toBe('[Video Metadata]');
});
it('should return descriptive string for thought part', () => {
const part = { thought: 'thinking' } as unknown as Part;
expect(partToString(part, verboseOptions)).toBe('[Thought: thinking]');
});
it('should return descriptive string for codeExecutionResult part', () => {
const part = { codeExecutionResult: {} } as Part;
expect(partToString(part, verboseOptions)).toBe(
'[Code Execution Result]',
);
});
it('should return descriptive string for executableCode part', () => {
const part = { executableCode: {} } as Part;
expect(partToString(part, verboseOptions)).toBe('[Executable Code]');
});
it('should return descriptive string for fileData part', () => {
const part = { fileData: {} } as Part;
expect(partToString(part, verboseOptions)).toBe('[File Data]');
});
it('should return descriptive string for functionCall part', () => {
const part = { functionCall: { name: 'myFunction' } } as Part;
expect(partToString(part, verboseOptions)).toBe(
'[Function Call: myFunction]',
);
});
it('should return descriptive string for functionResponse part', () => {
const part = { functionResponse: { name: 'myFunction' } } as Part;
expect(partToString(part, verboseOptions)).toBe(
'[Function Response: myFunction]',
);
});
it('should return descriptive string for inlineData part', () => {
const part = { inlineData: { mimeType: 'image/png', data: '' } } as Part;
expect(partToString(part, verboseOptions)).toBe('<image/png>');
});
it('should return an empty string for an unknown part type', () => {
const part: Part = {};
expect(partToString(part, verboseOptions)).toBe('');
});
it('should handle complex nested arrays with various part types', () => {
const parts = [
'start ',
{ text: 'middle' },
[
{ functionCall: { name: 'func1' } },
' end',
{ inlineData: { mimeType: 'audio/mp3', data: '' } },
],
];
expect(partToString(parts as Part, verboseOptions)).toBe(
'start middle[Function Call: func1] end<audio/mp3>',
);
});
});
describe('getResponseText', () => {
it('should return null when no candidates exist', () => {
const response = mockResponse(undefined);
expect(getResponseText(response)).toBeNull();
});
it('should return concatenated text from first candidate', () => {
const result = mockResponse([{ text: 'a' }, { text: 'b' }]);
expect(getResponseText(result)).toBe('ab');
});
it('should ignore parts without text', () => {
const result = mockResponse([{ functionCall: {} }, { text: 'hello' }]);
expect(getResponseText(result)).toBe('hello');
});
it('should return null when candidate has no parts', () => {
const result = mockResponse([]);
expect(getResponseText(result)).toBeNull();
});
});
});

View File

@ -0,0 +1,85 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { GenerateContentResponse, PartListUnion, Part } from '@google/genai';
/**
* Converts a PartListUnion into a string.
* If verbose is true, includes summary representations of non-text parts.
*/
export function partToString(
value: PartListUnion,
options?: { verbose?: boolean },
): string {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return value.map((part) => partToString(part, options)).join('');
}
// Cast to Part, assuming it might contain project-specific fields
const part = value as Part & {
videoMetadata?: unknown;
thought?: string;
codeExecutionResult?: unknown;
executableCode?: unknown;
};
if (options?.verbose) {
if (part.videoMetadata !== undefined) {
return `[Video Metadata]`;
}
if (part.thought !== undefined) {
return `[Thought: ${part.thought}]`;
}
if (part.codeExecutionResult !== undefined) {
return `[Code Execution Result]`;
}
if (part.executableCode !== undefined) {
return `[Executable Code]`;
}
// Standard Part fields
if (part.fileData !== undefined) {
return `[File Data]`;
}
if (part.functionCall !== undefined) {
return `[Function Call: ${part.functionCall.name}]`;
}
if (part.functionResponse !== undefined) {
return `[Function Response: ${part.functionResponse.name}]`;
}
if (part.inlineData !== undefined) {
return `<${part.inlineData.mimeType}>`;
}
}
return part.text ?? '';
}
export function getResponseText(
response: GenerateContentResponse,
): string | null {
if (response.candidates && response.candidates.length > 0) {
const candidate = response.candidates[0];
if (
candidate.content &&
candidate.content.parts &&
candidate.content.parts.length > 0
) {
return candidate.content.parts
.filter((part) => part.text)
.map((part) => part.text)
.join('');
}
}
return null;
}

View File

@ -12,7 +12,7 @@ import {
} from '@google/genai';
import { GeminiClient } from '../core/client.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { PartListUnion } from '@google/genai';
import { getResponseText, partToString } from './partUtils.js';
/**
* A function that summarizes the result of a tool execution.
@ -40,40 +40,6 @@ export const defaultSummarizer: Summarizer = (
_abortSignal: AbortSignal,
) => Promise.resolve(JSON.stringify(result.llmContent));
// TODO: Move both these functions to utils
function partToString(part: PartListUnion): string {
if (!part) {
return '';
}
if (typeof part === 'string') {
return part;
}
if (Array.isArray(part)) {
return part.map(partToString).join('');
}
if ('text' in part) {
return part.text ?? '';
}
return '';
}
function getResponseText(response: GenerateContentResponse): string | null {
if (response.candidates && response.candidates.length > 0) {
const candidate = response.candidates[0];
if (
candidate.content &&
candidate.content.parts &&
candidate.content.parts.length > 0
) {
return candidate.content.parts
.filter((part) => part.text)
.map((part) => part.text)
.join('');
}
}
return null;
}
const SUMMARIZE_TOOL_OUTPUT_PROMPT = `Summarize the following tool output to be a maximum of {maxOutputTokens} tokens. The summary should be concise and capture the main points of the tool output.
The summarization should be done based on the content that is provided. Here are the basic rules to follow: