feat(core): add partUtils module with unit tests (#4575)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
parent
487debe525
commit
a00f1bb916
|
@ -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('');
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -4,7 +4,8 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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.
|
* 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 type GeminiCodeRequest = PartListUnion;
|
||||||
|
|
||||||
export function partListUnionToString(value: PartListUnion): string {
|
export function partListUnionToString(value: PartListUnion): string {
|
||||||
if (typeof value === 'string') {
|
return partToString(value, { verbose: true });
|
||||||
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 '';
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from '@google/genai';
|
} from '@google/genai';
|
||||||
import { GeminiClient } from '../core/client.js';
|
import { GeminiClient } from '../core/client.js';
|
||||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.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.
|
* A function that summarizes the result of a tool execution.
|
||||||
|
@ -40,40 +40,6 @@ export const defaultSummarizer: Summarizer = (
|
||||||
_abortSignal: AbortSignal,
|
_abortSignal: AbortSignal,
|
||||||
) => Promise.resolve(JSON.stringify(result.llmContent));
|
) => 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.
|
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:
|
The summarization should be done based on the content that is provided. Here are the basic rules to follow:
|
||||||
|
|
Loading…
Reference in New Issue