2025-08-14 13:28:33 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @license
|
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-08-26 00:04:53 +02:00
|
|
|
|
import type { Mock } from 'vitest';
|
|
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
|
|
import type { WebSearchToolParams } from './web-search.js';
|
|
|
|
|
|
import { WebSearchTool } from './web-search.js';
|
|
|
|
|
|
import type { Config } from '../config/config.js';
|
2025-08-14 13:28:33 -07:00
|
|
|
|
import { GeminiClient } from '../core/client.js';
|
2025-08-21 14:40:18 -07:00
|
|
|
|
import { ToolErrorType } from './tool-error.js';
|
2025-08-14 13:28:33 -07:00
|
|
|
|
|
|
|
|
|
|
// Mock GeminiClient and Config constructor
|
|
|
|
|
|
vi.mock('../core/client.js');
|
|
|
|
|
|
vi.mock('../config/config.js');
|
|
|
|
|
|
|
|
|
|
|
|
describe('WebSearchTool', () => {
|
|
|
|
|
|
const abortSignal = new AbortController().signal;
|
|
|
|
|
|
let mockGeminiClient: GeminiClient;
|
|
|
|
|
|
let tool: WebSearchTool;
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
const mockConfigInstance = {
|
|
|
|
|
|
getGeminiClient: () => mockGeminiClient,
|
|
|
|
|
|
getProxy: () => undefined,
|
2025-11-11 08:10:50 -08:00
|
|
|
|
generationConfigService: {
|
|
|
|
|
|
getResolvedConfig: vi.fn().mockImplementation(({ model }) => ({
|
|
|
|
|
|
model,
|
|
|
|
|
|
sdkConfig: {},
|
|
|
|
|
|
})),
|
|
|
|
|
|
},
|
2025-08-14 13:28:33 -07:00
|
|
|
|
} as unknown as Config;
|
|
|
|
|
|
mockGeminiClient = new GeminiClient(mockConfigInstance);
|
|
|
|
|
|
tool = new WebSearchTool(mockConfigInstance);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
|
vi.restoreAllMocks();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('build', () => {
|
|
|
|
|
|
it('should return an invocation for a valid query', () => {
|
|
|
|
|
|
const params: WebSearchToolParams = { query: 'test query' };
|
|
|
|
|
|
const invocation = tool.build(params);
|
|
|
|
|
|
expect(invocation).toBeDefined();
|
|
|
|
|
|
expect(invocation.params).toEqual(params);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('should throw an error for an empty query', () => {
|
|
|
|
|
|
const params: WebSearchToolParams = { query: '' };
|
|
|
|
|
|
expect(() => tool.build(params)).toThrow(
|
|
|
|
|
|
"The 'query' parameter cannot be empty.",
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('should throw an error for a query with only whitespace', () => {
|
|
|
|
|
|
const params: WebSearchToolParams = { query: ' ' };
|
|
|
|
|
|
expect(() => tool.build(params)).toThrow(
|
|
|
|
|
|
"The 'query' parameter cannot be empty.",
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('getDescription', () => {
|
|
|
|
|
|
it('should return a description of the search', () => {
|
|
|
|
|
|
const params: WebSearchToolParams = { query: 'test query' };
|
|
|
|
|
|
const invocation = tool.build(params);
|
|
|
|
|
|
expect(invocation.getDescription()).toBe(
|
|
|
|
|
|
'Searching the web for: "test query"',
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('execute', () => {
|
|
|
|
|
|
it('should return search results for a successful query', async () => {
|
|
|
|
|
|
const params: WebSearchToolParams = { query: 'successful query' };
|
|
|
|
|
|
(mockGeminiClient.generateContent as Mock).mockResolvedValue({
|
|
|
|
|
|
candidates: [
|
|
|
|
|
|
{
|
|
|
|
|
|
content: {
|
|
|
|
|
|
role: 'model',
|
|
|
|
|
|
parts: [{ text: 'Here are your results.' }],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const invocation = tool.build(params);
|
|
|
|
|
|
const result = await invocation.execute(abortSignal);
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.llmContent).toBe(
|
|
|
|
|
|
'Web search results for "successful query":\n\nHere are your results.',
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(result.returnDisplay).toBe(
|
|
|
|
|
|
'Search results for "successful query" returned.',
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(result.sources).toBeUndefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('should handle no search results found', async () => {
|
|
|
|
|
|
const params: WebSearchToolParams = { query: 'no results query' };
|
|
|
|
|
|
(mockGeminiClient.generateContent as Mock).mockResolvedValue({
|
|
|
|
|
|
candidates: [
|
|
|
|
|
|
{
|
|
|
|
|
|
content: {
|
|
|
|
|
|
role: 'model',
|
|
|
|
|
|
parts: [{ text: '' }],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const invocation = tool.build(params);
|
|
|
|
|
|
const result = await invocation.execute(abortSignal);
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.llmContent).toBe(
|
|
|
|
|
|
'No search results or information found for query: "no results query"',
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(result.returnDisplay).toBe('No information found.');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-21 14:40:18 -07:00
|
|
|
|
it('should return a WEB_SEARCH_FAILED error on failure', async () => {
|
2025-08-14 13:28:33 -07:00
|
|
|
|
const params: WebSearchToolParams = { query: 'error query' };
|
|
|
|
|
|
const testError = new Error('API Failure');
|
|
|
|
|
|
(mockGeminiClient.generateContent as Mock).mockRejectedValue(testError);
|
|
|
|
|
|
|
|
|
|
|
|
const invocation = tool.build(params);
|
|
|
|
|
|
const result = await invocation.execute(abortSignal);
|
|
|
|
|
|
|
2025-08-21 14:40:18 -07:00
|
|
|
|
expect(result.error?.type).toBe(ToolErrorType.WEB_SEARCH_FAILED);
|
2025-08-14 13:28:33 -07:00
|
|
|
|
expect(result.llmContent).toContain('Error:');
|
|
|
|
|
|
expect(result.llmContent).toContain('API Failure');
|
|
|
|
|
|
expect(result.returnDisplay).toBe('Error performing web search.');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('should correctly format results with sources and citations', async () => {
|
|
|
|
|
|
const params: WebSearchToolParams = { query: 'grounding query' };
|
|
|
|
|
|
(mockGeminiClient.generateContent as Mock).mockResolvedValue({
|
|
|
|
|
|
candidates: [
|
|
|
|
|
|
{
|
|
|
|
|
|
content: {
|
|
|
|
|
|
role: 'model',
|
|
|
|
|
|
parts: [{ text: 'This is a test response.' }],
|
|
|
|
|
|
},
|
|
|
|
|
|
groundingMetadata: {
|
|
|
|
|
|
groundingChunks: [
|
|
|
|
|
|
{ web: { uri: 'https://example.com', title: 'Example Site' } },
|
|
|
|
|
|
{ web: { uri: 'https://google.com', title: 'Google' } },
|
|
|
|
|
|
],
|
|
|
|
|
|
groundingSupports: [
|
|
|
|
|
|
{
|
|
|
|
|
|
segment: { startIndex: 5, endIndex: 14 },
|
|
|
|
|
|
groundingChunkIndices: [0],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
segment: { startIndex: 15, endIndex: 24 },
|
|
|
|
|
|
groundingChunkIndices: [0, 1],
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const invocation = tool.build(params);
|
|
|
|
|
|
const result = await invocation.execute(abortSignal);
|
|
|
|
|
|
|
|
|
|
|
|
const expectedLlmContent = `Web search results for "grounding query":
|
|
|
|
|
|
|
|
|
|
|
|
This is a test[1] response.[1][2]
|
|
|
|
|
|
|
|
|
|
|
|
Sources:
|
|
|
|
|
|
[1] Example Site (https://example.com)
|
|
|
|
|
|
[2] Google (https://google.com)`;
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.llmContent).toBe(expectedLlmContent);
|
|
|
|
|
|
expect(result.returnDisplay).toBe(
|
|
|
|
|
|
'Search results for "grounding query" returned.',
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(result.sources).toHaveLength(2);
|
|
|
|
|
|
});
|
2025-08-23 01:09:16 +09:00
|
|
|
|
|
|
|
|
|
|
it('should insert markers at correct byte positions for multibyte text', async () => {
|
|
|
|
|
|
const params: WebSearchToolParams = { query: 'multibyte query' };
|
|
|
|
|
|
(mockGeminiClient.generateContent as Mock).mockResolvedValue({
|
|
|
|
|
|
candidates: [
|
|
|
|
|
|
{
|
|
|
|
|
|
content: {
|
|
|
|
|
|
role: 'model',
|
|
|
|
|
|
parts: [{ text: 'こんにちは! Gemini CLI✨️' }],
|
|
|
|
|
|
},
|
|
|
|
|
|
groundingMetadata: {
|
|
|
|
|
|
groundingChunks: [
|
|
|
|
|
|
{
|
|
|
|
|
|
web: {
|
|
|
|
|
|
title: 'Japanese Greeting',
|
|
|
|
|
|
uri: 'https://example.test/japanese-greeting',
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
web: {
|
|
|
|
|
|
title: 'google-gemini/gemini-cli',
|
|
|
|
|
|
uri: 'https://github.com/google-gemini/gemini-cli',
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
web: {
|
|
|
|
|
|
title: 'Gemini CLI: your open-source AI agent',
|
|
|
|
|
|
uri: 'https://blog.google/technology/developers/introducing-gemini-cli-open-source-ai-agent/',
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
groundingSupports: [
|
|
|
|
|
|
{
|
|
|
|
|
|
segment: {
|
|
|
|
|
|
// Byte range of "こんにちは!" (utf-8 encoded)
|
|
|
|
|
|
startIndex: 0,
|
|
|
|
|
|
endIndex: 16,
|
|
|
|
|
|
},
|
|
|
|
|
|
groundingChunkIndices: [0],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
segment: {
|
|
|
|
|
|
// Byte range of "Gemini CLI✨️" (utf-8 encoded)
|
|
|
|
|
|
startIndex: 17,
|
|
|
|
|
|
endIndex: 33,
|
|
|
|
|
|
},
|
|
|
|
|
|
groundingChunkIndices: [1, 2],
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const invocation = tool.build(params);
|
|
|
|
|
|
const result = await invocation.execute(abortSignal);
|
|
|
|
|
|
|
|
|
|
|
|
const expectedLlmContent = `Web search results for "multibyte query":
|
|
|
|
|
|
|
|
|
|
|
|
こんにちは![1] Gemini CLI✨️[2][3]
|
|
|
|
|
|
|
|
|
|
|
|
Sources:
|
|
|
|
|
|
[1] Japanese Greeting (https://example.test/japanese-greeting)
|
|
|
|
|
|
[2] google-gemini/gemini-cli (https://github.com/google-gemini/gemini-cli)
|
|
|
|
|
|
[3] Gemini CLI: your open-source AI agent (https://blog.google/technology/developers/introducing-gemini-cli-open-source-ai-agent/)`;
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.llmContent).toBe(expectedLlmContent);
|
|
|
|
|
|
expect(result.returnDisplay).toBe(
|
|
|
|
|
|
'Search results for "multibyte query" returned.',
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(result.sources).toHaveLength(3);
|
|
|
|
|
|
});
|
2025-08-14 13:28:33 -07:00
|
|
|
|
});
|
|
|
|
|
|
});
|