Merge remote-tracking branch 'origin/main' into gundermanc/frugal-search-plus-plus

This commit is contained in:
Christian Gunderman
2026-02-06 09:00:13 -08:00
276 changed files with 18698 additions and 4389 deletions

View File

@@ -0,0 +1,163 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { spawn, ChildProcess } from 'node:child_process';
import { join, resolve } from 'node:path';
import { writeFileSync, mkdirSync } from 'node:fs';
import { Writable, Readable } from 'node:stream';
import { env } from 'node:process';
import * as acp from '@agentclientprotocol/sdk';
const sandboxEnv = env['GEMINI_SANDBOX'];
const itMaybe = sandboxEnv && sandboxEnv !== 'false' ? it.skip : it;
class MockClient implements acp.Client {
updates: acp.SessionNotification[] = [];
sessionUpdate = async (params: acp.SessionNotification) => {
this.updates.push(params);
};
requestPermission = async (): Promise<acp.RequestPermissionResponse> => {
throw new Error('unexpected');
};
}
describe('ACP Environment and Auth', () => {
let rig: TestRig;
let child: ChildProcess | undefined;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => {
child?.kill();
child = undefined;
await rig.cleanup();
});
itMaybe(
'should load .env from project directory and use the provided API key',
async () => {
rig.setup('acp-env-loading');
// Create a project directory with a .env file containing a recognizable invalid key
const projectDir = resolve(join(rig.testDir!, 'project'));
mkdirSync(projectDir, { recursive: true });
writeFileSync(
join(projectDir, '.env'),
'GEMINI_API_KEY=test-key-from-env\n',
);
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
child = spawn('node', [bundlePath, '--experimental-acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
...process.env,
GEMINI_CLI_HOME: rig.homeDir!,
GEMINI_API_KEY: undefined,
VERBOSE: 'true',
},
});
const input = Writable.toWeb(child.stdin!);
const output = Readable.toWeb(
child.stdout!,
) as ReadableStream<Uint8Array>;
const testClient = new MockClient();
const stream = acp.ndJsonStream(input, output);
const connection = new acp.ClientSideConnection(() => testClient, stream);
await connection.initialize({
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {
fs: { readTextFile: false, writeTextFile: false },
},
});
// 1. newSession should succeed because it finds the key in .env
const { sessionId } = await connection.newSession({
cwd: projectDir,
mcpServers: [],
});
expect(sessionId).toBeDefined();
// 2. prompt should fail because the key is invalid,
// but the error should come from the API, not the internal auth check.
await expect(
connection.prompt({
sessionId,
prompt: [{ type: 'text', text: 'hello' }],
}),
).rejects.toSatisfy((error: unknown) => {
const acpError = error as acp.RequestError;
const errorData = acpError.data as
| { error?: { message?: string } }
| undefined;
const message = String(errorData?.error?.message || acpError.message);
// It should NOT be our internal "Authentication required" message
expect(message).not.toContain('Authentication required');
// It SHOULD be an API error mentioning the invalid key
expect(message).toContain('API key not valid');
return true;
});
child.stdin!.end();
},
);
itMaybe(
'should fail with authRequired when no API key is found',
async () => {
rig.setup('acp-auth-failure');
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
child = spawn('node', [bundlePath, '--experimental-acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
...process.env,
GEMINI_CLI_HOME: rig.homeDir!,
GEMINI_API_KEY: undefined,
VERBOSE: 'true',
},
});
const input = Writable.toWeb(child.stdin!);
const output = Readable.toWeb(
child.stdout!,
) as ReadableStream<Uint8Array>;
const testClient = new MockClient();
const stream = acp.ndJsonStream(input, output);
const connection = new acp.ClientSideConnection(() => testClient, stream);
await connection.initialize({
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {
fs: { readTextFile: false, writeTextFile: false },
},
});
await expect(
connection.newSession({
cwd: resolve(rig.testDir!),
mcpServers: [],
}),
).rejects.toMatchObject({
message: expect.stringContaining(
'Gemini API key is missing or not configured.',
),
});
child.stdin!.end();
},
);
});

View File

@@ -7,7 +7,12 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync } from 'node:fs';
import * as path from 'node:path';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
import {
TestRig,
printDebugInfo,
assertModelHasOutput,
checkModelOutputContent,
} from './test-helper.js';
describe('file-system', () => {
let rig: TestRig;
@@ -43,8 +48,11 @@ describe('file-system', () => {
'Expected to find a read_file tool call',
).toBeTruthy();
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, 'hello world', 'File read test');
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: 'hello world',
testName: 'File read test',
});
});
it('should be able to write a file', async () => {
@@ -74,8 +82,8 @@ describe('file-system', () => {
'Expected to find a write_file, edit, or replace tool call',
).toBeTruthy();
// Validate model output - will throw if no output
validateModelOutput(result, null, 'File write test');
assertModelHasOutput(result);
checkModelOutputContent(result, { testName: 'File write test' });
const fileContent = rig.readFile('test.txt');

View File

@@ -6,7 +6,12 @@
import { WEB_SEARCH_TOOL_NAME } from '../packages/core/src/tools/tool-names.js';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
import {
TestRig,
printDebugInfo,
assertModelHasOutput,
checkModelOutputContent,
} from './test-helper.js';
describe('web search tool', () => {
let rig: TestRig;
@@ -68,12 +73,11 @@ describe('web search tool', () => {
`Expected to find a call to ${WEB_SEARCH_TOOL_NAME}`,
).toBeTruthy();
// Validate model output - will throw if no output, warn if missing expected content
const hasExpectedContent = validateModelOutput(
result,
['weather', 'london'],
'Google web search test',
);
assertModelHasOutput(result);
const hasExpectedContent = checkModelOutputContent(result, {
expectedContent: ['weather', 'london'],
testName: 'Google web search test',
});
// If content was missing, log the search queries used
if (!hasExpectedContent) {

View File

@@ -9,7 +9,8 @@ import {
TestRig,
poll,
printDebugInfo,
validateModelOutput,
assertModelHasOutput,
checkModelOutputContent,
} from './test-helper.js';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
@@ -68,7 +69,10 @@ describe('list_directory', () => {
throw e;
}
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, ['file1.txt', 'subdir'], 'List directory test');
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: ['file1.txt', 'subdir'],
testName: 'List directory test',
});
});
});

View File

@@ -5,7 +5,12 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
import {
TestRig,
printDebugInfo,
assertModelHasOutput,
checkModelOutputContent,
} from './test-helper.js';
describe('read_many_files', () => {
let rig: TestRig;
@@ -50,7 +55,7 @@ describe('read_many_files', () => {
'Expected to find either read_many_files or multiple read_file tool calls',
).toBeTruthy();
// Validate model output - will throw if no output
validateModelOutput(result, null, 'Read many files test');
assertModelHasOutput(result);
checkModelOutputContent(result, { testName: 'Read many files test' });
});
});

View File

@@ -0,0 +1,111 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { RipGrepTool } from '../packages/core/src/tools/ripGrep.js';
import { Config } from '../packages/core/src/config/config.js';
import { WorkspaceContext } from '../packages/core/src/utils/workspaceContext.js';
// Mock Config to provide necessary context
class MockConfig {
constructor(private targetDir: string) {}
getTargetDir() {
return this.targetDir;
}
getWorkspaceContext() {
return new WorkspaceContext(this.targetDir, [this.targetDir]);
}
getDebugMode() {
return true;
}
getFileFilteringRespectGitIgnore() {
return true;
}
getFileFilteringRespectGeminiIgnore() {
return true;
}
getFileFilteringOptions() {
return {
respectGitIgnore: true,
respectGeminiIgnore: true,
customIgnoreFilePaths: [],
};
}
validatePathAccess() {
return null;
}
}
describe('ripgrep-real-direct', () => {
let tempDir: string;
let tool: RipGrepTool;
beforeAll(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-real-test-'));
// Create test files
await fs.writeFile(path.join(tempDir, 'file1.txt'), 'hello world\n');
await fs.mkdir(path.join(tempDir, 'subdir'));
await fs.writeFile(
path.join(tempDir, 'subdir', 'file2.txt'),
'hello universe\n',
);
await fs.writeFile(path.join(tempDir, 'file3.txt'), 'goodbye moon\n');
const config = new MockConfig(tempDir) as unknown as Config;
tool = new RipGrepTool(config);
});
afterAll(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it('should find matches using the real ripgrep binary', async () => {
const invocation = tool.build({ pattern: 'hello' });
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Found 2 matches');
expect(result.llmContent).toContain('file1.txt');
expect(result.llmContent).toContain('L1: hello world');
expect(result.llmContent).toContain('subdir'); // Should show path
expect(result.llmContent).toContain('file2.txt');
expect(result.llmContent).toContain('L1: hello universe');
expect(result.llmContent).not.toContain('goodbye moon');
});
it('should handle no matches correctly', async () => {
const invocation = tool.build({ pattern: 'nonexistent_pattern_123' });
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('No matches found');
});
it('should respect include filters', async () => {
// Create a .js file
await fs.writeFile(
path.join(tempDir, 'script.js'),
'console.log("hello");\n',
);
const invocation = tool.build({ pattern: 'hello', include: '*.js' });
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Found 1 match');
expect(result.llmContent).toContain('script.js');
expect(result.llmContent).not.toContain('file1.txt');
});
});

View File

@@ -5,7 +5,12 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
import {
TestRig,
printDebugInfo,
assertModelHasOutput,
checkModelOutputContent,
} from './test-helper.js';
import { getShellConfiguration } from '../packages/core/src/utils/shell-utils.js';
const { shell } = getShellConfiguration();
@@ -115,13 +120,11 @@ describe('run_shell_command', () => {
'Expected to find a run_shell_command tool call',
).toBeTruthy();
// Validate model output - will throw if no output, warn if missing expected content
// Model often reports exit code instead of showing output
validateModelOutput(
result,
['hello-world', 'exit code 0'],
'Shell command test',
);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: ['hello-world', 'exit code 0'],
testName: 'Shell command test',
});
});
it('should be able to run a shell command via stdin', async () => {
@@ -149,8 +152,11 @@ describe('run_shell_command', () => {
'Expected to find a run_shell_command tool call',
).toBeTruthy();
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, 'test-stdin', 'Shell command stdin test');
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: 'test-stdin',
testName: 'Shell command stdin test',
});
});
it.skip('should run allowed sub-command in non-interactive mode', async () => {
@@ -494,12 +500,11 @@ describe('run_shell_command', () => {
)[0];
expect(toolCall.toolRequest.success).toBe(true);
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(
result,
'test-allow-all',
'Shell command stdin allow all',
);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: 'test-allow-all',
testName: 'Shell command stdin allow all',
});
});
it('should propagate environment variables to the child process', async () => {
@@ -528,7 +533,11 @@ describe('run_shell_command', () => {
foundToolCall,
'Expected to find a run_shell_command tool call',
).toBeTruthy();
validateModelOutput(result, varValue, 'Env var propagation test');
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: varValue,
testName: 'Env var propagation test',
});
expect(result).toContain(varValue);
} finally {
delete process.env[varName];
@@ -558,7 +567,11 @@ describe('run_shell_command', () => {
'Expected to find a run_shell_command tool call',
).toBeTruthy();
validateModelOutput(result, fileName, 'Platform-specific listing test');
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: fileName,
testName: 'Platform-specific listing test',
});
expect(result).toContain(fileName);
});

View File

@@ -11,7 +11,12 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, poll, validateModelOutput } from './test-helper.js';
import {
TestRig,
poll,
assertModelHasOutput,
checkModelOutputContent,
} from './test-helper.js';
import { join } from 'node:path';
import { writeFileSync } from 'node:fs';
@@ -226,8 +231,11 @@ describe.skip('simple-mcp-server', () => {
expect(foundToolCall, 'Expected to find an add tool call').toBeTruthy();
// Validate model output - will throw if no output, fail if missing expected content
validateModelOutput(output, '15', 'MCP server test');
assertModelHasOutput(output);
checkModelOutputContent(output, {
expectedContent: '15',
testName: 'MCP server test',
});
expect(
output.includes('15'),
'Expected output to contain the sum (15)',

View File

@@ -5,7 +5,12 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
import {
TestRig,
printDebugInfo,
assertModelHasOutput,
checkModelOutputContent,
} from './test-helper.js';
describe.skip('stdin context', () => {
let rig: TestRig;
@@ -67,7 +72,11 @@ describe.skip('stdin context', () => {
}
// Validate model output
validateModelOutput(result, randomString, 'STDIN context test');
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: randomString,
testName: 'STDIN context test',
});
expect(
result.toLowerCase().includes(randomString),

View File

@@ -9,7 +9,8 @@ import {
TestRig,
createToolCallErrorMessage,
printDebugInfo,
validateModelOutput,
assertModelHasOutput,
checkModelOutputContent,
} from './test-helper.js';
describe('write_file', () => {
@@ -46,8 +47,11 @@ describe('write_file', () => {
),
).toBeTruthy();
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, 'dad.txt', 'Write file test');
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: 'dad.txt',
testName: 'Write file test',
});
const newFilePath = 'dad.txt';