mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-22 07:41:23 -07:00
Merge remote-tracking branch 'origin/main' into feature/simulator-knowledge-update
# Conflicts: # package-lock.json # package.json # packages/cli/src/interactiveCli.tsx # packages/core/src/telemetry/llmRole.ts
This commit is contained in:
@@ -10,6 +10,7 @@ import * as path from 'node:path';
|
||||
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import type { FileFilteringOptions } from '../config/constants.js';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
// Simple console logger for now.
|
||||
// TODO: Integrate with a more robust server-side logger.
|
||||
const logger = {
|
||||
@@ -80,10 +81,8 @@ export async function bfsFileSearch(
|
||||
return { currentDir, entries };
|
||||
} catch (error) {
|
||||
// Warn user that a directory could not be read, as this affects search results.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = (error as Error)?.message ?? 'Unknown error';
|
||||
debugLogger.warn(
|
||||
`[WARN] Skipping unreadable directory: ${currentDir} (${message})`,
|
||||
`[WARN] Skipping unreadable directory: ${currentDir} (${getErrorMessage(error)})`,
|
||||
);
|
||||
if (debug) {
|
||||
logger.debug(`Full error for ${currentDir}:`, error);
|
||||
@@ -154,10 +153,8 @@ export function bfsFileSearchSync(
|
||||
foundFiles,
|
||||
);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = (error as Error)?.message ?? 'Unknown error';
|
||||
debugLogger.warn(
|
||||
`[WARN] Skipping unreadable directory: ${currentDir} (${message})`,
|
||||
`[WARN] Skipping unreadable directory: ${currentDir} (${getErrorMessage(error)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,12 @@ export function generateCheckpointFileName(
|
||||
toolCall: ToolCallRequestInfo,
|
||||
): string | null {
|
||||
const toolArgs = toolCall.args;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const toolFilePath = toolArgs['file_path'] as string;
|
||||
const rawFilePath = toolArgs['file_path'];
|
||||
|
||||
if (!toolFilePath) {
|
||||
if (typeof rawFilePath !== 'string' || !rawFilePath) {
|
||||
return null;
|
||||
}
|
||||
const toolFilePath = rawFilePath;
|
||||
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
@@ -168,15 +168,18 @@ export function getCheckpointInfoList(
|
||||
|
||||
for (const [file, content] of checkpointFiles) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const toolCallData = JSON.parse(content) as ToolCallData;
|
||||
if (toolCallData.messageId) {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
const result = z
|
||||
.object({ messageId: z.string() })
|
||||
.passthrough()
|
||||
.safeParse(parsed);
|
||||
if (result.success) {
|
||||
checkpointInfoList.push({
|
||||
messageId: toolCallData.messageId,
|
||||
messageId: result.data.messageId,
|
||||
checkpoint: file.replace('.json', ''),
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Ignore invalid JSON files
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ describe('compatibility', () => {
|
||||
desc: '256 colors are not supported',
|
||||
},
|
||||
])('should return $expected when $desc', ({ depth, term, expected }) => {
|
||||
vi.stubEnv('COLORTERM', '');
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(depth);
|
||||
if (term !== undefined) {
|
||||
vi.stubEnv('TERM', term);
|
||||
@@ -203,6 +204,13 @@ describe('compatibility', () => {
|
||||
}
|
||||
expect(supports256Colors()).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return true when COLORTERM is kmscon', () => {
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
|
||||
vi.stubEnv('TERM', 'linux');
|
||||
vi.stubEnv('COLORTERM', 'kmscon');
|
||||
expect(supports256Colors()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsTrueColor', () => {
|
||||
@@ -230,6 +238,12 @@ describe('compatibility', () => {
|
||||
expected: true,
|
||||
desc: 'getColorDepth returns >= 24',
|
||||
},
|
||||
{
|
||||
colorterm: 'kmscon',
|
||||
depth: 4,
|
||||
expected: true,
|
||||
desc: 'COLORTERM is kmscon',
|
||||
},
|
||||
{
|
||||
colorterm: '',
|
||||
depth: 8,
|
||||
@@ -289,19 +303,6 @@ describe('compatibility', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return tmux warning when detected and in alternate buffer', () => {
|
||||
vi.stubEnv('TMUX', '/tmp/tmux-1001/default,1,0');
|
||||
|
||||
const warnings = getCompatibilityWarnings({ isAlternateBuffer: true });
|
||||
expect(warnings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'tmux-alternate-buffer',
|
||||
message: expect.stringContaining('tmux detected'),
|
||||
priority: WarningPriority.High,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return low-color tmux warning when detected', () => {
|
||||
vi.stubEnv('TERM', 'screen');
|
||||
vi.stubEnv('TMUX', '1');
|
||||
@@ -422,6 +423,18 @@ describe('compatibility', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return no color warnings for kmscon terminal', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.stubEnv('TERMINAL_EMULATOR', '');
|
||||
vi.stubEnv('TERM', 'linux');
|
||||
vi.stubEnv('COLORTERM', 'kmscon');
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
|
||||
|
||||
const warnings = getCompatibilityWarnings();
|
||||
expect(warnings.find((w) => w.id === '256-color')).toBeUndefined();
|
||||
expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return no warnings in a standard environment with true color', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.stubEnv('TERMINAL_EMULATOR', '');
|
||||
|
||||
@@ -85,6 +85,11 @@ export function supports256Colors(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Terminals supporting true color (like kmscon) also support 256 colors
|
||||
if (supportsTrueColor()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -95,7 +100,8 @@ export function supportsTrueColor(): boolean {
|
||||
// Check COLORTERM environment variable
|
||||
if (
|
||||
process.env['COLORTERM'] === 'truecolor' ||
|
||||
process.env['COLORTERM'] === '24bit'
|
||||
process.env['COLORTERM'] === '24bit' ||
|
||||
process.env['COLORTERM'] === 'kmscon'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -145,15 +151,6 @@ export function getCompatibilityWarnings(options?: {
|
||||
});
|
||||
}
|
||||
|
||||
if (isTmux() && options?.isAlternateBuffer) {
|
||||
warnings.push({
|
||||
id: 'tmux-alternate-buffer',
|
||||
message:
|
||||
'Warning: tmux detected — alternate buffer mode may cause unexpected scrollback loss and flickering. If you experience issues, disable it in /settings → "Use Alternate Screen Buffer".\n Tip: Use Ctrl-b [ to access tmux copy mode for scrolling history.',
|
||||
priority: WarningPriority.High,
|
||||
});
|
||||
}
|
||||
|
||||
if (isLowColorTmux()) {
|
||||
warnings.push({
|
||||
id: 'low-color-tmux',
|
||||
|
||||
@@ -302,6 +302,10 @@ export async function openDiff(
|
||||
|
||||
if (isTerminalEditor(editor)) {
|
||||
try {
|
||||
if (!commandExists(diffCommand.command)) {
|
||||
throw new Error(`Editor command not found: ${diffCommand.command}`);
|
||||
}
|
||||
|
||||
const result = spawnSync(diffCommand.command, diffCommand.args, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ export function parseAndFormatApiError(
|
||||
if (isApiError(nestedError)) {
|
||||
finalMessage = nestedError.error.message;
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// It's not a nested JSON error, so we just use the message as is.
|
||||
}
|
||||
let text = `[API Error: ${finalMessage} (Status: ${parsedError.error.status})]`;
|
||||
@@ -75,7 +75,7 @@ export function parseAndFormatApiError(
|
||||
}
|
||||
return text;
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Not a valid JSON, fall through and return the original message.
|
||||
}
|
||||
return `[API Error: ${error}]`;
|
||||
|
||||
@@ -109,6 +109,13 @@ export interface HookEndPayload extends HookPayload {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the 'hook-system-message' event.
|
||||
*/
|
||||
export interface HookSystemMessagePayload extends HookPayload {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the 'retry-attempt' event.
|
||||
*/
|
||||
@@ -183,6 +190,7 @@ export enum CoreEvent {
|
||||
SettingsChanged = 'settings-changed',
|
||||
HookStart = 'hook-start',
|
||||
HookEnd = 'hook-end',
|
||||
HookSystemMessage = 'hook-system-message',
|
||||
AgentsRefreshed = 'agents-refreshed',
|
||||
AdminSettingsChanged = 'admin-settings-changed',
|
||||
RetryAttempt = 'retry-attempt',
|
||||
@@ -217,6 +225,7 @@ export interface CoreEvents extends ExtensionEvents {
|
||||
[CoreEvent.SettingsChanged]: never[];
|
||||
[CoreEvent.HookStart]: [HookStartPayload];
|
||||
[CoreEvent.HookEnd]: [HookEndPayload];
|
||||
[CoreEvent.HookSystemMessage]: [HookSystemMessagePayload];
|
||||
[CoreEvent.AgentsRefreshed]: never[];
|
||||
[CoreEvent.AdminSettingsChanged]: never[];
|
||||
[CoreEvent.RetryAttempt]: [RetryAttemptPayload];
|
||||
@@ -339,6 +348,13 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
||||
this.emit(CoreEvent.HookEnd, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies subscribers that a hook has provided a system message.
|
||||
*/
|
||||
emitHookSystemMessage(payload: HookSystemMessagePayload): void {
|
||||
this.emit(CoreEvent.HookSystemMessage, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies subscribers that agents have been refreshed.
|
||||
*/
|
||||
|
||||
@@ -4,21 +4,37 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { updateGlobalFetchTimeouts } from './fetch.js';
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
||||
import {
|
||||
isPrivateIp,
|
||||
isPrivateIpAsync,
|
||||
isAddressPrivate,
|
||||
fetchWithTimeout,
|
||||
} from './fetch.js';
|
||||
import * as dnsPromises from 'node:dns/promises';
|
||||
import type { LookupAddress, LookupAllOptions } from 'node:dns';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
|
||||
const { setGlobalDispatcher, Agent, ProxyAgent } = vi.hoisted(() => ({
|
||||
setGlobalDispatcher: vi.fn(),
|
||||
Agent: vi.fn(),
|
||||
ProxyAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
setGlobalDispatcher,
|
||||
Agent,
|
||||
ProxyAgent,
|
||||
}));
|
||||
|
||||
vi.mock('node:dns/promises', () => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import after mocks are established
|
||||
const {
|
||||
isPrivateIp,
|
||||
isPrivateIpAsync,
|
||||
isAddressPrivate,
|
||||
fetchWithTimeout,
|
||||
setGlobalProxy,
|
||||
} = await import('./fetch.js');
|
||||
|
||||
// Mock global fetch
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = vi.fn();
|
||||
@@ -183,4 +199,19 @@ describe('fetch utils', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setGlobalProxy', () => {
|
||||
it('should configure ProxyAgent with experiment flag timeout', () => {
|
||||
const proxyUrl = 'http://proxy.example.com';
|
||||
updateGlobalFetchTimeouts(45773134);
|
||||
setGlobalProxy(proxyUrl);
|
||||
|
||||
expect(ProxyAgent).toHaveBeenCalledWith({
|
||||
uri: proxyUrl,
|
||||
headersTimeout: 45773134,
|
||||
bodyTimeout: 45773134,
|
||||
});
|
||||
expect(setGlobalDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,6 @@ import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
|
||||
const DEFAULT_HEADERS_TIMEOUT = 300000; // 5 minutes
|
||||
const DEFAULT_BODY_TIMEOUT = 300000; // 5 minutes
|
||||
|
||||
export class FetchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -31,14 +28,36 @@ export class PrivateIpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
let defaultTimeout = 300000; // 5 minutes
|
||||
let currentProxy: string | undefined = undefined;
|
||||
|
||||
// Configure default global dispatcher with higher timeouts
|
||||
setGlobalDispatcher(
|
||||
new Agent({
|
||||
headersTimeout: DEFAULT_HEADERS_TIMEOUT,
|
||||
bodyTimeout: DEFAULT_BODY_TIMEOUT,
|
||||
headersTimeout: defaultTimeout,
|
||||
bodyTimeout: defaultTimeout,
|
||||
}),
|
||||
);
|
||||
|
||||
export function updateGlobalFetchTimeouts(timeoutMs: number) {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new RangeError(
|
||||
`Invalid timeout value: ${timeoutMs}. Must be a positive finite number.`,
|
||||
);
|
||||
}
|
||||
defaultTimeout = timeoutMs;
|
||||
if (currentProxy) {
|
||||
setGlobalProxy(currentProxy);
|
||||
} else {
|
||||
setGlobalDispatcher(
|
||||
new Agent({
|
||||
headersTimeout: defaultTimeout,
|
||||
bodyTimeout: defaultTimeout,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a hostname by stripping IPv6 brackets if present.
|
||||
*/
|
||||
@@ -191,11 +210,12 @@ export async function fetchWithTimeout(
|
||||
}
|
||||
|
||||
export function setGlobalProxy(proxy: string) {
|
||||
currentProxy = proxy;
|
||||
setGlobalDispatcher(
|
||||
new ProxyAgent({
|
||||
uri: proxy,
|
||||
headersTimeout: DEFAULT_HEADERS_TIMEOUT,
|
||||
bodyTimeout: DEFAULT_BODY_TIMEOUT,
|
||||
headersTimeout: defaultTimeout,
|
||||
bodyTimeout: defaultTimeout,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
isEmpty,
|
||||
} from './fileUtils.js';
|
||||
import { StandardFileSystemService } from '../services/fileSystemService.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
|
||||
vi.mock('mime/lite', () => ({
|
||||
default: { getType: vi.fn() },
|
||||
@@ -54,6 +55,7 @@ describe('fileUtils', () => {
|
||||
let testImageFilePath: string;
|
||||
let testPdfFilePath: string;
|
||||
let testAudioFilePath: string;
|
||||
let testVideoFilePath: string;
|
||||
let testBinaryFilePath: string;
|
||||
let nonexistentFilePath: string;
|
||||
let directoryPath: string;
|
||||
@@ -70,6 +72,7 @@ describe('fileUtils', () => {
|
||||
testImageFilePath = path.join(tempRootDir, 'image.png');
|
||||
testPdfFilePath = path.join(tempRootDir, 'document.pdf');
|
||||
testAudioFilePath = path.join(tempRootDir, 'audio.mp3');
|
||||
testVideoFilePath = path.join(tempRootDir, 'video.mp4');
|
||||
testBinaryFilePath = path.join(tempRootDir, 'app.exe');
|
||||
nonexistentFilePath = path.join(tempRootDir, 'nonexistent.txt');
|
||||
directoryPath = path.join(tempRootDir, 'subdir');
|
||||
@@ -283,6 +286,25 @@ describe('fileUtils', () => {
|
||||
}
|
||||
expect(await isBinaryFile(filePathForBinaryTest)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a source file containing literal U+FFFD (replacement character)', async () => {
|
||||
const content =
|
||||
'// Rust-style source\npub const UNICODE_REPLACEMENT_CHAR: char = \'\uFFFD\';\nlet s = "\uFFFD\uFFFD\uFFFD";\n';
|
||||
actualNodeFs.writeFileSync(filePathForBinaryTest, content, 'utf8');
|
||||
expect(await isBinaryFile(filePathForBinaryTest)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a file with mixed CJK, emoji, and U+FFFD content', async () => {
|
||||
const content = '\uFFFD\uFFFD hello \u4e16\u754c \uD83D\uDE00\n';
|
||||
actualNodeFs.writeFileSync(filePathForBinaryTest, content, 'utf8');
|
||||
expect(await isBinaryFile(filePathForBinaryTest)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a file with dense invalid UTF-8 byte sequences', async () => {
|
||||
const binaryContent = Buffer.alloc(128, 0x80);
|
||||
actualNodeFs.writeFileSync(filePathForBinaryTest, binaryContent);
|
||||
expect(await isBinaryFile(filePathForBinaryTest)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BOM detection and encoding', () => {
|
||||
@@ -704,6 +726,19 @@ describe('fileUtils', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('should detect supported audio files by extension when mime lookup is missing', async () => {
|
||||
const filePath = path.join(tempRootDir, 'fallback.flac');
|
||||
actualNodeFs.writeFileSync(
|
||||
filePath,
|
||||
Buffer.from([0x66, 0x4c, 0x61, 0x43, 0x00, 0x00, 0x00, 0x22]),
|
||||
);
|
||||
mockMimeGetType.mockReturnValueOnce(false);
|
||||
|
||||
expect(await detectFileType(filePath)).toBe('audio');
|
||||
|
||||
actualNodeFs.unlinkSync(filePath);
|
||||
});
|
||||
|
||||
it('should detect svg type by extension', async () => {
|
||||
expect(await detectFileType('image.svg')).toBe('svg');
|
||||
expect(await detectFileType('image.icon.svg')).toBe('svg');
|
||||
@@ -755,6 +790,8 @@ describe('fileUtils', () => {
|
||||
actualNodeFs.unlinkSync(testPdfFilePath);
|
||||
if (actualNodeFs.existsSync(testAudioFilePath))
|
||||
actualNodeFs.unlinkSync(testAudioFilePath);
|
||||
if (actualNodeFs.existsSync(testVideoFilePath))
|
||||
actualNodeFs.unlinkSync(testVideoFilePath);
|
||||
if (actualNodeFs.existsSync(testBinaryFilePath))
|
||||
actualNodeFs.unlinkSync(testBinaryFilePath);
|
||||
});
|
||||
@@ -880,6 +917,70 @@ describe('fileUtils', () => {
|
||||
expect(result.returnDisplay).toContain('Read audio file: audio.mp3');
|
||||
});
|
||||
|
||||
it('should normalize supported audio mime types before returning inline data', async () => {
|
||||
const fakeWavData = Buffer.from([
|
||||
0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
const wavFilePath = path.join(tempRootDir, 'voice.wav');
|
||||
actualNodeFs.writeFileSync(wavFilePath, fakeWavData);
|
||||
mockMimeGetType.mockReturnValue('audio/x-wav');
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
wavFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
);
|
||||
|
||||
expect(
|
||||
(result.llmContent as { inlineData: { mimeType: string } }).inlineData
|
||||
.mimeType,
|
||||
).toBe('audio/wav');
|
||||
});
|
||||
|
||||
it('should reject unsupported audio mime types with a clear error', async () => {
|
||||
const unsupportedAudioPath = path.join(tempRootDir, 'legacy.adp');
|
||||
actualNodeFs.writeFileSync(
|
||||
unsupportedAudioPath,
|
||||
Buffer.from([0x00, 0x01, 0x02, 0x03]),
|
||||
);
|
||||
mockMimeGetType.mockReturnValue('audio/adpcm');
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
unsupportedAudioPath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
);
|
||||
|
||||
expect(result.errorType).toBe(ToolErrorType.READ_CONTENT_FAILURE);
|
||||
expect(result.error).toContain('Unsupported audio file format');
|
||||
expect(result.returnDisplay).toContain('Unsupported audio file format');
|
||||
});
|
||||
|
||||
it('should process a video file', async () => {
|
||||
const fakeMp4Data = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
|
||||
0x00, 0x00, 0x02, 0x00,
|
||||
]);
|
||||
actualNodeFs.writeFileSync(testVideoFilePath, fakeMp4Data);
|
||||
mockMimeGetType.mockReturnValue('video/mp4');
|
||||
const result = await processSingleFileContent(
|
||||
testVideoFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
);
|
||||
expect(
|
||||
(result.llmContent as { inlineData: unknown }).inlineData,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
(result.llmContent as { inlineData: { mimeType: string } }).inlineData
|
||||
.mimeType,
|
||||
).toBe('video/mp4');
|
||||
expect(
|
||||
(result.llmContent as { inlineData: { data: string } }).inlineData.data,
|
||||
).toBe(fakeMp4Data.toString('base64'));
|
||||
expect(result.returnDisplay).toContain('Read video file: video.mp4');
|
||||
});
|
||||
|
||||
it('should read an SVG file as text when under 1MB', async () => {
|
||||
const svgContent = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
|
||||
@@ -8,6 +8,7 @@ import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { PartUnion } from '@google/genai';
|
||||
import { isBinaryFile as isBinaryFileCheck } from 'isbinaryfile';
|
||||
import mime from 'mime/lite';
|
||||
import type { FileSystemService } from '../services/fileSystemService.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
@@ -201,6 +202,72 @@ export function getSpecificMimeType(filePath: string): string | undefined {
|
||||
return typeof lookedUpMime === 'string' ? lookedUpMime : undefined;
|
||||
}
|
||||
|
||||
const SUPPORTED_AUDIO_MIME_TYPES_BY_EXTENSION = new Map<string, string>([
|
||||
['.mp3', 'audio/mpeg'],
|
||||
['.wav', 'audio/wav'],
|
||||
['.aiff', 'audio/aiff'],
|
||||
['.aif', 'audio/aiff'],
|
||||
['.aac', 'audio/aac'],
|
||||
['.ogg', 'audio/ogg'],
|
||||
['.flac', 'audio/flac'],
|
||||
]);
|
||||
|
||||
const AUDIO_MIME_TYPE_NORMALIZATION: Record<string, string> = {
|
||||
'audio/mp3': 'audio/mpeg',
|
||||
'audio/x-mp3': 'audio/mpeg',
|
||||
'audio/wave': 'audio/wav',
|
||||
'audio/x-wav': 'audio/wav',
|
||||
'audio/vnd.wave': 'audio/wav',
|
||||
'audio/x-pn-wav': 'audio/wav',
|
||||
'audio/x-aiff': 'audio/aiff',
|
||||
'audio/aif': 'audio/aiff',
|
||||
'audio/x-aac': 'audio/aac',
|
||||
};
|
||||
|
||||
function formatSupportedAudioFormats(): string {
|
||||
const displayNames = Array.from(
|
||||
new Set(
|
||||
Array.from(SUPPORTED_AUDIO_MIME_TYPES_BY_EXTENSION.keys()).map((ext) => {
|
||||
if (ext === '.aif' || ext === '.aiff') {
|
||||
return 'AIFF';
|
||||
}
|
||||
return ext.slice(1).toUpperCase();
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (displayNames.length <= 1) {
|
||||
return displayNames[0] ?? '';
|
||||
}
|
||||
|
||||
return `${displayNames.slice(0, -1).join(', ')}, and ${displayNames.at(-1)}`;
|
||||
}
|
||||
|
||||
const SUPPORTED_AUDIO_FORMATS_DISPLAY = formatSupportedAudioFormats();
|
||||
|
||||
function getSupportedAudioMimeTypeForFile(
|
||||
filePath: string,
|
||||
): string | undefined {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const extensionMimeType =
|
||||
SUPPORTED_AUDIO_MIME_TYPES_BY_EXTENSION.get(extension);
|
||||
const lookedUpMimeType = getSpecificMimeType(filePath)?.toLowerCase();
|
||||
const normalizedMimeType = lookedUpMimeType
|
||||
? (AUDIO_MIME_TYPE_NORMALIZATION[lookedUpMimeType] ?? lookedUpMimeType)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
normalizedMimeType &&
|
||||
[...SUPPORTED_AUDIO_MIME_TYPES_BY_EXTENSION.values()].includes(
|
||||
normalizedMimeType,
|
||||
)
|
||||
) {
|
||||
return normalizedMimeType;
|
||||
}
|
||||
|
||||
return extensionMimeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is within a given root directory.
|
||||
* @param pathToCheck The absolute path to check.
|
||||
@@ -279,53 +346,17 @@ export async function isEmpty(filePath: string): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Heuristic: determine if a file is likely binary.
|
||||
* Now BOM-aware: if a Unicode BOM is detected, we treat it as text.
|
||||
* For non-BOM files, retain the existing null-byte and non-printable ratio checks.
|
||||
* Delegates to the `isbinaryfile` package for UTF-8-aware detection.
|
||||
*/
|
||||
export async function isBinaryFile(filePath: string): Promise<boolean> {
|
||||
let fh: fs.promises.FileHandle | null = null;
|
||||
try {
|
||||
fh = await fs.promises.open(filePath, 'r');
|
||||
const stats = await fh.stat();
|
||||
const fileSize = stats.size;
|
||||
if (fileSize === 0) return false; // empty is not binary
|
||||
|
||||
// Sample up to 4KB from the head (previous behavior)
|
||||
const sampleSize = Math.min(4096, fileSize);
|
||||
const buf = Buffer.alloc(sampleSize);
|
||||
const { bytesRead } = await fh.read(buf, 0, sampleSize, 0);
|
||||
if (bytesRead === 0) return false;
|
||||
|
||||
// BOM → text (avoid false positives for UTF‑16/32 with nulls)
|
||||
const bom = detectBOM(buf.subarray(0, Math.min(4, bytesRead)));
|
||||
if (bom) return false;
|
||||
|
||||
let nonPrintableCount = 0;
|
||||
for (let i = 0; i < bytesRead; i++) {
|
||||
if (buf[i] === 0) return true; // strong indicator of binary when no BOM
|
||||
if (buf[i] < 9 || (buf[i] > 13 && buf[i] < 32)) {
|
||||
nonPrintableCount++;
|
||||
}
|
||||
}
|
||||
// If >30% non-printable characters, consider it binary
|
||||
return nonPrintableCount / bytesRead > 0.3;
|
||||
return Boolean(await isBinaryFileCheck(filePath));
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`Failed to check if file is binary: ${filePath}`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
if (fh) {
|
||||
try {
|
||||
await fh.close();
|
||||
} catch (closeError) {
|
||||
debugLogger.warn(
|
||||
`Failed to close file handle for: ${filePath}`,
|
||||
closeError instanceof Error ? closeError.message : String(closeError),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +401,14 @@ export async function detectFileType(
|
||||
}
|
||||
}
|
||||
|
||||
const supportedAudioMimeType = getSupportedAudioMimeTypeForFile(filePath);
|
||||
if (supportedAudioMimeType) {
|
||||
if (!(await isBinaryFile(filePath))) {
|
||||
return 'text';
|
||||
}
|
||||
return 'audio';
|
||||
}
|
||||
|
||||
// Stricter binary check for common non-text extensions before content check
|
||||
// These are often not well-covered by mime-types or might be misidentified.
|
||||
if (BINARY_EXTENSIONS.includes(ext)) {
|
||||
@@ -532,17 +571,40 @@ export async function processSingleFileContent(
|
||||
linesShown: [actualStart + 1, sliceEnd],
|
||||
};
|
||||
}
|
||||
case 'image':
|
||||
case 'pdf':
|
||||
case 'audio':
|
||||
case 'video': {
|
||||
case 'audio': {
|
||||
const mimeType = getSupportedAudioMimeTypeForFile(filePath);
|
||||
if (!mimeType) {
|
||||
return {
|
||||
llmContent: `Could not read audio file because its format is not supported. Supported audio formats are ${SUPPORTED_AUDIO_FORMATS_DISPLAY}.`,
|
||||
returnDisplay: `Unsupported audio file format: ${relativePathForDisplay}`,
|
||||
error: `Unsupported audio file format for ${filePath}. Supported audio formats are ${SUPPORTED_AUDIO_FORMATS_DISPLAY}.`,
|
||||
errorType: ToolErrorType.READ_CONTENT_FAILURE,
|
||||
};
|
||||
}
|
||||
const contentBuffer = await fs.promises.readFile(filePath);
|
||||
const base64Data = contentBuffer.toString('base64');
|
||||
return {
|
||||
llmContent: {
|
||||
inlineData: {
|
||||
data: base64Data,
|
||||
mimeType: mime.getType(filePath) || 'application/octet-stream',
|
||||
mimeType,
|
||||
},
|
||||
},
|
||||
returnDisplay: `Read audio file: ${relativePathForDisplay}`,
|
||||
};
|
||||
}
|
||||
case 'image':
|
||||
case 'pdf':
|
||||
case 'video': {
|
||||
const mimeType =
|
||||
getSpecificMimeType(filePath) ?? 'application/octet-stream';
|
||||
const contentBuffer = await fs.promises.readFile(filePath);
|
||||
const base64Data = contentBuffer.toString('base64');
|
||||
return {
|
||||
llmContent: {
|
||||
inlineData: {
|
||||
data: base64Data,
|
||||
mimeType,
|
||||
},
|
||||
},
|
||||
returnDisplay: `Read ${fileType} file: ${relativePathForDisplay}`,
|
||||
@@ -576,7 +638,7 @@ export async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fsPromises.access(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch (_: unknown) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function crawl(options: CrawlOptions): Promise<string[]> {
|
||||
}
|
||||
|
||||
results = await api.crawl(options.crawlDirectory).withPromise();
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// The directory probably doesn't exist.
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { FileSearchFactory, AbortError, filter } from './fileSearch.js';
|
||||
import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils';
|
||||
import * as crawler from './crawler.js';
|
||||
import { GEMINI_IGNORE_FILE_NAME } from '../../config/constants.js';
|
||||
import { FileDiscoveryService } from '../../services/fileDiscoveryService.js';
|
||||
import { escapePath } from '../paths.js';
|
||||
|
||||
describe('FileSearch', () => {
|
||||
let tmpDir: string;
|
||||
@@ -789,11 +791,12 @@ describe('FileSearch', () => {
|
||||
|
||||
// Search for the file using a pattern that contains special characters.
|
||||
// The `unescapePath` function should handle the escaped path correctly.
|
||||
const results = await fileSearch.search(
|
||||
'src/file with \\(special\\) chars.txt',
|
||||
);
|
||||
const searchPattern = escapePath('src/file with (special) chars.txt');
|
||||
const results = await fileSearch.search(searchPattern);
|
||||
|
||||
expect(results).toEqual(['src/file with (special) chars.txt']);
|
||||
expect(results.map((r) => path.normalize(r))).toEqual([
|
||||
path.normalize('src/file with (special) chars.txt'),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('DirectoryFileSearch', () => {
|
||||
|
||||
@@ -113,7 +113,9 @@ async function readFullStructure(
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
isNodeError(error) &&
|
||||
(error.code === 'EACCES' || error.code === 'ENOENT')
|
||||
(error.code === 'EACCES' ||
|
||||
error.code === 'ENOENT' ||
|
||||
error.code === 'EPERM')
|
||||
) {
|
||||
debugLogger.warn(
|
||||
`Warning: Could not read directory ${currentPath}: ${error.message}`,
|
||||
@@ -121,7 +123,7 @@ async function readFullStructure(
|
||||
if (currentPath === rootPath && error.code === 'ENOENT') {
|
||||
return null; // Root directory itself not found
|
||||
}
|
||||
// For other EACCES/ENOENT on subdirectories, just skip them.
|
||||
// For other EACCES/ENOENT/EPERM on subdirectories, just skip them.
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -27,14 +27,14 @@ export const getPty = async (): Promise<PtyImplementation> => {
|
||||
const module = await import(lydell);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
return { module, name: 'lydell-node-pty' };
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
try {
|
||||
const nodePty = 'node-pty';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const module = await import(nodePty);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
return { module, name: 'node-pty' };
|
||||
} catch (_e2) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export class GitIgnoreParser implements GitIgnoreFilter {
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(patternsFilePath, 'utf-8');
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return ignore();
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ export class GitIgnoreParser implements GitIgnoreFilter {
|
||||
|
||||
// Extra patterns (like .geminiignore) have final precedence
|
||||
return ig.add(this.processedExtraPatterns).ignores(normalizedPath);
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function isGitRepository(directory: string): boolean {
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
// If any filesystem error occurs, assume not a git repo
|
||||
return false;
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export function findGitRoot(directory: string): string | null {
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
|
||||
if (typeof errorObj === 'string') {
|
||||
try {
|
||||
errorObj = JSON.parse(sanitizeJsonString(errorObj));
|
||||
} catch (_) {
|
||||
} catch {
|
||||
// Not a JSON string, can't parse.
|
||||
return null;
|
||||
}
|
||||
@@ -200,7 +200,7 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
|
||||
// The message is a JSON string, but not a nested error object.
|
||||
break;
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
// It wasn't a JSON string, so we've drilled down as far as we can.
|
||||
break;
|
||||
}
|
||||
@@ -284,7 +284,7 @@ function fromGaxiosError(errorObj: object): ErrorShape | undefined {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
data = JSON.parse(sanitizeJsonString(data));
|
||||
} catch (_) {
|
||||
} catch {
|
||||
// Not a JSON string, can't parse.
|
||||
}
|
||||
}
|
||||
@@ -334,7 +334,7 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
data = JSON.parse(sanitizeJsonString(data));
|
||||
} catch (_) {
|
||||
} catch {
|
||||
// Not a JSON string, can't parse.
|
||||
// Try one more fallback: look for the first '{' and last '}'
|
||||
if (typeof data === 'string') {
|
||||
@@ -346,7 +346,7 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
|
||||
data = JSON.parse(
|
||||
sanitizeJsonString(data.substring(firstBrace, lastBrace + 1)),
|
||||
);
|
||||
} catch (__) {
|
||||
} catch {
|
||||
// Still failed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,32 @@ describe('classifyGoogleError', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should return RetryableQuotaError with delay for 503 Service Unavailable with RetryInfo', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 503,
|
||||
message:
|
||||
'No capacity available for model gemini-3.1-pro-preview on the server',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'MODEL_CAPACITY_EXHAUSTED',
|
||||
domain: 'cloudcode-pa.googleapis.com',
|
||||
metadata: {
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
|
||||
retryDelay: '9s',
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const result = classifyGoogleError(new Error());
|
||||
expect(result).toBeInstanceOf(RetryableQuotaError);
|
||||
expect((result as RetryableQuotaError).retryDelayMs).toBe(9000);
|
||||
});
|
||||
|
||||
it('should return original error if code is not 429, 499 or 503', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 500,
|
||||
|
||||
@@ -14,6 +14,14 @@ import {
|
||||
} from './googleErrors.js';
|
||||
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
|
||||
|
||||
// Enum for Google API type strings
|
||||
enum GoogleApiType {
|
||||
ERROR_INFO = 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
HELP = 'type.googleapis.com/google.rpc.Help',
|
||||
QUOTA_FAILURE = 'type.googleapis.com/google.rpc.QuotaFailure',
|
||||
RETRY_INFO = 'type.googleapis.com/google.rpc.RetryInfo',
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-retryable error indicating a hard quota limit has been reached (e.g., daily limit).
|
||||
*/
|
||||
@@ -136,8 +144,7 @@ function classifyValidationRequiredError(
|
||||
googleApiError: GoogleApiError,
|
||||
): ValidationRequiredError | null {
|
||||
const errorInfo = googleApiError.details.find(
|
||||
(d): d is ErrorInfo =>
|
||||
d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
(d): d is ErrorInfo => d['@type'] === GoogleApiType.ERROR_INFO,
|
||||
);
|
||||
|
||||
if (!errorInfo) {
|
||||
@@ -154,7 +161,7 @@ function classifyValidationRequiredError(
|
||||
|
||||
// Try to extract validation info from Help detail first
|
||||
const helpDetail = googleApiError.details.find(
|
||||
(d): d is Help => d['@type'] === 'type.googleapis.com/google.rpc.Help',
|
||||
(d): d is Help => d['@type'] === GoogleApiType.HELP,
|
||||
);
|
||||
|
||||
let validationLink: string | undefined;
|
||||
@@ -198,12 +205,13 @@ function classifyValidationRequiredError(
|
||||
* - 404 errors are classified as `ModelNotFoundError`.
|
||||
* - 403 errors with `VALIDATION_REQUIRED` from cloudcode-pa domains are classified
|
||||
* as `ValidationRequiredError`.
|
||||
* - 429 errors are classified as either `TerminalQuotaError` or `RetryableQuotaError`:
|
||||
* - 429 or 499 errors are classified as either `TerminalQuotaError` or `RetryableQuotaError`:
|
||||
* - CloudCode API: `RATE_LIMIT_EXCEEDED` → `RetryableQuotaError`, `QUOTA_EXHAUSTED` → `TerminalQuotaError`.
|
||||
* - If the error indicates a daily limit (in QuotaFailure), it's a `TerminalQuotaError`.
|
||||
* - If the error has a retry delay, it's a `RetryableQuotaError`.
|
||||
* - If the error indicates a per-minute limit, it's a `RetryableQuotaError`.
|
||||
* - If the error message contains the phrase "Please retry in X[s|ms]", it's a `RetryableQuotaError`.
|
||||
* - 503 errors are classified as `RetryableQuotaError`.
|
||||
*
|
||||
* @param error The error to classify.
|
||||
* @returns A classified error or the original `unknown` error.
|
||||
@@ -227,24 +235,11 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for 503 Service Unavailable errors
|
||||
if (status === 503) {
|
||||
const errorMessage =
|
||||
googleApiError?.message ||
|
||||
(error instanceof Error ? error.message : String(error));
|
||||
return new RetryableQuotaError(
|
||||
errorMessage,
|
||||
googleApiError ?? {
|
||||
code: 503,
|
||||
message: errorMessage,
|
||||
details: [],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!googleApiError ||
|
||||
(googleApiError.code !== 429 && googleApiError.code !== 499) ||
|
||||
(googleApiError.code !== 429 &&
|
||||
googleApiError.code !== 499 &&
|
||||
googleApiError.code !== 503) ||
|
||||
googleApiError.details.length === 0
|
||||
) {
|
||||
// Fallback: try to parse the error message for a retry delay
|
||||
@@ -265,9 +260,9 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
}
|
||||
return new RetryableQuotaError(errorMessage, cause, retryDelaySeconds);
|
||||
}
|
||||
} else if (status === 429 || status === 499) {
|
||||
// Fallback: If it is a 429 or 499 but doesn't have a specific "retry in" message,
|
||||
// assume it is a temporary rate limit and retry after 5 sec (same as DEFAULT_RETRY_OPTIONS).
|
||||
} else if (status === 429 || status === 499 || status === 503) {
|
||||
// Fallback: If it is a 429, 499, or 503 but doesn't have a specific "retry in" message,
|
||||
// assume it is a temporary rate limit and retry.
|
||||
return new RetryableQuotaError(
|
||||
errorMessage,
|
||||
googleApiError ?? {
|
||||
@@ -282,18 +277,15 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
}
|
||||
|
||||
const quotaFailure = googleApiError.details.find(
|
||||
(d): d is QuotaFailure =>
|
||||
d['@type'] === 'type.googleapis.com/google.rpc.QuotaFailure',
|
||||
(d): d is QuotaFailure => d['@type'] === GoogleApiType.QUOTA_FAILURE,
|
||||
);
|
||||
|
||||
const errorInfo = googleApiError.details.find(
|
||||
(d): d is ErrorInfo =>
|
||||
d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
(d): d is ErrorInfo => d['@type'] === GoogleApiType.ERROR_INFO,
|
||||
);
|
||||
|
||||
const retryInfo = googleApiError.details.find(
|
||||
(d): d is RetryInfo =>
|
||||
d['@type'] === 'type.googleapis.com/google.rpc.RetryInfo',
|
||||
(d): d is RetryInfo => d['@type'] === GoogleApiType.RETRY_INFO,
|
||||
);
|
||||
|
||||
// 1. Check for long-term limits in QuotaFailure or ErrorInfo
|
||||
@@ -321,7 +313,7 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
// INSUFFICIENT_G1_CREDITS_BALANCE is always terminal, regardless of domain
|
||||
if (errorInfo.reason === 'INSUFFICIENT_G1_CREDITS_BALANCE') {
|
||||
return new TerminalQuotaError(
|
||||
`${googleApiError.message}`,
|
||||
googleApiError.message,
|
||||
googleApiError,
|
||||
delaySeconds,
|
||||
errorInfo.reason,
|
||||
@@ -335,21 +327,21 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
const effectiveDelay = delaySeconds ?? 10;
|
||||
if (effectiveDelay > MAX_RETRYABLE_DELAY_SECONDS) {
|
||||
return new TerminalQuotaError(
|
||||
`${googleApiError.message}`,
|
||||
googleApiError.message,
|
||||
googleApiError,
|
||||
effectiveDelay,
|
||||
errorInfo.reason,
|
||||
);
|
||||
}
|
||||
return new RetryableQuotaError(
|
||||
`${googleApiError.message}`,
|
||||
googleApiError.message,
|
||||
googleApiError,
|
||||
effectiveDelay,
|
||||
);
|
||||
}
|
||||
if (errorInfo.reason === 'QUOTA_EXHAUSTED') {
|
||||
return new TerminalQuotaError(
|
||||
`${googleApiError.message}`,
|
||||
googleApiError.message,
|
||||
googleApiError,
|
||||
delaySeconds,
|
||||
errorInfo.reason,
|
||||
@@ -400,19 +392,10 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
// If we reached this point and the status is still 429 or 499, we return retryable.
|
||||
if (status === 429 || status === 499) {
|
||||
const errorMessage =
|
||||
googleApiError?.message ||
|
||||
(error instanceof Error ? error.message : String(error));
|
||||
return new RetryableQuotaError(
|
||||
errorMessage,
|
||||
googleApiError ?? {
|
||||
code: status,
|
||||
message: errorMessage,
|
||||
details: [],
|
||||
},
|
||||
);
|
||||
}
|
||||
return error; // Fallback to original error if no specific classification fits.
|
||||
// If we reached this point, the status is 429, 499, or 503 and we have details,
|
||||
// but no specific violation was matched. We return a generic retryable error.
|
||||
const errorMessage =
|
||||
googleApiError.message ||
|
||||
(error instanceof Error ? error.message : String(error));
|
||||
return new RetryableQuotaError(errorMessage, googleApiError);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export class IgnoreFileParser implements IgnoreFileFilter {
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(patternsFilePath, 'utf-8');
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
debugLogger.debug(
|
||||
`Ignore file not found: ${patternsFilePath}, continue without it.`,
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
type FileFilteringOptions,
|
||||
} from '../config/constants.js';
|
||||
import { GEMINI_DIR, homedir, normalizePath } from './paths.js';
|
||||
import { GEMINI_DIR, homedir, normalizePath, isSubpath } from './paths.js';
|
||||
import type { ExtensionLoader } from './extensionLoader.js';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -791,15 +791,8 @@ export async function loadJitSubdirectoryMemory(
|
||||
|
||||
// Find the deepest trusted root that contains the target path
|
||||
for (const root of trustedRoots) {
|
||||
const resolvedRoot = normalizePath(root);
|
||||
const resolvedRootWithTrailing = resolvedRoot.endsWith(path.sep)
|
||||
? resolvedRoot
|
||||
: resolvedRoot + path.sep;
|
||||
|
||||
if (
|
||||
resolvedTarget === resolvedRoot ||
|
||||
resolvedTarget.startsWith(resolvedRootWithTrailing)
|
||||
) {
|
||||
if (isSubpath(root, targetPath)) {
|
||||
const resolvedRoot = normalizePath(root);
|
||||
if (!bestRoot || resolvedRoot.length > bestRoot.length) {
|
||||
bestRoot = resolvedRoot;
|
||||
}
|
||||
|
||||
@@ -305,6 +305,28 @@ describe('oauth-flow', () => {
|
||||
'Invalid value for OAUTH_CALLBACK_PORT',
|
||||
);
|
||||
});
|
||||
|
||||
it('should settle on timeout without keeping the process alive', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = startCallbackServer('timeout-state');
|
||||
await server.port;
|
||||
|
||||
const responsePromise = server.response.catch((e: Error) => {
|
||||
if (e.message !== 'OAuth callback timeout') throw e;
|
||||
return e;
|
||||
});
|
||||
|
||||
// Advance timers by 5 minutes to trigger the timeout
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
|
||||
const error = await responsePromise;
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe('OAuth callback timeout');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeCodeForToken', () => {
|
||||
|
||||
@@ -116,6 +116,8 @@ export function startCallbackServer(
|
||||
portReject = reject;
|
||||
});
|
||||
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const responsePromise = new Promise<OAuthAuthorizationResponse>(
|
||||
(resolve, reject) => {
|
||||
let serverPort: number;
|
||||
@@ -221,18 +223,31 @@ export function startCallbackServer(
|
||||
portResolve(serverPort); // Resolve port promise immediately
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(
|
||||
const abortController = new AbortController();
|
||||
timeoutId = setTimeout(
|
||||
() => {
|
||||
server.close();
|
||||
reject(new Error('OAuth callback timeout'));
|
||||
abortController.abort(new Error('OAuth callback timeout'));
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
timeoutId.unref();
|
||||
|
||||
const onAbort = () => {
|
||||
server.close();
|
||||
reject(abortController.signal.reason);
|
||||
};
|
||||
abortController.signal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
server.on('close', () => {
|
||||
abortController.signal.removeEventListener('abort', onAbort);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return { port: portPromise, response: responsePromise };
|
||||
return {
|
||||
port: portPromise,
|
||||
response: responsePromise,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
shortenPath,
|
||||
normalizePath,
|
||||
resolveToRealPath,
|
||||
makeRelative,
|
||||
deduplicateAbsolutePaths,
|
||||
toPathKey,
|
||||
} from './paths.js';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
@@ -215,7 +218,7 @@ describe('isSubpath', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSubpath on Windows', () => {
|
||||
describe.skipIf(process.platform !== 'win32')('isSubpath on Windows', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
beforeEach(() => mockPlatform('win32'));
|
||||
@@ -268,6 +271,20 @@ describe('isSubpath on Windows', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform !== 'darwin')('isSubpath on Darwin', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
beforeEach(() => mockPlatform('darwin'));
|
||||
|
||||
it('should be case-insensitive for path components on Darwin', () => {
|
||||
expect(isSubpath('/PROJECT', '/project/src')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for a direct subpath on Darwin', () => {
|
||||
expect(isSubpath('/Users/Test', '/Users/Test/file.txt')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortenPath', () => {
|
||||
describe.skipIf(process.platform === 'win32')('on POSIX', () => {
|
||||
it('should not shorten a path that is shorter than maxLen', () => {
|
||||
@@ -586,6 +603,54 @@ describe('resolveToRealPath', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeRelative', () => {
|
||||
describe.skipIf(process.platform === 'win32')('on POSIX', () => {
|
||||
it('should return relative path if targetPath is already relative', () => {
|
||||
expect(makeRelative('foo/bar', '/root')).toBe('foo/bar');
|
||||
});
|
||||
|
||||
it('should return relative path from root to target', () => {
|
||||
const root = '/Users/test/project';
|
||||
const target = '/Users/test/project/src/file.ts';
|
||||
expect(makeRelative(target, root)).toBe('src/file.ts');
|
||||
});
|
||||
|
||||
it('should return "." if target and root are the same', () => {
|
||||
const root = '/Users/test/project';
|
||||
expect(makeRelative(root, root)).toBe('.');
|
||||
});
|
||||
|
||||
it('should handle parent directories with ..', () => {
|
||||
const root = '/Users/test/project/src';
|
||||
const target = '/Users/test/project/docs/readme.md';
|
||||
expect(makeRelative(target, root)).toBe('../docs/readme.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform !== 'win32')('on Windows', () => {
|
||||
it('should return relative path if targetPath is already relative', () => {
|
||||
expect(makeRelative('foo/bar', 'C:\\root')).toBe('foo/bar');
|
||||
});
|
||||
|
||||
it('should return relative path from root to target', () => {
|
||||
const root = 'C:\\Users\\test\\project';
|
||||
const target = 'C:\\Users\\test\\project\\src\\file.ts';
|
||||
expect(makeRelative(target, root)).toBe('src\\file.ts');
|
||||
});
|
||||
|
||||
it('should return "." if target and root are the same', () => {
|
||||
const root = 'C:\\Users\\test\\project';
|
||||
expect(makeRelative(root, root)).toBe('.');
|
||||
});
|
||||
|
||||
it('should handle parent directories with ..', () => {
|
||||
const root = 'C:\\Users\\test\\project\\src';
|
||||
const target = 'C:\\Users\\test\\project\\docs\\readme.md';
|
||||
expect(makeRelative(target, root)).toBe('..\\docs\\readme.md');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePath', () => {
|
||||
it('should resolve a relative path to an absolute path', () => {
|
||||
const result = normalizePath('some/relative/path');
|
||||
@@ -615,7 +680,19 @@ describe('normalizePath', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('on POSIX', () => {
|
||||
describe.skipIf(process.platform !== 'darwin')('on Darwin', () => {
|
||||
beforeEach(() => mockPlatform('darwin'));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('should lowercase the entire path', () => {
|
||||
const result = normalizePath('/Users/TEST');
|
||||
expect(result).toBe('/users/test');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(
|
||||
process.platform === 'win32' || process.platform === 'darwin',
|
||||
)('on Linux', () => {
|
||||
it('should preserve case', () => {
|
||||
const result = normalizePath('/usr/Local/Bin');
|
||||
expect(result).toContain('Local');
|
||||
@@ -627,4 +704,62 @@ describe('normalizePath', () => {
|
||||
expect(result).toBe('/usr/local/bin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deduplicateAbsolutePaths', () => {
|
||||
it('should return an empty array if no paths are provided', () => {
|
||||
expect(deduplicateAbsolutePaths(undefined)).toEqual([]);
|
||||
expect(deduplicateAbsolutePaths(null)).toEqual([]);
|
||||
expect(deduplicateAbsolutePaths([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should deduplicate paths using their normalized identity', () => {
|
||||
const paths = ['/workspace/foo', '/workspace/foo/'];
|
||||
expect(deduplicateAbsolutePaths(paths)).toEqual(['/workspace/foo']);
|
||||
});
|
||||
|
||||
it('should handle case-insensitivity on Windows and macOS', () => {
|
||||
mockPlatform('win32');
|
||||
const paths = ['/workspace/foo', '/Workspace/Foo'];
|
||||
expect(deduplicateAbsolutePaths(paths)).toEqual(['/workspace/foo']);
|
||||
|
||||
mockPlatform('darwin');
|
||||
const macPaths = ['/tmp/foo', '/Tmp/Foo'];
|
||||
expect(deduplicateAbsolutePaths(macPaths)).toEqual(['/tmp/foo']);
|
||||
|
||||
mockPlatform('linux');
|
||||
const linuxPaths = ['/tmp/foo', '/tmp/FOO'];
|
||||
expect(deduplicateAbsolutePaths(linuxPaths)).toEqual([
|
||||
'/tmp/foo',
|
||||
'/tmp/FOO',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw an error if a path is not absolute', () => {
|
||||
const paths = ['relative/path'];
|
||||
expect(() => deduplicateAbsolutePaths(paths)).toThrow(
|
||||
'Path must be absolute: relative/path',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPathKey', () => {
|
||||
it('should normalize paths and strip trailing slashes', () => {
|
||||
expect(toPathKey('/foo/bar//baz/')).toBe(path.normalize('/foo/bar/baz'));
|
||||
});
|
||||
|
||||
it('should convert paths to lowercase on Windows and macOS', () => {
|
||||
mockPlatform('win32');
|
||||
expect(toPathKey('/Workspace/Foo')).toBe(
|
||||
path.normalize('/workspace/foo'),
|
||||
);
|
||||
// Ensure drive roots are preserved
|
||||
expect(toPathKey('C:\\')).toBe('c:\\');
|
||||
|
||||
mockPlatform('darwin');
|
||||
expect(toPathKey('/Tmp/Foo')).toBe(path.normalize('/tmp/foo'));
|
||||
|
||||
mockPlatform('linux');
|
||||
expect(toPathKey('/Tmp/Foo')).toBe(path.normalize('/Tmp/Foo'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,9 +325,14 @@ export function getProjectHash(projectRoot: string): string {
|
||||
* - On Windows, converts to lowercase for case-insensitivity.
|
||||
*/
|
||||
export function normalizePath(p: string): string {
|
||||
const resolved = path.resolve(p);
|
||||
const platform = process.platform;
|
||||
const isWindows = platform === 'win32';
|
||||
const pathModule = isWindows ? path.win32 : path;
|
||||
|
||||
const resolved = pathModule.resolve(p);
|
||||
const normalized = resolved.replace(/\\/g, '/');
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
const isCaseInsensitive = isWindows || platform === 'darwin';
|
||||
return isCaseInsensitive ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,11 +342,25 @@ export function normalizePath(p: string): string {
|
||||
* @returns True if childPath is a subpath of parentPath, false otherwise.
|
||||
*/
|
||||
export function isSubpath(parentPath: string, childPath: string): boolean {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const platform = process.platform;
|
||||
const isWindows = platform === 'win32';
|
||||
const isDarwin = platform === 'darwin';
|
||||
const pathModule = isWindows ? path.win32 : path;
|
||||
|
||||
// On Windows, path.relative is case-insensitive. On POSIX, it's case-sensitive.
|
||||
const relative = pathModule.relative(parentPath, childPath);
|
||||
// Resolve both paths to absolute to ensure consistent comparison,
|
||||
// especially when mixing relative and absolute paths or when casing differs.
|
||||
let p = pathModule.resolve(parentPath);
|
||||
let c = pathModule.resolve(childPath);
|
||||
|
||||
// On Windows, path.relative is case-insensitive.
|
||||
// On POSIX (including Darwin), path.relative is case-sensitive.
|
||||
// We want it to be case-insensitive on Darwin to match user expectation and sandbox policy.
|
||||
if (isDarwin) {
|
||||
p = p.toLowerCase();
|
||||
c = c.toLowerCase();
|
||||
}
|
||||
|
||||
const relative = pathModule.relative(p, c);
|
||||
|
||||
return (
|
||||
!relative.startsWith(`..${pathModule.sep}`) &&
|
||||
@@ -350,6 +369,22 @@ export function isSubpath(parentPath: string, childPath: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to verify a value is a string and does not contain null bytes.
|
||||
*/
|
||||
export function isValidPathString(p: unknown): p is string {
|
||||
return typeof p === 'string' && !p.includes('\0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a value is a valid path string, throwing an Error otherwise.
|
||||
*/
|
||||
export function assertValidPathString(p: unknown): asserts p is string {
|
||||
if (!isValidPathString(p)) {
|
||||
throw new Error(`Invalid path: ${String(p)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a path to its real path, sanitizing it first.
|
||||
* - Removes 'file://' protocol if present.
|
||||
@@ -360,6 +395,7 @@ export function isSubpath(parentPath: string, childPath: string): boolean {
|
||||
* @returns The resolved real path.
|
||||
*/
|
||||
export function resolveToRealPath(pathStr: string): string {
|
||||
assertValidPathString(pathStr);
|
||||
let resolvedPath = pathStr;
|
||||
|
||||
try {
|
||||
@@ -368,7 +404,7 @@ export function resolveToRealPath(pathStr: string): string {
|
||||
}
|
||||
|
||||
resolvedPath = decodeURIComponent(resolvedPath);
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Ignore error (e.g. malformed URI), keep path from previous step
|
||||
}
|
||||
|
||||
@@ -418,3 +454,45 @@ function robustRealpath(p: string, visited = new Set<string>()): string {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates an array of paths and ensures all paths are absolute.
|
||||
*/
|
||||
export function deduplicateAbsolutePaths(paths?: string[] | null): string[] {
|
||||
if (!paths || paths.length === 0) return [];
|
||||
|
||||
const uniquePathsMap = new Map<string, string>();
|
||||
for (const p of paths) {
|
||||
if (!path.isAbsolute(p)) {
|
||||
throw new Error(`Path must be absolute: ${p}`);
|
||||
}
|
||||
|
||||
const key = toPathKey(p);
|
||||
if (!uniquePathsMap.has(key)) {
|
||||
uniquePathsMap.set(key, p);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniquePathsMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable string key for a path to be used in comparisons or Map lookups.
|
||||
*/
|
||||
export function toPathKey(p: string): string {
|
||||
// Normalize path segments
|
||||
let norm = path.normalize(p);
|
||||
|
||||
// Strip trailing slashes (except for root paths)
|
||||
if (norm.length > 1 && (norm.endsWith('/') || norm.endsWith('\\'))) {
|
||||
// On Windows, don't strip the slash from a drive root (e.g., "C:\\")
|
||||
if (!/^[a-zA-Z]:[\\/]$/.test(norm)) {
|
||||
norm = norm.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to lowercase on case-insensitive platforms
|
||||
const platform = process.platform;
|
||||
const isCaseInsensitive = platform === 'win32' || platform === 'darwin';
|
||||
return isCaseInsensitive ? norm.toLowerCase() : norm;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ describe('process-utils', () => {
|
||||
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL');
|
||||
});
|
||||
|
||||
it('should use escalation on Unix if requested', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
const exited = false;
|
||||
@@ -87,6 +86,11 @@ describe('process-utils', () => {
|
||||
isExited,
|
||||
});
|
||||
|
||||
// flush microtasks
|
||||
await new Promise(process.nextTick);
|
||||
await new Promise(process.nextTick);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
// First call should be SIGTERM
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGTERM');
|
||||
|
||||
@@ -110,6 +114,11 @@ describe('process-utils', () => {
|
||||
isExited,
|
||||
});
|
||||
|
||||
// flush microtasks
|
||||
await new Promise(process.nextTick);
|
||||
await new Promise(process.nextTick);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGTERM');
|
||||
|
||||
// Simulate process exiting
|
||||
@@ -117,10 +126,11 @@ describe('process-utils', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SIGKILL_TIMEOUT_MS);
|
||||
|
||||
// Second call should NOT be SIGKILL because it exited
|
||||
expect(mockProcessKill).not.toHaveBeenCalledWith(-1234, 'SIGKILL');
|
||||
|
||||
await killPromise;
|
||||
});
|
||||
|
||||
it('should fallback to specific process kill if group kill fails', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
mockProcessKill.mockImplementationOnce(() => {
|
||||
|
||||
@@ -32,7 +32,8 @@ export interface KillOptions {
|
||||
* or the PTY's built-in kill method.
|
||||
*
|
||||
* On Unix, it attempts to kill the process group (using -pid) with escalation
|
||||
* from SIGTERM to SIGKILL if requested.
|
||||
* from SIGTERM to SIGKILL if requested. It also walks the process tree using pgrep
|
||||
* to ensure all descendants are killed.
|
||||
*/
|
||||
export async function killProcessGroup(options: KillOptions): Promise<void> {
|
||||
const { pid, escalate = false, isExited = () => false, pty } = options;
|
||||
@@ -49,18 +50,65 @@ export async function killProcessGroup(options: KillOptions): Promise<void> {
|
||||
// Invoke taskkill to ensure the entire tree is terminated and any orphaned descendant processes are reaped.
|
||||
try {
|
||||
await spawnAsync('taskkill', ['/pid', pid.toString(), '/f', '/t']);
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Ignore errors if the process tree is already dead
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Unix logic
|
||||
// Unix logic: Walk process tree to find all descendants
|
||||
const getAllDescendants = async (parentPid: number): Promise<number[]> => {
|
||||
let children: number[] = [];
|
||||
try {
|
||||
const { stdout } = await spawnAsync('pgrep', [
|
||||
'-P',
|
||||
parentPid.toString(),
|
||||
]);
|
||||
const pids = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((p: string) => parseInt(p, 10))
|
||||
.filter((p: number) => !isNaN(p));
|
||||
for (const p of pids) {
|
||||
children.push(p);
|
||||
const grandchildren = await getAllDescendants(p);
|
||||
children = children.concat(grandchildren);
|
||||
}
|
||||
} catch {
|
||||
// pgrep exits with 1 if no children are found
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
const descendants = await getAllDescendants(pid);
|
||||
const allPidsToKill = [...descendants.reverse(), pid];
|
||||
|
||||
try {
|
||||
const initialSignal = options.signal || (escalate ? 'SIGTERM' : 'SIGKILL');
|
||||
|
||||
// Try killing the process group first (-pid)
|
||||
process.kill(-pid, initialSignal);
|
||||
try {
|
||||
process.kill(-pid, initialSignal);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Kill individual processes in the tree to ensure detached descendants are caught
|
||||
for (const targetPid of allPidsToKill) {
|
||||
try {
|
||||
process.kill(targetPid, initialSignal);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (pty) {
|
||||
try {
|
||||
pty.kill(typeof initialSignal === 'string' ? initialSignal : undefined);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (escalate && !isExited()) {
|
||||
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
||||
@@ -70,43 +118,30 @@ export async function killProcessGroup(options: KillOptions): Promise<void> {
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback to specific process kill if group kill fails or on error
|
||||
if (!isExited()) {
|
||||
if (pty) {
|
||||
if (escalate) {
|
||||
|
||||
for (const targetPid of allPidsToKill) {
|
||||
try {
|
||||
// Attempt the group kill BEFORE the pty session leader dies
|
||||
process.kill(-pid, 'SIGTERM');
|
||||
pty.kill('SIGTERM');
|
||||
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
||||
if (!isExited()) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
pty.kill('SIGKILL');
|
||||
}
|
||||
process.kill(targetPid, 'SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if (pty) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL'); // Group kill first
|
||||
pty.kill('SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ultimate fallback if something unexpected throws
|
||||
if (!isExited()) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,6 +511,40 @@ describe('retryWithBackoff', () => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on OpenSSL 3.x SSL error code (ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC)', async () => {
|
||||
const error = new Error('SSL error');
|
||||
(error as any).code = 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC';
|
||||
const mockFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const promise = retryWithBackoff(mockFn, {
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 1,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise).resolves.toBe('success');
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on unknown SSL BAD_RECORD_MAC variant via substring fallback', async () => {
|
||||
const error = new Error('SSL error');
|
||||
(error as any).code = 'ERR_SSL_SOME_FUTURE_BAD_RECORD_MAC';
|
||||
const mockFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const promise = retryWithBackoff(mockFn, {
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 1,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise).resolves.toBe('success');
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on gaxios-style SSL error with code property', async () => {
|
||||
// This matches the exact structure from issue #17318
|
||||
const error = new Error(
|
||||
@@ -634,6 +668,58 @@ describe('retryWithBackoff', () => {
|
||||
);
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not emit onRetry when aborted before catch retry handling', async () => {
|
||||
const abortController = new AbortController();
|
||||
const onRetry = vi.fn();
|
||||
const mockFn = vi.fn().mockImplementation(async () => {
|
||||
const error = new Error('Server error') as HttpError;
|
||||
error.status = 500;
|
||||
abortController.abort();
|
||||
throw error;
|
||||
});
|
||||
|
||||
const promise = retryWithBackoff(mockFn, {
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 100,
|
||||
signal: abortController.signal,
|
||||
onRetry,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow(
|
||||
expect.objectContaining({ name: 'AbortError' }),
|
||||
);
|
||||
expect(onRetry).not.toHaveBeenCalled();
|
||||
expect(debugLogger.warn).not.toHaveBeenCalled();
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not emit onRetry when aborted before content retry handling', async () => {
|
||||
const abortController = new AbortController();
|
||||
const onRetry = vi.fn();
|
||||
const shouldRetryOnContent = vi.fn().mockImplementation(() => {
|
||||
abortController.abort();
|
||||
return true;
|
||||
});
|
||||
const mockFn = vi.fn().mockResolvedValue({});
|
||||
|
||||
const promise = retryWithBackoff(mockFn, {
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 100,
|
||||
signal: abortController.signal,
|
||||
onRetry,
|
||||
shouldRetryOnContent,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow(
|
||||
expect.objectContaining({ name: 'AbortError' }),
|
||||
);
|
||||
expect(onRetry).not.toHaveBeenCalled();
|
||||
expect(debugLogger.warn).not.toHaveBeenCalled();
|
||||
expect(shouldRetryOnContent).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should trigger fallback for OAuth personal users on persistent 500 errors', async () => {
|
||||
const fallbackCallback = vi.fn().mockResolvedValue('gemini-2.5-flash');
|
||||
|
||||
|
||||
@@ -53,14 +53,30 @@ const RETRYABLE_NETWORK_CODES = [
|
||||
'ENOTFOUND',
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
// SSL/TLS transient errors
|
||||
'ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC',
|
||||
'ERR_SSL_WRONG_VERSION_NUMBER',
|
||||
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
|
||||
'ERR_SSL_BAD_RECORD_MAC',
|
||||
'EPROTO', // Generic protocol error (often SSL-related)
|
||||
];
|
||||
|
||||
// Node.js builds SSL error codes by prepending ERR_SSL_ to the uppercased
|
||||
// OpenSSL reason string with spaces replaced by underscores (see
|
||||
// TLSWrap::ClearOut in node/src/crypto/crypto_tls.cc). The reason string
|
||||
// format varies by OpenSSL version (e.g. ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC
|
||||
// on OpenSSL 1.x, ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC on OpenSSL 3.x), so
|
||||
// match the stable suffix instead of enumerating every variant.
|
||||
const RETRYABLE_SSL_ERROR_PATTERN = /^ERR_SSL_.*BAD_RECORD_MAC/i;
|
||||
|
||||
/**
|
||||
* Returns true if the error code should be retried: either an exact match
|
||||
* against RETRYABLE_NETWORK_CODES, or an SSL BAD_RECORD_MAC variant (the
|
||||
* OpenSSL reason-string portion of the code varies across OpenSSL versions).
|
||||
*/
|
||||
function isRetryableSslErrorCode(code: string): boolean {
|
||||
return (
|
||||
RETRYABLE_NETWORK_CODES.includes(code) ||
|
||||
RETRYABLE_SSL_ERROR_PATTERN.test(code)
|
||||
);
|
||||
}
|
||||
|
||||
function getNetworkErrorCode(error: unknown): string | undefined {
|
||||
const getCode = (obj: unknown): string | undefined => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
@@ -112,7 +128,7 @@ export function getRetryErrorType(error: unknown): string {
|
||||
}
|
||||
|
||||
const errorCode = getNetworkErrorCode(error);
|
||||
if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) {
|
||||
if (errorCode && isRetryableSslErrorCode(errorCode)) {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
@@ -153,7 +169,7 @@ export function isRetryableError(
|
||||
): boolean {
|
||||
// Check for common network error codes
|
||||
const errorCode = getNetworkErrorCode(error);
|
||||
if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) {
|
||||
if (errorCode && isRetryableSslErrorCode(errorCode)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -232,6 +248,11 @@ export async function retryWithBackoff<T>(
|
||||
|
||||
let attempt = 0;
|
||||
let currentDelay = initialDelayMs;
|
||||
const throwIfAborted = () => {
|
||||
if (signal?.aborted) {
|
||||
throw createAbortError();
|
||||
}
|
||||
};
|
||||
|
||||
while (attempt < maxAttempts) {
|
||||
if (signal?.aborted) {
|
||||
@@ -246,6 +267,7 @@ export async function retryWithBackoff<T>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
shouldRetryOnContent(result as GenerateContentResponse)
|
||||
) {
|
||||
throwIfAborted();
|
||||
const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1);
|
||||
const delayWithJitter = Math.max(0, currentDelay + jitter);
|
||||
if (onRetry) {
|
||||
@@ -266,6 +288,7 @@ export async function retryWithBackoff<T>(
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
throwIfAborted();
|
||||
|
||||
const classifiedError = classifyGoogleError(error);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function validateUrl(url: string): void {
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
throw new Error(`Invalid URL: ${url}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const sessionId = randomUUID();
|
||||
|
||||
export function createSessionId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
@@ -98,8 +98,11 @@ export async function deleteSubagentSessionDirAndArtifactsAsync(
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
if (file.isFile() && file.name.endsWith('.json')) {
|
||||
const agentId = path.basename(file.name, '.json');
|
||||
if (
|
||||
file.isFile() &&
|
||||
(file.name.endsWith('.json') || file.name.endsWith('.jsonl'))
|
||||
) {
|
||||
const agentId = path.basename(file.name, path.extname(file.name));
|
||||
await deleteSessionArtifactsAsync(agentId, tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('execStreaming (Integration)', () => {
|
||||
for await (const line of generator) {
|
||||
lines.push(line);
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return lines;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
parseCommandDetails,
|
||||
splitCommands,
|
||||
stripShellWrapper,
|
||||
normalizeCommand,
|
||||
hasRedirection,
|
||||
resolveExecutable,
|
||||
} from './shell-utils.js';
|
||||
@@ -115,6 +116,23 @@ const mockPowerShellResult = (
|
||||
});
|
||||
};
|
||||
|
||||
describe('normalizeCommand', () => {
|
||||
it('should lowercase the command', () => {
|
||||
expect(normalizeCommand('NPM')).toBe('npm');
|
||||
});
|
||||
|
||||
it('should remove .exe extension', () => {
|
||||
expect(normalizeCommand('node.exe')).toBe('node');
|
||||
});
|
||||
|
||||
it('should handle absolute paths', () => {
|
||||
expect(normalizeCommand('/usr/bin/npm')).toBe('npm');
|
||||
expect(normalizeCommand('C:\\Program Files\\nodejs\\node.exe')).toBe(
|
||||
'node',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommandRoots', () => {
|
||||
it('should return a single command', () => {
|
||||
expect(getCommandRoots('ls -l')).toEqual(['ls']);
|
||||
|
||||
@@ -179,6 +179,7 @@ export interface ParsedCommandDetail {
|
||||
name: string;
|
||||
text: string;
|
||||
startIndex: number;
|
||||
args?: string[];
|
||||
}
|
||||
|
||||
interface CommandParseResult {
|
||||
@@ -218,9 +219,16 @@ foreach ($commandAst in $commandAsts) {
|
||||
if ([string]::IsNullOrWhiteSpace($name)) {
|
||||
continue
|
||||
}
|
||||
$args = @()
|
||||
if ($commandAst.CommandElements.Count -gt 1) {
|
||||
for ($i = 1; $i -lt $commandAst.CommandElements.Count; $i++) {
|
||||
$args += $commandAst.CommandElements[$i].Extent.Text.Trim()
|
||||
}
|
||||
}
|
||||
$commandObjects += [PSCustomObject]@{
|
||||
name = $name
|
||||
text = $commandAst.Extent.Text.Trim()
|
||||
args = $args
|
||||
}
|
||||
}
|
||||
[PSCustomObject]@{
|
||||
@@ -302,6 +310,20 @@ function normalizeCommandName(raw: string): string {
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a command name for sandbox policy lookups.
|
||||
* Converts to lowercase and removes the .exe extension for cross-platform consistency.
|
||||
*
|
||||
* @param commandName - The command name to normalize.
|
||||
* @returns The normalized command name.
|
||||
*/
|
||||
export function normalizeCommand(commandName: string): string {
|
||||
// Split by both separators and get the last non-empty part
|
||||
const parts = commandName.split(/[\\/]/).filter(Boolean);
|
||||
const base = parts.length > 0 ? parts[parts.length - 1] : '';
|
||||
return base.toLowerCase().replace(/\.exe$/, '');
|
||||
}
|
||||
|
||||
function extractNameFromNode(node: Node): string | null {
|
||||
switch (node.type) {
|
||||
case 'command': {
|
||||
@@ -355,11 +377,31 @@ function collectCommandDetails(
|
||||
|
||||
const name = extractNameFromNode(current);
|
||||
if (name) {
|
||||
details.push({
|
||||
const detail: ParsedCommandDetail = {
|
||||
name,
|
||||
text: source.slice(current.startIndex, current.endIndex).trim(),
|
||||
startIndex: current.startIndex,
|
||||
});
|
||||
};
|
||||
|
||||
if (current.type === 'command') {
|
||||
const args: string[] = [];
|
||||
const nameNode = current.childForFieldName('name');
|
||||
for (let i = 0; i < current.childCount; i += 1) {
|
||||
const child = current.child(i);
|
||||
if (
|
||||
child &&
|
||||
child.type === 'word' &&
|
||||
child.startIndex !== nameNode?.startIndex
|
||||
) {
|
||||
args.push(child.text);
|
||||
}
|
||||
}
|
||||
if (args.length > 0) {
|
||||
detail.args = args;
|
||||
}
|
||||
}
|
||||
|
||||
details.push(detail);
|
||||
}
|
||||
|
||||
// Traverse all children to find all sub-components (commands, redirections, etc.)
|
||||
@@ -455,7 +497,7 @@ export function parseBashCommandDetails(
|
||||
'Syntax Errors:',
|
||||
syntaxErrors,
|
||||
);
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Ignore query errors
|
||||
} finally {
|
||||
query?.delete();
|
||||
@@ -509,7 +551,7 @@ function parsePowerShellCommandDetails(
|
||||
|
||||
let parsed: {
|
||||
success?: boolean;
|
||||
commands?: Array<{ name?: string; text?: string }>;
|
||||
commands?: Array<{ name?: string; text?: string; args?: string[] }>;
|
||||
hasRedirection?: boolean;
|
||||
} | null = null;
|
||||
try {
|
||||
@@ -524,7 +566,7 @@ function parsePowerShellCommandDetails(
|
||||
}
|
||||
|
||||
const details = (parsed.commands ?? [])
|
||||
.map((commandDetail) => {
|
||||
.map((commandDetail): ParsedCommandDetail | null => {
|
||||
if (!commandDetail || typeof commandDetail.name !== 'string') {
|
||||
return null;
|
||||
}
|
||||
@@ -539,6 +581,9 @@ function parsePowerShellCommandDetails(
|
||||
name,
|
||||
text,
|
||||
startIndex: 0,
|
||||
args: Array.isArray(commandDetail.args)
|
||||
? commandDetail.args
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
.filter((detail): detail is ParsedCommandDetail => detail !== null);
|
||||
@@ -802,34 +847,40 @@ export const spawnAsync = async (
|
||||
|
||||
const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(finalCommand, finalArgs, {
|
||||
...options,
|
||||
env: finalEnv,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(finalCommand, finalArgs, {
|
||||
...options,
|
||||
env: finalEnv,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
reject(new Error(`Command failed with exit code ${code}:\n${stderr}`));
|
||||
}
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
reject(
|
||||
new Error(`Command failed with exit code ${code}:\n${stderr}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
reject(err);
|
||||
child.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
prepared.cleanup?.();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -857,109 +908,115 @@ export async function* execStreaming(
|
||||
env: options?.env ?? process.env,
|
||||
});
|
||||
|
||||
const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared;
|
||||
|
||||
const child = spawn(finalCommand, finalArgs, {
|
||||
...options,
|
||||
env: finalEnv,
|
||||
// ensure we don't open a window on windows if possible/relevant
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: child.stdout,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
const errorChunks: Buffer[] = [];
|
||||
let stderrTotalBytes = 0;
|
||||
const MAX_STDERR_BYTES = 20 * 1024; // 20KB limit
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
if (stderrTotalBytes < MAX_STDERR_BYTES) {
|
||||
errorChunks.push(chunk);
|
||||
stderrTotalBytes += chunk.length;
|
||||
}
|
||||
});
|
||||
|
||||
let error: Error | null = null;
|
||||
child.on('error', (err) => {
|
||||
error = err;
|
||||
});
|
||||
|
||||
const onAbort = () => {
|
||||
// If manually aborted by signal, we kill immediately.
|
||||
if (!child.killed) child.kill();
|
||||
};
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
options?.signal?.addEventListener('abort', onAbort);
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
if (options?.signal?.aborted) break;
|
||||
yield line;
|
||||
}
|
||||
finished = true;
|
||||
} finally {
|
||||
rl.close();
|
||||
options?.signal?.removeEventListener('abort', onAbort);
|
||||
const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared;
|
||||
|
||||
// Ensure process is killed when the generator is closed (consumer breaks loop)
|
||||
let killedByGenerator = false;
|
||||
if (!finished && child.exitCode === null && !child.killed) {
|
||||
try {
|
||||
child.kill();
|
||||
} catch (_e) {
|
||||
// ignore error if process is already dead
|
||||
const child = spawn(finalCommand, finalArgs, {
|
||||
...options,
|
||||
env: finalEnv,
|
||||
// ensure we don't open a window on windows if possible/relevant
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: child.stdout,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
const errorChunks: Buffer[] = [];
|
||||
let stderrTotalBytes = 0;
|
||||
const MAX_STDERR_BYTES = 20 * 1024; // 20KB limit
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
if (stderrTotalBytes < MAX_STDERR_BYTES) {
|
||||
errorChunks.push(chunk);
|
||||
stderrTotalBytes += chunk.length;
|
||||
}
|
||||
killedByGenerator = true;
|
||||
});
|
||||
|
||||
let error: Error | null = null;
|
||||
child.on('error', (err) => {
|
||||
error = err;
|
||||
});
|
||||
|
||||
const onAbort = () => {
|
||||
// If manually aborted by signal, we kill immediately.
|
||||
if (!child.killed) child.kill();
|
||||
};
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
options?.signal?.addEventListener('abort', onAbort);
|
||||
}
|
||||
|
||||
// Ensure we wait for the process to exit to check codes
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// If an error occurred before we got here (e.g. spawn failure), reject immediately.
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
let finished = false;
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
if (options?.signal?.aborted) break;
|
||||
yield line;
|
||||
}
|
||||
finished = true;
|
||||
} finally {
|
||||
rl.close();
|
||||
options?.signal?.removeEventListener('abort', onAbort);
|
||||
|
||||
// Ensure process is killed when the generator is closed (consumer breaks loop)
|
||||
let killedByGenerator = false;
|
||||
if (!finished && child.exitCode === null && !child.killed) {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore error if process is already dead
|
||||
}
|
||||
killedByGenerator = true;
|
||||
}
|
||||
|
||||
function checkExit(code: number | null) {
|
||||
// If we aborted or killed it manually, we treat it as success (stop waiting)
|
||||
if (options?.signal?.aborted || killedByGenerator) {
|
||||
resolve();
|
||||
// Ensure we wait for the process to exit to check codes
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// If an error occurred before we got here (e.g. spawn failure), reject immediately.
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = options?.allowedExitCodes ?? [0];
|
||||
if (code !== null && allowed.includes(code)) {
|
||||
resolve();
|
||||
} else {
|
||||
// If we have an accumulated error or explicit error event
|
||||
if (error) reject(error);
|
||||
else {
|
||||
const stderr = Buffer.concat(errorChunks).toString('utf8');
|
||||
const truncatedMsg =
|
||||
stderrTotalBytes >= MAX_STDERR_BYTES ? '...[truncated]' : '';
|
||||
reject(
|
||||
new Error(
|
||||
`Process exited with code ${code}: ${stderr}${truncatedMsg}`,
|
||||
),
|
||||
);
|
||||
function checkExit(code: number | null) {
|
||||
// If we aborted or killed it manually, we treat it as success (stop waiting)
|
||||
if (options?.signal?.aborted || killedByGenerator) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = options?.allowedExitCodes ?? [0];
|
||||
if (code !== null && allowed.includes(code)) {
|
||||
resolve();
|
||||
} else {
|
||||
// If we have an accumulated error or explicit error event
|
||||
if (error) reject(error);
|
||||
else {
|
||||
const stderr = Buffer.concat(errorChunks).toString('utf8');
|
||||
const truncatedMsg =
|
||||
stderrTotalBytes >= MAX_STDERR_BYTES ? '...[truncated]' : '';
|
||||
reject(
|
||||
new Error(
|
||||
`Process exited with code ${code}: ${stderr}${truncatedMsg}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (child.exitCode !== null) {
|
||||
checkExit(child.exitCode);
|
||||
} else {
|
||||
child.on('close', (code) => checkExit(code));
|
||||
child.on('error', (err) => reject(err));
|
||||
}
|
||||
});
|
||||
if (child.exitCode !== null) {
|
||||
checkExit(child.exitCode);
|
||||
} else {
|
||||
child.on('close', (code) => checkExit(code));
|
||||
child.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
prepared.cleanup?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function getSystemEncoding(): string | null {
|
||||
locale = execSync('locale charmap', { encoding: 'utf8' })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
debugLogger.warn('Failed to get locale charmap.');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -30,11 +30,12 @@ describe('terminalSerializer', () => {
|
||||
allowProposedApi: true,
|
||||
});
|
||||
const result = serializeTerminalToObject(terminal);
|
||||
expect(result).toHaveLength(24);
|
||||
expect(result).toHaveLength(1);
|
||||
result.forEach((line) => {
|
||||
// Expect each line to be either empty or contain a single token with spaces
|
||||
// Actually, the first cell will have inverse: true (cursor), so it will have multiple tokens
|
||||
if (line.length > 0) {
|
||||
expect(line[0].text.trim()).toBe('');
|
||||
expect(line[line.length - 1].text.trim()).toBe('');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface AnsiToken {
|
||||
underline: boolean;
|
||||
dim: boolean;
|
||||
inverse: boolean;
|
||||
isUninitialized: boolean;
|
||||
fg: string;
|
||||
bg: string;
|
||||
}
|
||||
@@ -126,6 +127,12 @@ class Cell {
|
||||
return this.cell?.getChars() || ' ';
|
||||
}
|
||||
|
||||
isUninitialized(): boolean {
|
||||
return this.cell
|
||||
? this.cell.getCode() === 0 && this.cell.isAttributeDefault()
|
||||
: true;
|
||||
}
|
||||
|
||||
isAttribute(attribute: Attribute): boolean {
|
||||
return (this.attributes & attribute) !== 0;
|
||||
}
|
||||
@@ -137,7 +144,8 @@ class Cell {
|
||||
this.bg === other.bg &&
|
||||
this.fgColorMode === other.fgColorMode &&
|
||||
this.bgColorMode === other.bgColorMode &&
|
||||
this.isCursor() === other.isCursor()
|
||||
this.isCursor() === other.isCursor() &&
|
||||
this.isUninitialized() === other.isUninitialized()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -149,19 +157,21 @@ export function serializeTerminalToObject(
|
||||
): AnsiOutput {
|
||||
const buffer = terminal.buffer.active;
|
||||
const cursorX = buffer.cursorX;
|
||||
const cursorY = buffer.cursorY;
|
||||
const absoluteCursorY = buffer.baseY + buffer.cursorY;
|
||||
const defaultFg = '';
|
||||
const defaultBg = '';
|
||||
|
||||
const result: AnsiOutput = [];
|
||||
|
||||
// Reuse cell instances
|
||||
const lastCell = new Cell(null, -1, -1, cursorX, cursorY);
|
||||
const currentCell = new Cell(null, -1, -1, cursorX, cursorY);
|
||||
const lastCell = new Cell(null, -1, -1, cursorX, absoluteCursorY);
|
||||
const currentCell = new Cell(null, -1, -1, cursorX, absoluteCursorY);
|
||||
|
||||
const effectiveStart = startLine ?? buffer.viewportY;
|
||||
const effectiveEnd = endLine ?? buffer.viewportY + terminal.rows;
|
||||
|
||||
const cellBuffer = terminal.buffer.active.getNullCell();
|
||||
|
||||
for (let y = effectiveStart; y < effectiveEnd; y++) {
|
||||
const line = buffer.getLine(y);
|
||||
const currentLine: AnsiLine = [];
|
||||
@@ -171,12 +181,12 @@ export function serializeTerminalToObject(
|
||||
}
|
||||
|
||||
// Reset lastCell for new line
|
||||
lastCell.update(null, -1, -1, cursorX, cursorY);
|
||||
lastCell.update(null, -1, -1, cursorX, absoluteCursorY);
|
||||
let currentText = '';
|
||||
|
||||
for (let x = 0; x < terminal.cols; x++) {
|
||||
const cellData = line.getCell(x);
|
||||
currentCell.update(cellData || null, x, y, cursorX, cursorY);
|
||||
const cellData = line.getCell(x, cellBuffer);
|
||||
currentCell.update(cellData || null, x, y, cursorX, absoluteCursorY);
|
||||
|
||||
if (x > 0 && !currentCell.equals(lastCell)) {
|
||||
if (currentText) {
|
||||
@@ -188,6 +198,7 @@ export function serializeTerminalToObject(
|
||||
dim: lastCell.isAttribute(Attribute.dim),
|
||||
inverse:
|
||||
lastCell.isAttribute(Attribute.inverse) || lastCell.isCursor(),
|
||||
isUninitialized: lastCell.isUninitialized(),
|
||||
fg: convertColorToHex(lastCell.fg, lastCell.fgColorMode, defaultFg),
|
||||
bg: convertColorToHex(lastCell.bg, lastCell.bgColorMode, defaultBg),
|
||||
};
|
||||
@@ -198,7 +209,7 @@ export function serializeTerminalToObject(
|
||||
currentText += currentCell.getChars();
|
||||
// Copy state from currentCell to lastCell. Since we can't easily deep copy
|
||||
// without allocating, we just update lastCell with the same data.
|
||||
lastCell.update(cellData || null, x, y, cursorX, cursorY);
|
||||
lastCell.update(cellData || null, x, y, cursorX, absoluteCursorY);
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
@@ -209,6 +220,7 @@ export function serializeTerminalToObject(
|
||||
underline: lastCell.isAttribute(Attribute.underline),
|
||||
dim: lastCell.isAttribute(Attribute.dim),
|
||||
inverse: lastCell.isAttribute(Attribute.inverse) || lastCell.isCursor(),
|
||||
isUninitialized: lastCell.isUninitialized(),
|
||||
fg: convertColorToHex(lastCell.fg, lastCell.fgColorMode, defaultFg),
|
||||
bg: convertColorToHex(lastCell.bg, lastCell.bgColorMode, defaultBg),
|
||||
};
|
||||
@@ -218,6 +230,23 @@ export function serializeTerminalToObject(
|
||||
result.push(currentLine);
|
||||
}
|
||||
|
||||
// Remove trailing empty lines
|
||||
while (result.length > 0) {
|
||||
const lastLine = result[result.length - 1];
|
||||
const lineY = effectiveStart + result.length - 1;
|
||||
|
||||
// A line is empty if all its tokens are marked as uninitialized and it has no cursor
|
||||
const isEmpty =
|
||||
lastLine.every((token) => token.isUninitialized && !token.inverse) &&
|
||||
lineY !== absoluteCursorY;
|
||||
|
||||
if (isEmpty) {
|
||||
result.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,113 +5,10 @@
|
||||
*/
|
||||
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import {
|
||||
doesToolInvocationMatch,
|
||||
getToolSuggestion,
|
||||
shouldHideToolCall,
|
||||
} from './tool-utils.js';
|
||||
import {
|
||||
ReadFileTool,
|
||||
ApprovalMode,
|
||||
CoreToolCallStatus,
|
||||
ASK_USER_DISPLAY_NAME,
|
||||
WRITE_FILE_DISPLAY_NAME,
|
||||
EDIT_DISPLAY_NAME,
|
||||
READ_FILE_DISPLAY_NAME,
|
||||
type AnyToolInvocation,
|
||||
type Config,
|
||||
} from '../index.js';
|
||||
import { doesToolInvocationMatch, getToolSuggestion } from './tool-utils.js';
|
||||
import { ReadFileTool, type AnyToolInvocation, type Config } from '../index.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
|
||||
describe('shouldHideToolCall', () => {
|
||||
it.each([
|
||||
{
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
hasResult: true,
|
||||
shouldHide: true,
|
||||
},
|
||||
{
|
||||
status: CoreToolCallStatus.Executing,
|
||||
hasResult: true,
|
||||
shouldHide: true,
|
||||
},
|
||||
{
|
||||
status: CoreToolCallStatus.AwaitingApproval,
|
||||
hasResult: true,
|
||||
shouldHide: true,
|
||||
},
|
||||
{
|
||||
status: CoreToolCallStatus.Validating,
|
||||
hasResult: true,
|
||||
shouldHide: true,
|
||||
},
|
||||
{
|
||||
status: CoreToolCallStatus.Success,
|
||||
hasResult: true,
|
||||
shouldHide: false,
|
||||
},
|
||||
{
|
||||
status: CoreToolCallStatus.Error,
|
||||
hasResult: false,
|
||||
shouldHide: true,
|
||||
},
|
||||
{
|
||||
status: CoreToolCallStatus.Error,
|
||||
hasResult: true,
|
||||
shouldHide: false,
|
||||
},
|
||||
])(
|
||||
'AskUser: status=$status, hasResult=$hasResult -> hide=$shouldHide',
|
||||
({ status, hasResult, shouldHide }) => {
|
||||
expect(
|
||||
shouldHideToolCall({
|
||||
displayName: ASK_USER_DISPLAY_NAME,
|
||||
status,
|
||||
hasResultDisplay: hasResult,
|
||||
}),
|
||||
).toBe(shouldHide);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: WRITE_FILE_DISPLAY_NAME,
|
||||
mode: ApprovalMode.PLAN,
|
||||
visible: false,
|
||||
},
|
||||
{ name: EDIT_DISPLAY_NAME, mode: ApprovalMode.PLAN, visible: false },
|
||||
{
|
||||
name: WRITE_FILE_DISPLAY_NAME,
|
||||
mode: ApprovalMode.DEFAULT,
|
||||
visible: true,
|
||||
},
|
||||
{ name: READ_FILE_DISPLAY_NAME, mode: ApprovalMode.PLAN, visible: true },
|
||||
])(
|
||||
'Plan Mode: tool=$name, mode=$mode -> visible=$visible',
|
||||
({ name, mode, visible }) => {
|
||||
expect(
|
||||
shouldHideToolCall({
|
||||
displayName: name,
|
||||
status: CoreToolCallStatus.Success,
|
||||
approvalMode: mode,
|
||||
hasResultDisplay: true,
|
||||
}),
|
||||
).toBe(!visible);
|
||||
},
|
||||
);
|
||||
|
||||
it('hides tool calls with a parentCallId', () => {
|
||||
expect(
|
||||
shouldHideToolCall({
|
||||
displayName: 'any_tool',
|
||||
status: CoreToolCallStatus.Success,
|
||||
hasResultDisplay: true,
|
||||
parentCallId: 'some-parent',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolSuggestion', () => {
|
||||
it('should suggest the top N closest tool names for a typo', () => {
|
||||
const allToolNames = ['list_files', 'read_file', 'write_file'];
|
||||
|
||||
@@ -11,16 +11,7 @@ import {
|
||||
} from '../index.js';
|
||||
import { SHELL_TOOL_NAMES } from './shell-utils.js';
|
||||
import levenshtein from 'fast-levenshtein';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
type ToolCallResponseInfo,
|
||||
} from '../scheduler/types.js';
|
||||
import {
|
||||
ASK_USER_DISPLAY_NAME,
|
||||
WRITE_FILE_DISPLAY_NAME,
|
||||
EDIT_DISPLAY_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
import type { ToolCallResponseInfo } from '../scheduler/types.js';
|
||||
|
||||
/**
|
||||
* Validates if an object is a ToolCallResponseInfo.
|
||||
@@ -36,62 +27,6 @@ export function isToolCallResponseInfo(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for determining if a tool call should be hidden in the CLI history.
|
||||
*/
|
||||
export interface ShouldHideToolCallParams {
|
||||
/** The display name of the tool. */
|
||||
displayName: string;
|
||||
/** The current status of the tool call. */
|
||||
status: CoreToolCallStatus;
|
||||
/** The approval mode active when the tool was called. */
|
||||
approvalMode?: ApprovalMode;
|
||||
/** Whether the tool has produced a result for display. */
|
||||
hasResultDisplay: boolean;
|
||||
/** The ID of the parent tool call, if any. */
|
||||
parentCallId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a tool call should be hidden from the standard tool history UI.
|
||||
*
|
||||
* We hide tools in several cases:
|
||||
* 1. Tool calls that have a parent, as they are "internal" to another tool (e.g. subagent).
|
||||
* 2. Ask User tools that are in progress, displayed via specialized UI.
|
||||
* 3. Ask User tools that errored without result display, typically param
|
||||
* validation errors that the agent automatically recovers from.
|
||||
* 4. WriteFile and Edit tools when in Plan Mode, redundant because the
|
||||
* resulting plans are displayed separately upon exiting plan mode.
|
||||
*/
|
||||
export function shouldHideToolCall(params: ShouldHideToolCallParams): boolean {
|
||||
const { displayName, status, approvalMode, hasResultDisplay, parentCallId } =
|
||||
params;
|
||||
|
||||
if (parentCallId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (displayName) {
|
||||
case ASK_USER_DISPLAY_NAME:
|
||||
switch (status) {
|
||||
case CoreToolCallStatus.Scheduled:
|
||||
case CoreToolCallStatus.Validating:
|
||||
case CoreToolCallStatus.Executing:
|
||||
case CoreToolCallStatus.AwaitingApproval:
|
||||
return true;
|
||||
case CoreToolCallStatus.Error:
|
||||
return !hasResultDisplay;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
case WRITE_FILE_DISPLAY_NAME:
|
||||
case EDIT_DISPLAY_NAME:
|
||||
return approvalMode === ApprovalMode.PLAN;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a suggestion string for a tool name that was not found in the registry.
|
||||
* It finds the closest matches based on Levenshtein distance.
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import {
|
||||
isRenderedInHistory,
|
||||
belongsInConfirmationQueue,
|
||||
isVisibleInToolGroup,
|
||||
} from './tool-visibility.js';
|
||||
import { CoreToolCallStatus } from '../scheduler/types.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import {
|
||||
ASK_USER_DISPLAY_NAME,
|
||||
WRITE_FILE_DISPLAY_NAME,
|
||||
EDIT_DISPLAY_NAME,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
READ_FILE_DISPLAY_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
|
||||
describe('ToolVisibility Rules', () => {
|
||||
const createCtx = (overrides = {}) => ({
|
||||
name: 'some_tool',
|
||||
displayName: 'Some Tool',
|
||||
status: CoreToolCallStatus.Success,
|
||||
hasResult: true,
|
||||
parentCallId: undefined,
|
||||
isClientInitiated: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('isRenderedInHistory', () => {
|
||||
it('hides tools with parents', () => {
|
||||
expect(
|
||||
isRenderedInHistory(createCtx({ parentCallId: 'parent-123' })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hides AskUser errors without results', () => {
|
||||
expect(
|
||||
isRenderedInHistory(
|
||||
createCtx({
|
||||
displayName: ASK_USER_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Error,
|
||||
hasResult: false,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('shows AskUser success', () => {
|
||||
expect(
|
||||
isRenderedInHistory(
|
||||
createCtx({
|
||||
displayName: ASK_USER_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Success,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides WriteFile/Edit in Plan Mode', () => {
|
||||
expect(
|
||||
isRenderedInHistory(
|
||||
createCtx({
|
||||
displayName: WRITE_FILE_DISPLAY_NAME,
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isRenderedInHistory(
|
||||
createCtx({
|
||||
displayName: EDIT_DISPLAY_NAME,
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('shows ReadFile in Plan Mode', () => {
|
||||
expect(
|
||||
isRenderedInHistory(
|
||||
createCtx({
|
||||
displayName: READ_FILE_DISPLAY_NAME,
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('belongsInConfirmationQueue', () => {
|
||||
it('returns false for update_topic', () => {
|
||||
expect(
|
||||
belongsInConfirmationQueue(createCtx({ name: UPDATE_TOPIC_TOOL_NAME })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for standard tools', () => {
|
||||
expect(
|
||||
belongsInConfirmationQueue(createCtx({ name: 'write_file' })),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVisibleInToolGroup', () => {
|
||||
it('shows tools with parents (agent tools)', () => {
|
||||
expect(
|
||||
isVisibleInToolGroup(createCtx({ parentCallId: 'parent-123' }), 'full'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides WriteFile/Edit in Plan Mode', () => {
|
||||
expect(
|
||||
isVisibleInToolGroup(
|
||||
createCtx({
|
||||
displayName: WRITE_FILE_DISPLAY_NAME,
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
}),
|
||||
'full',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hides non-client-initiated errors on low verbosity', () => {
|
||||
expect(
|
||||
isVisibleInToolGroup(
|
||||
createCtx({
|
||||
status: CoreToolCallStatus.Error,
|
||||
isClientInitiated: false,
|
||||
}),
|
||||
'low',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('shows non-client-initiated errors on full verbosity', () => {
|
||||
expect(
|
||||
isVisibleInToolGroup(
|
||||
createCtx({
|
||||
status: CoreToolCallStatus.Error,
|
||||
isClientInitiated: false,
|
||||
}),
|
||||
'full',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides confirming tools', () => {
|
||||
expect(
|
||||
isVisibleInToolGroup(
|
||||
createCtx({ status: CoreToolCallStatus.AwaitingApproval }),
|
||||
'full',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hides AskUser while in progress', () => {
|
||||
expect(
|
||||
isVisibleInToolGroup(
|
||||
createCtx({
|
||||
displayName: ASK_USER_DISPLAY_NAME,
|
||||
status: CoreToolCallStatus.Executing,
|
||||
}),
|
||||
'full',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { CoreToolCallStatus, type ToolCall } from '../scheduler/types.js';
|
||||
import {
|
||||
ASK_USER_DISPLAY_NAME,
|
||||
WRITE_FILE_DISPLAY_NAME,
|
||||
EDIT_DISPLAY_NAME,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
|
||||
export interface ToolVisibilityContext {
|
||||
/** The internal name of the tool. */
|
||||
name: string;
|
||||
/** The display name of the tool. */
|
||||
displayName?: string;
|
||||
/** The current status of the tool call. */
|
||||
status: CoreToolCallStatus;
|
||||
/** The approval mode active when the tool was called. */
|
||||
approvalMode?: ApprovalMode;
|
||||
/** Whether the tool has produced a result for display (e.g., resultDisplay or liveOutput). */
|
||||
hasResult: boolean;
|
||||
/** The ID of the parent tool call, if any. */
|
||||
parentCallId?: string;
|
||||
/** True if the tool was initiated directly by the user via a slash command. */
|
||||
isClientInitiated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a core ToolCall to a ToolVisibilityContext.
|
||||
*/
|
||||
export function buildToolVisibilityContext(
|
||||
tc: ToolCall,
|
||||
): ToolVisibilityContext {
|
||||
let hasResult = false;
|
||||
if (
|
||||
tc.status === CoreToolCallStatus.Success ||
|
||||
tc.status === CoreToolCallStatus.Error ||
|
||||
tc.status === CoreToolCallStatus.Cancelled
|
||||
) {
|
||||
hasResult = !!tc.response.resultDisplay;
|
||||
} else if (tc.status === CoreToolCallStatus.Executing) {
|
||||
hasResult = !!tc.liveOutput;
|
||||
}
|
||||
|
||||
return {
|
||||
name: tc.request.name,
|
||||
displayName: tc.tool?.displayName ?? tc.request.name,
|
||||
status: tc.status,
|
||||
approvalMode: tc.approvalMode,
|
||||
hasResult,
|
||||
parentCallId: tc.request.parentCallId,
|
||||
isClientInitiated: tc.request.isClientInitiated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a tool should ever appear as a completed block in the main chat log.
|
||||
*/
|
||||
export function isRenderedInHistory(ctx: ToolVisibilityContext): boolean {
|
||||
if (ctx.parentCallId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const displayName = ctx.displayName ?? ctx.name;
|
||||
|
||||
switch (displayName) {
|
||||
case ASK_USER_DISPLAY_NAME:
|
||||
// We only render AskUser in history if it errored with a result or succeeded.
|
||||
// If it errored without a result, it was an internal validation failure.
|
||||
if (ctx.status === CoreToolCallStatus.Error) {
|
||||
return ctx.hasResult;
|
||||
}
|
||||
return ctx.status === CoreToolCallStatus.Success;
|
||||
|
||||
case WRITE_FILE_DISPLAY_NAME:
|
||||
case EDIT_DISPLAY_NAME:
|
||||
// In Plan Mode, edits are redundant because the plan shows the diffs.
|
||||
return ctx.approvalMode !== ApprovalMode.PLAN;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a tool belongs in the Awaiting Approval confirmation queue.
|
||||
*/
|
||||
export function belongsInConfirmationQueue(
|
||||
ctx: ToolVisibilityContext,
|
||||
): boolean {
|
||||
const displayName = ctx.displayName ?? ctx.name;
|
||||
|
||||
// Narrative background tools auto-execute and never require confirmation
|
||||
if (
|
||||
ctx.name === UPDATE_TOPIC_TOOL_NAME ||
|
||||
displayName === UPDATE_TOPIC_DISPLAY_NAME
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All other standard tools could theoretically require confirmation
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a tool should be actively rendered in the dynamic ToolGroupMessage UI right now.
|
||||
* This takes into account current execution states and UI settings.
|
||||
*/
|
||||
export function isVisibleInToolGroup(
|
||||
ctx: ToolVisibilityContext,
|
||||
errorVerbosity: 'full' | 'low',
|
||||
): boolean {
|
||||
const displayName = ctx.displayName ?? ctx.name;
|
||||
|
||||
// Hide internal errors unless the user explicitly requested full verbosity
|
||||
if (
|
||||
errorVerbosity === 'low' &&
|
||||
ctx.status === CoreToolCallStatus.Error &&
|
||||
!ctx.isClientInitiated
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We hide AskUser while it's in progress because it renders in its own modal.
|
||||
// We also hide terminal states that don't meet history rendering criteria (e.g. errors without results).
|
||||
if (displayName === ASK_USER_DISPLAY_NAME) {
|
||||
switch (ctx.status) {
|
||||
case CoreToolCallStatus.Scheduled:
|
||||
case CoreToolCallStatus.Validating:
|
||||
case CoreToolCallStatus.Executing:
|
||||
case CoreToolCallStatus.AwaitingApproval:
|
||||
return false;
|
||||
case CoreToolCallStatus.Error:
|
||||
return ctx.hasResult;
|
||||
case CoreToolCallStatus.Success:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// In Plan Mode, edits are redundant because the plan shows the diffs.
|
||||
if (
|
||||
(displayName === WRITE_FILE_DISPLAY_NAME ||
|
||||
displayName === EDIT_DISPLAY_NAME) &&
|
||||
ctx.approvalMode === ApprovalMode.PLAN
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We hide confirming tools from the active group because they render in the
|
||||
// ToolConfirmationQueue at the bottom of the screen instead.
|
||||
if (ctx.status === CoreToolCallStatus.AwaitingApproval) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -188,7 +188,7 @@ export class WorkspaceContext {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export class WorkspaceContext {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user