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:
Hadi Minooei
2026-04-16 22:05:57 -07:00
914 changed files with 892464 additions and 16022 deletions
+3 -6
View File
@@ -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)})`,
);
}
}
+11 -8
View File
@@ -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
}
}
+26 -13
View File
@@ -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', '');
+7 -10
View File
@@ -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',
+4
View File
@@ -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',
});
+2 -2
View File
@@ -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}]`;
+16
View File
@@ -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.
*/
+37 -6
View File
@@ -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();
});
});
});
+27 -7
View File
@@ -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,
}),
);
}
+101
View File
@@ -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">
+106 -44
View File
@@ -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 UTF16/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;
+2 -2
View File
@@ -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;
}
}
+2 -2
View File
@@ -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;
}
}
+2 -2
View File
@@ -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;
}
}
+5 -5
View File
@@ -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,
+31 -48
View File
@@ -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);
}
+1 -1
View File
@@ -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.`,
);
+3 -10
View File
@@ -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', () => {
+20 -5
View File
@@ -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,
};
}
/**
+137 -2
View File
@@ -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'));
});
});
});
+84 -6
View File
@@ -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;
}
+12 -2
View File
@@ -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(() => {
+66 -31
View File
@@ -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
}
}
}
+86
View File
@@ -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');
+29 -6
View File
@@ -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}`);
}
-2
View File
@@ -6,8 +6,6 @@
import { randomUUID } from 'node:crypto';
export const sessionId = randomUUID();
export function createSessionId(): string {
return randomUUID();
}
+5 -2
View File
@@ -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']);
+176 -119
View File
@@ -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?.();
}
}
+1 -1
View File
@@ -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('');
}
});
});
+37 -8
View File
@@ -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;
}
+2 -105
View File
@@ -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'];
+1 -66
View 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);
});
});
});
+164
View File
@@ -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;
}
+2 -2
View File
@@ -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;
}
}