Compare commits

...

10 Commits

Author SHA1 Message Date
Shreya Keshive 5be9172ad5
fix(ide): preserve focus when showing diff view (#6795) 2025-08-22 02:24:45 +00:00
Gal Zahavi 14ca687c05
test(integration-tests): isolate user memory from test runs (#6790) 2025-08-22 00:34:13 +00:00
Tommaso Sciortino 15c62bade3
Reuse CoreToolScheduler for nonInteractiveToolExecutor (#6714) 2025-08-21 23:49:12 +00:00
Jacob Richman 29699274bb
feat(settings) support editing string settings. (#6732) 2025-08-21 23:43:56 +00:00
christine betts 10286934e6
Introduce initial screen reader mode handling and flag (#6653) 2025-08-21 22:29:15 +00:00
Ricardo Fabbri 679acc45b2
fix(docs): path of chat checkpoints in manual (#6303)
Co-authored-by: Arya Gummadi <aryagummadi@google.com>
2025-08-21 22:27:23 +00:00
Billy Biggs 2dd15572ea
Support IDE connections via stdio MCP (#6417) 2025-08-21 22:00:05 +00:00
joshualitt ec41b8db8e
feat(core): Annotate remaining error paths in tools with type. (#6699) 2025-08-21 21:40:18 +00:00
Gaurav 299bf58309
fix: handle extra text in gemini output for dedup workflow (#6771) 2025-08-21 20:40:44 +00:00
Victor May 720eb81890
At Command Race Condition Bugfix For Non-Interactive Mode (#6676) 2025-08-21 18:47:40 +00:00
51 changed files with 1369 additions and 608 deletions

View File

@ -185,8 +185,13 @@ jobs:
core.info(`Raw duplicates JSON: ${rawJson}`); core.info(`Raw duplicates JSON: ${rawJson}`);
let parsedJson; let parsedJson;
try { try {
const trimmedJson = rawJson.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '').trim(); const jsonStringMatch = rawJson.match(/{[\s\S]*}/);
parsedJson = JSON.parse(trimmedJson); if (!jsonStringMatch) {
core.setFailed(`Could not find JSON object in the output.\nRaw output: ${rawJson}`);
return;
}
const jsonString = jsonStringMatch[0];
parsedJson = JSON.parse(jsonString);
core.info(`Parsed duplicates JSON: ${JSON.stringify(parsedJson)}`); core.info(`Parsed duplicates JSON: ${JSON.stringify(parsedJson)}`);
} catch (err) { } catch (err) {
core.setFailed(`Failed to parse duplicates JSON from Gemini output: ${err.message}\nRaw output: ${rawJson}`); core.setFailed(`Failed to parse duplicates JSON from Gemini output: ${err.message}\nRaw output: ${rawJson}`);

View File

@ -18,8 +18,8 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Saves the current conversation history. You must add a `<tag>` for identifying the conversation state. - **Description:** Saves the current conversation history. You must add a `<tag>` for identifying the conversation state.
- **Usage:** `/chat save <tag>` - **Usage:** `/chat save <tag>`
- **Details on Checkpoint Location:** The default locations for saved chat checkpoints are: - **Details on Checkpoint Location:** The default locations for saved chat checkpoints are:
- Linux/macOS: `~/.config/google-generative-ai/checkpoints/` - Linux/macOS: `~/.gemini/tmp/<project_hash>/`
- Windows: `C:\Users\<YourUsername>\AppData\Roaming\google-generative-ai\checkpoints\` - Windows: `C:\Users\<YourUsername>\.gemini\tmp\<project_hash>\`
- When you run `/chat list`, the CLI only scans these specific directories to find available checkpoints. - When you run `/chat list`, the CLI only scans these specific directories to find available checkpoints.
- **Note:** These checkpoints are for manually saving and resuming conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../checkpointing.md). - **Note:** These checkpoints are for manually saving and resuming conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../checkpointing.md).
- **`resume`** - **`resume`**

View File

@ -308,6 +308,20 @@ In addition to a project settings file, a project's `.gemini` directory can cont
"showLineNumbers": false "showLineNumbers": false
``` ```
- **`accessibility`** (object):
- **Description:** Configures accessibility features for the CLI.
- **Properties:**
- **`screenReader`** (boolean): Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. This can also be enabled with the `--screen-reader` command-line flag, which will take precedence over the setting.
- **`disableLoadingPhrases`** (boolean): Disables the display of loading phrases during operations.
- **Default:** `{"screenReader": false, "disableLoadingPhrases": false}`
- **Example:**
```json
"accessibility": {
"screenReader": true,
"disableLoadingPhrases": true
}
```
### Example `settings.json`: ### Example `settings.json`:
```json ```json
@ -475,6 +489,8 @@ Arguments passed directly when running the CLI can override other configurations
- Can be specified multiple times or as comma-separated values. - Can be specified multiple times or as comma-separated values.
- 5 directories can be added at maximum. - 5 directories can be added at maximum.
- Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` - Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2`
- **`--screen-reader`**:
- Enables screen reader mode for accessibility.
- **`--version`**: - **`--version`**:
- Displays the version of the CLI. - Displays the version of the CLI.

View File

@ -9,16 +9,38 @@ if (process.env.NO_COLOR !== undefined) {
delete process.env.NO_COLOR; delete process.env.NO_COLOR;
} }
import { mkdir, readdir, rm } from 'fs/promises'; import { mkdir, readdir, rm, readFile, writeFile, unlink } from 'fs/promises';
import { join, dirname } from 'path'; import { join, dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import * as os from 'os';
import {
GEMINI_CONFIG_DIR,
DEFAULT_CONTEXT_FILENAME,
} from '../packages/core/src/tools/memoryTool.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..'); const rootDir = join(__dirname, '..');
const integrationTestsDir = join(rootDir, '.integration-tests'); const integrationTestsDir = join(rootDir, '.integration-tests');
let runDir = ''; // Make runDir accessible in teardown let runDir = ''; // Make runDir accessible in teardown
const memoryFilePath = join(
os.homedir(),
GEMINI_CONFIG_DIR,
DEFAULT_CONTEXT_FILENAME,
);
let originalMemoryContent: string | null = null;
export async function setup() { export async function setup() {
try {
originalMemoryContent = await readFile(memoryFilePath, 'utf-8');
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
throw e;
}
// File doesn't exist, which is fine.
}
runDir = join(integrationTestsDir, `${Date.now()}`); runDir = join(integrationTestsDir, `${Date.now()}`);
await mkdir(runDir, { recursive: true }); await mkdir(runDir, { recursive: true });
@ -57,4 +79,15 @@ export async function teardown() {
if (process.env.KEEP_OUTPUT !== 'true' && runDir) { if (process.env.KEEP_OUTPUT !== 'true' && runDir) {
await rm(runDir, { recursive: true, force: true }); await rm(runDir, { recursive: true, force: true });
} }
if (originalMemoryContent !== null) {
await mkdir(dirname(memoryFilePath), { recursive: true });
await writeFile(memoryFilePath, originalMemoryContent, 'utf-8');
} else {
try {
await unlink(memoryFilePath);
} catch {
// File might not exist if the test failed before creating it.
}
}
} }

View File

@ -4,5 +4,6 @@
"noEmit": true, "noEmit": true,
"allowJs": true "allowJs": true
}, },
"include": ["**/*.ts"] "include": ["**/*.ts"],
"references": [{ "path": "../packages/core" }]
} }

88
package-lock.json generated
View File

@ -63,16 +63,16 @@
} }
}, },
"node_modules/@alcalzone/ansi-tokenize": { "node_modules/@alcalzone/ansi-tokenize": {
"version": "0.1.3", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.0.tgz",
"integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", "integrity": "sha512-qI/5TaaaCZE4yeSZ83lu0+xi1r88JSxUjnH4OP/iZF7+KKZ75u3ee5isd0LxX+6N8U0npL61YrpbthILHB6BnA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^6.2.1", "ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^4.0.0" "is-fullwidth-code-point": "^5.0.0"
}, },
"engines": { "engines": {
"node": ">=14.13.1" "node": ">=18"
} }
}, },
"node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": {
@ -88,12 +88,15 @@
} }
}, },
"node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": { "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": {
"version": "4.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz",
"integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==",
"license": "MIT", "license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.0.0"
},
"engines": { "engines": {
"node": ">=12" "node": ">=18"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
@ -5175,9 +5178,9 @@
} }
}, },
"node_modules/es-toolkit": { "node_modules/es-toolkit": {
"version": "1.39.5", "version": "1.39.10",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.5.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz",
"integrity": "sha512-z9V0qU4lx1TBXDNFWfAASWk6RNU6c6+TJBKE+FLIg8u0XJ6Yw58Hi0yX8ftEouj6p1QARRlXLFfHbIli93BdQQ==", "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"docs", "docs",
@ -6859,26 +6862,26 @@
} }
}, },
"node_modules/ink": { "node_modules/ink": {
"version": "6.1.1", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/ink/-/ink-6.1.1.tgz", "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.2.tgz",
"integrity": "sha512-Bqw78FX+1TSIGxs6bdvohgoy6mTfqjFJVNyYzXn8HIyZyVmwLX8XdnhUtUwyaelLCqLz8uuFseCbomRZWjyo5g==", "integrity": "sha512-LN1f+/D8KKqMqRux08fIfA9wsEAJ9Bu9CiI3L6ih7bnqNSDUXT/JVJ0rUIc4NkjPiPaeI3BVNREcLYLz9ePSEg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@alcalzone/ansi-tokenize": "^0.1.3", "@alcalzone/ansi-tokenize": "^0.2.0",
"ansi-escapes": "^7.0.0", "ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.1", "ansi-styles": "^6.2.1",
"auto-bind": "^5.0.1", "auto-bind": "^5.0.1",
"chalk": "^5.3.0", "chalk": "^5.6.0",
"cli-boxes": "^3.0.0", "cli-boxes": "^3.0.0",
"cli-cursor": "^4.0.0", "cli-cursor": "^4.0.0",
"cli-truncate": "^4.0.0", "cli-truncate": "^4.0.0",
"code-excerpt": "^4.0.0", "code-excerpt": "^4.0.0",
"es-toolkit": "^1.22.0", "es-toolkit": "^1.39.10",
"indent-string": "^5.0.0", "indent-string": "^5.0.0",
"is-in-ci": "^1.0.0", "is-in-ci": "^2.0.0",
"patch-console": "^2.0.0", "patch-console": "^2.0.0",
"react-reconciler": "^0.32.0", "react-reconciler": "^0.32.0",
"scheduler": "^0.23.0", "scheduler": "^0.26.0",
"signal-exit": "^3.0.7", "signal-exit": "^3.0.7",
"slice-ansi": "^7.1.0", "slice-ansi": "^7.1.0",
"stack-utils": "^2.0.6", "stack-utils": "^2.0.6",
@ -7030,9 +7033,9 @@
} }
}, },
"node_modules/ink/node_modules/chalk": { "node_modules/ink/node_modules/chalk": {
"version": "5.4.1", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz",
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0" "node": "^12.17.0 || ^14.13 || >=16.0.0"
@ -7047,6 +7050,21 @@
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ink/node_modules/is-in-ci": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz",
"integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==",
"license": "MIT",
"bin": {
"is-in-ci": "cli.js"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ink/node_modules/signal-exit": { "node_modules/ink/node_modules/signal-exit": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@ -9733,13 +9751,6 @@
"react": "^19.1.0" "react": "^19.1.0"
} }
}, },
"node_modules/react-dom/node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"dev": true,
"license": "MIT"
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -9761,12 +9772,6 @@
"react": "^19.1.0" "react": "^19.1.0"
} }
}, },
"node_modules/react-reconciler/node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT"
},
"node_modules/read-package-up": { "node_modules/read-package-up": {
"version": "11.0.0", "version": "11.0.0",
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz",
@ -10224,13 +10229,10 @@
} }
}, },
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT", "license": "MIT"
"dependencies": {
"loose-envify": "^1.1.0"
}
}, },
"node_modules/selderee": { "node_modules/selderee": {
"version": "0.11.0", "version": "0.11.0",

View File

@ -73,6 +73,7 @@ export interface CliArgs {
listExtensions: boolean | undefined; listExtensions: boolean | undefined;
proxy: string | undefined; proxy: string | undefined;
includeDirectories: string[] | undefined; includeDirectories: string[] | undefined;
screenReader: boolean | undefined;
} }
export async function parseArguments(): Promise<CliArgs> { export async function parseArguments(): Promise<CliArgs> {
@ -229,6 +230,11 @@ export async function parseArguments(): Promise<CliArgs> {
// Handle comma-separated values // Handle comma-separated values
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())), dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
}) })
.option('screen-reader', {
type: 'boolean',
description: 'Enable screen reader mode for accessibility.',
default: false,
})
.check((argv) => { .check((argv) => {
if (argv.prompt && argv['promptInteractive']) { if (argv.prompt && argv['promptInteractive']) {
@ -465,6 +471,9 @@ export async function loadCliConfig(
const sandboxConfig = await loadSandboxConfig(settings, argv); const sandboxConfig = await loadSandboxConfig(settings, argv);
// The screen reader argument takes precedence over the accessibility setting.
const screenReader =
argv.screenReader ?? settings.accessibility?.screenReader ?? false;
return new Config({ return new Config({
sessionId, sessionId,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@ -490,7 +499,10 @@ export async function loadCliConfig(
argv.show_memory_usage || argv.show_memory_usage ||
settings.showMemoryUsage || settings.showMemoryUsage ||
false, false,
accessibility: settings.accessibility, accessibility: {
...settings.accessibility,
screenReader,
},
telemetry: { telemetry: {
enabled: argv.telemetry ?? settings.telemetry?.enabled, enabled: argv.telemetry ?? settings.telemetry?.enabled,
target: (argv.telemetryTarget ?? target: (argv.telemetryTarget ??

View File

@ -58,6 +58,7 @@ export interface SummarizeToolOutputSettings {
export interface AccessibilitySettings { export interface AccessibilitySettings {
disableLoadingPhrases?: boolean; disableLoadingPhrases?: boolean;
screenReader?: boolean;
} }
export interface SettingsError { export interface SettingsError {

View File

@ -206,6 +206,16 @@ export const SETTINGS_SCHEMA = {
description: 'Disable loading phrases for accessibility', description: 'Disable loading phrases for accessibility',
showInDialog: true, showInDialog: true,
}, },
screenReader: {
type: 'boolean',
label: 'Screen Reader Mode',
category: 'Accessibility',
requiresRestart: true,
default: false,
description:
'Render output in plain-text to be more screen reader accessible',
showInDialog: true,
},
}, },
}, },
checkpointing: { checkpointing: {

View File

@ -316,7 +316,7 @@ export async function main() {
/> />
</SettingsContext.Provider> </SettingsContext.Provider>
</React.StrictMode>, </React.StrictMode>,
{ exitOnCtrlC: false }, { exitOnCtrlC: false, isScreenReaderEnabled: config.getScreenReader() },
); );
checkForUpdates() checkForUpdates()

View File

@ -18,6 +18,7 @@ import { runNonInteractive } from './nonInteractiveCli.js';
import { vi } from 'vitest'; import { vi } from 'vitest';
// Mock core modules // Mock core modules
vi.mock('./ui/hooks/atCommandProcessor.js');
vi.mock('@google/gemini-cli-core', async (importOriginal) => { vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original = const original =
await importOriginal<typeof import('@google/gemini-cli-core')>(); await importOriginal<typeof import('@google/gemini-cli-core')>();
@ -41,7 +42,7 @@ describe('runNonInteractive', () => {
sendMessageStream: vi.Mock; sendMessageStream: vi.Mock;
}; };
beforeEach(() => { beforeEach(async () => {
mockCoreExecuteToolCall = vi.mocked(executeToolCall); mockCoreExecuteToolCall = vi.mocked(executeToolCall);
mockShutdownTelemetry = vi.mocked(shutdownTelemetry); mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
@ -72,6 +73,14 @@ describe('runNonInteractive', () => {
getContentGeneratorConfig: vi.fn().mockReturnValue({}), getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getDebugMode: vi.fn().mockReturnValue(false), getDebugMode: vi.fn().mockReturnValue(false),
} as unknown as Config; } as unknown as Config;
const { handleAtCommand } = await import(
'./ui/hooks/atCommandProcessor.js'
);
vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({
processedQuery: [{ text: query }],
shouldProceed: true,
}));
}); });
afterEach(() => { afterEach(() => {
@ -163,14 +172,16 @@ describe('runNonInteractive', () => {
mockCoreExecuteToolCall.mockResolvedValue({ mockCoreExecuteToolCall.mockResolvedValue({
error: new Error('Execution failed'), error: new Error('Execution failed'),
errorType: ToolErrorType.EXECUTION_FAILED, errorType: ToolErrorType.EXECUTION_FAILED,
responseParts: { responseParts: [
functionResponse: { {
name: 'errorTool', functionResponse: {
response: { name: 'errorTool',
output: 'Error: Execution failed', response: {
output: 'Error: Execution failed',
},
}, },
}, },
}, ],
resultDisplay: 'Execution failed', resultDisplay: 'Execution failed',
}); });
const finalResponse: ServerGeminiStreamEvent[] = [ const finalResponse: ServerGeminiStreamEvent[] = [
@ -273,4 +284,48 @@ describe('runNonInteractive', () => {
'\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', '\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
); );
}); });
it('should preprocess @include commands before sending to the model', async () => {
// 1. Mock the imported atCommandProcessor
const { handleAtCommand } = await import(
'./ui/hooks/atCommandProcessor.js'
);
const mockHandleAtCommand = vi.mocked(handleAtCommand);
// 2. Define the raw input and the expected processed output
const rawInput = 'Summarize @file.txt';
const processedParts: Part[] = [
{ text: 'Summarize @file.txt' },
{ text: '\n--- Content from referenced files ---\n' },
{ text: 'This is the content of the file.' },
{ text: '\n--- End of content ---' },
];
// 3. Setup the mock to return the processed parts
mockHandleAtCommand.mockResolvedValue({
processedQuery: processedParts,
shouldProceed: true,
});
// Mock a simple stream response from the Gemini client
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Summary complete.' },
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
// 4. Run the non-interactive mode with the raw input
await runNonInteractive(mockConfig, rawInput, 'prompt-id-7');
// 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
processedParts,
expect.any(AbortSignal),
'prompt-id-7',
);
// 6. Assert the final output is correct
expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.');
});
}); });

View File

@ -13,9 +13,10 @@ import {
GeminiEventType, GeminiEventType,
parseAndFormatApiError, parseAndFormatApiError,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { Content, Part, FunctionCall } from '@google/genai'; import { Content, Part } from '@google/genai';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
export async function runNonInteractive( export async function runNonInteractive(
config: Config, config: Config,
@ -40,9 +41,27 @@ export async function runNonInteractive(
const geminiClient = config.getGeminiClient(); const geminiClient = config.getGeminiClient();
const abortController = new AbortController(); const abortController = new AbortController();
const { processedQuery, shouldProceed } = await handleAtCommand({
query: input,
config,
addItem: (_item, _timestamp) => 0,
onDebugMessage: () => {},
messageId: Date.now(),
signal: abortController.signal,
});
if (!shouldProceed || !processedQuery) {
// An error occurred during @include processing (e.g., file not found).
// The error message is already logged by handleAtCommand.
console.error('Exiting due to an error processing the @ command.');
process.exit(1);
}
let currentMessages: Content[] = [ let currentMessages: Content[] = [
{ role: 'user', parts: [{ text: input }] }, { role: 'user', parts: processedQuery as Part[] },
]; ];
let turnCount = 0; let turnCount = 0;
while (true) { while (true) {
turnCount++; turnCount++;
@ -55,7 +74,7 @@ export async function runNonInteractive(
); );
return; return;
} }
const functionCalls: FunctionCall[] = []; const toolCallRequests: ToolCallRequestInfo[] = [];
const responseStream = geminiClient.sendMessageStream( const responseStream = geminiClient.sendMessageStream(
currentMessages[0]?.parts || [], currentMessages[0]?.parts || [],
@ -72,29 +91,13 @@ export async function runNonInteractive(
if (event.type === GeminiEventType.Content) { if (event.type === GeminiEventType.Content) {
process.stdout.write(event.value); process.stdout.write(event.value);
} else if (event.type === GeminiEventType.ToolCallRequest) { } else if (event.type === GeminiEventType.ToolCallRequest) {
const toolCallRequest = event.value; toolCallRequests.push(event.value);
const fc: FunctionCall = {
name: toolCallRequest.name,
args: toolCallRequest.args,
id: toolCallRequest.callId,
};
functionCalls.push(fc);
} }
} }
if (functionCalls.length > 0) { if (toolCallRequests.length > 0) {
const toolResponseParts: Part[] = []; const toolResponseParts: Part[] = [];
for (const requestInfo of toolCallRequests) {
for (const fc of functionCalls) {
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
const requestInfo: ToolCallRequestInfo = {
callId,
name: fc.name as string,
args: (fc.args ?? {}) as Record<string, unknown>,
isClientInitiated: false,
prompt_id,
};
const toolResponse = await executeToolCall( const toolResponse = await executeToolCall(
config, config,
requestInfo, requestInfo,
@ -103,7 +106,7 @@ export async function runNonInteractive(
if (toolResponse.error) { if (toolResponse.error) {
console.error( console.error(
`Error executing tool ${fc.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`, `Error executing tool ${requestInfo.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
); );
} }

View File

@ -87,6 +87,7 @@ interface MockServerConfig {
getGeminiClient: Mock<() => GeminiClient | undefined>; getGeminiClient: Mock<() => GeminiClient | undefined>;
getUserTier: Mock<() => Promise<string | undefined>>; getUserTier: Mock<() => Promise<string | undefined>>;
getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>; getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>;
getScreenReader: Mock<() => boolean>;
} }
// Mock @google/gemini-cli-core and its Config class // Mock @google/gemini-cli-core and its Config class
@ -168,6 +169,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
getConnectionStatus: vi.fn(() => 'connected'), getConnectionStatus: vi.fn(() => 'connected'),
})), })),
isTrustedFolder: vi.fn(() => true), isTrustedFolder: vi.fn(() => true),
getScreenReader: vi.fn(() => false),
}; };
}); });

View File

@ -923,10 +923,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
key={staticKey} key={staticKey}
items={[ items={[
<Box flexDirection="column" key="header"> <Box flexDirection="column" key="header">
{!settings.merged.hideBanner && ( {!(settings.merged.hideBanner || config.getScreenReader()) && (
<Header version={version} nightly={nightly} /> <Header version={version} nightly={nightly} />
)} )}
{!settings.merged.hideTips && <Tips config={config} />} {!(settings.merged.hideTips || config.getScreenReader()) && (
<Tips config={config} />
)}
</Box>, </Box>,
...history.map((h) => ( ...history.map((h) => (
<HistoryItemDisplay <HistoryItemDisplay
@ -1093,12 +1095,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<LoadingIndicator <LoadingIndicator
thought={ thought={
streamingState === StreamingState.WaitingForConfirmation || streamingState === StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.disableLoadingPhrases config.getAccessibility()?.disableLoadingPhrases ||
config.getScreenReader()
? undefined ? undefined
: thought : thought
} }
currentLoadingPhrase={ currentLoadingPhrase={
config.getAccessibility()?.disableLoadingPhrases config.getAccessibility()?.disableLoadingPhrases ||
config.getScreenReader()
? undefined ? undefined
: currentLoadingPhrase : currentLoadingPhrase
} }

View File

@ -26,6 +26,7 @@ import {
cleanupOldClipboardImages, cleanupOldClipboardImages,
} from '../utils/clipboardUtils.js'; } from '../utils/clipboardUtils.js';
import * as path from 'path'; import * as path from 'path';
import { SCREEN_READER_USER_PREFIX } from '../constants.js';
export interface InputPromptProps { export interface InputPromptProps {
buffer: TextBuffer; buffer: TextBuffer;
@ -688,7 +689,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
> >
{shellModeActive ? ( {shellModeActive ? (
reverseSearchActive ? ( reverseSearchActive ? (
<Text color={theme.text.link}>(r:) </Text> <Text
color={theme.text.link}
aria-label={SCREEN_READER_USER_PREFIX}
>
(r:){' '}
</Text>
) : ( ) : (
'! ' '! '
) )

View File

@ -27,11 +27,61 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js'; import { SettingsDialog } from './SettingsDialog.js';
import { LoadedSettings } from '../../config/settings.js'; import { LoadedSettings } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js'; import { VimModeProvider } from '../contexts/VimModeContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
// Mock the VimModeContext // Mock the VimModeContext
const mockToggleVimEnabled = vi.fn(); const mockToggleVimEnabled = vi.fn();
const mockSetVimMode = vi.fn(); const mockSetVimMode = vi.fn();
const createMockSettings = (
userSettings = {},
systemSettings = {},
workspaceSettings = {},
) =>
new LoadedSettings(
{
settings: { customThemes: {}, mcpServers: {}, ...systemSettings },
path: '/system/settings.json',
},
{
settings: {
customThemes: {},
mcpServers: {},
...userSettings,
},
path: '/user/settings.json',
},
{
settings: { customThemes: {}, mcpServers: {}, ...workspaceSettings },
path: '/workspace/settings.json',
},
[],
true,
);
vi.mock('../contexts/SettingsContext.js', async () => {
const actual = await vi.importActual('../contexts/SettingsContext.js');
let settings = createMockSettings({ 'a.string.setting': 'initial' });
return {
...actual,
useSettings: () => ({
settings,
setSetting: (key: string, value: string) => {
settings = createMockSettings({ [key]: value });
},
getSettingDefinition: (key: string) => {
if (key === 'a.string.setting') {
return {
type: 'string',
description: 'A string setting',
};
}
return undefined;
},
}),
};
});
vi.mock('../contexts/VimModeContext.js', async () => { vi.mock('../contexts/VimModeContext.js', async () => {
const actual = await vi.importActual('../contexts/VimModeContext.js'); const actual = await vi.importActual('../contexts/VimModeContext.js');
return { return {
@ -53,28 +103,6 @@ vi.mock('../../utils/settingsUtils.js', async () => {
}; };
}); });
// Mock the useKeypress hook to avoid context issues
interface Key {
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
paste: boolean;
sequence: string;
}
// Variables for keypress simulation (not currently used)
// let currentKeypressHandler: ((key: Key) => void) | null = null;
// let isKeypressActive = false;
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(
(_handler: (key: Key) => void, _options: { isActive: boolean }) => {
// Mock implementation - simplified for test stability
},
),
}));
// Helper function to simulate key presses (commented out for now) // Helper function to simulate key presses (commented out for now)
// const simulateKeyPress = async (keyData: Partial<Key> & { name: string }) => { // const simulateKeyPress = async (keyData: Partial<Key> & { name: string }) => {
// if (currentKeypressHandler) { // if (currentKeypressHandler) {
@ -149,7 +177,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
const output = lastFrame(); const output = lastFrame();
@ -163,7 +193,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
const output = lastFrame(); const output = lastFrame();
@ -176,7 +208,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
const output = lastFrame(); const output = lastFrame();
@ -191,7 +225,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Press down arrow // Press down arrow
@ -207,7 +243,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// First go down, then up // First go down, then up
@ -224,7 +262,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Navigate with vim keys // Navigate with vim keys
@ -241,7 +281,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Try to go up from first item // Try to go up from first item
@ -259,7 +301,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Press Enter to toggle current setting // Press Enter to toggle current setting
@ -274,7 +318,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Press Space to toggle current setting // Press Space to toggle current setting
@ -289,7 +335,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Navigate to vim mode setting and toggle it // Navigate to vim mode setting and toggle it
@ -308,7 +356,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Switch to scope focus // Switch to scope focus
@ -327,7 +377,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame, unmount } = render( const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Wait for initial render // Wait for initial render
@ -352,11 +404,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn(); const onRestartRequest = vi.fn();
const { unmount } = render( const { unmount } = render(
<SettingsDialog <KeypressProvider kittyProtocolEnabled={false}>
settings={settings} <SettingsDialog
onSelect={() => {}} settings={settings}
onRestartRequest={onRestartRequest} onSelect={() => {}}
/>, onRestartRequest={onRestartRequest}
/>
</KeypressProvider>,
); );
// This test would need to trigger a restart-required setting change // This test would need to trigger a restart-required setting change
@ -371,11 +425,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn(); const onRestartRequest = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog <KeypressProvider kittyProtocolEnabled={false}>
settings={settings} <SettingsDialog
onSelect={() => {}} settings={settings}
onRestartRequest={onRestartRequest} onSelect={() => {}}
/>, onRestartRequest={onRestartRequest}
/>
</KeypressProvider>,
); );
// Press 'r' key (this would only work if restart prompt is showing) // Press 'r' key (this would only work if restart prompt is showing)
@ -393,7 +449,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame, unmount } = render( const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Wait for initial render // Wait for initial render
@ -418,7 +476,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Switch to scope selector // Switch to scope selector
@ -442,7 +502,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Should show user scope values initially // Should show user scope values initially
@ -459,7 +521,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Try to toggle a setting (this might trigger vim mode toggle) // Try to toggle a setting (this might trigger vim mode toggle)
@ -477,7 +541,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Toggle a setting // Toggle a setting
@ -499,7 +565,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Navigate down many times to test scrolling // Navigate down many times to test scrolling
@ -519,7 +587,9 @@ describe('SettingsDialog', () => {
const { stdin, unmount } = render( const { stdin, unmount } = render(
<VimModeProvider settings={settings}> <VimModeProvider settings={settings}>
<SettingsDialog settings={settings} onSelect={onSelect} /> <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>
</VimModeProvider>, </VimModeProvider>,
); );
@ -542,7 +612,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
const output = lastFrame(); const output = lastFrame();
@ -555,7 +627,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Toggle a non-restart-required setting (like hideTips) // Toggle a non-restart-required setting (like hideTips)
@ -571,7 +645,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame, unmount } = render( const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// This test would need to navigate to a specific restart-required setting // This test would need to navigate to a specific restart-required setting
@ -591,7 +667,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { unmount } = render( const { unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Restart prompt should be cleared when switching scopes // Restart prompt should be cleared when switching scopes
@ -609,7 +687,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
const output = lastFrame(); const output = lastFrame();
@ -626,7 +706,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
const output = lastFrame(); const output = lastFrame();
@ -641,7 +723,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Rapid navigation // Rapid navigation
@ -660,7 +744,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Press Ctrl+C to reset current setting to default // Press Ctrl+C to reset current setting to default
@ -676,7 +762,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Press Ctrl+L to reset current setting to default // Press Ctrl+L to reset current setting to default
@ -692,7 +780,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Try to navigate when potentially at bounds // Try to navigate when potentially at bounds
@ -709,7 +799,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame, unmount } = render( const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Wait for initial render // Wait for initial render
@ -739,7 +831,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Should still render without crashing // Should still render without crashing
@ -752,7 +846,9 @@ describe('SettingsDialog', () => {
// Should not crash even if some settings are missing definitions // Should not crash even if some settings are missing definitions
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
expect(lastFrame()).toContain('Settings'); expect(lastFrame()).toContain('Settings');
@ -765,7 +861,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame, unmount } = render( const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Wait for initial render // Wait for initial render
@ -793,7 +891,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Toggle first setting (should require restart) // Toggle first setting (should require restart)
@ -822,7 +922,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Multiple scope changes // Multiple scope changes
@ -846,11 +948,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn(); const onRestartRequest = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog <KeypressProvider kittyProtocolEnabled={false}>
settings={settings} <SettingsDialog
onSelect={() => {}} settings={settings}
onRestartRequest={onRestartRequest} onSelect={() => {}}
/>, onRestartRequest={onRestartRequest}
/>
</KeypressProvider>,
); );
// This would test the restart workflow if we could trigger it // This would test the restart workflow if we could trigger it
@ -863,4 +967,58 @@ describe('SettingsDialog', () => {
unmount(); unmount();
}); });
}); });
describe('String Settings Editing', () => {
it('should allow editing and committing a string setting', async () => {
let settings = createMockSettings({ 'a.string.setting': 'initial' });
const onSelect = vi.fn();
const { stdin, unmount, rerender } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Wait for the dialog to render
await wait();
// Navigate to the last setting
for (let i = 0; i < 20; i++) {
stdin.write('j'); // Down
await wait(10);
}
// Press Enter to start editing
stdin.write('\r');
await wait();
// Type a new value
stdin.write('new value');
await wait();
// Press Enter to commit
stdin.write('\r');
await wait();
settings = createMockSettings(
{ 'a.string.setting': 'new value' },
{},
{},
);
rerender(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
await wait();
// Press Escape to exit
stdin.write('\u001B');
await wait();
expect(onSelect).toHaveBeenCalledWith(undefined, 'User');
unmount();
});
});
}); });

View File

@ -35,7 +35,7 @@ import {
import { useVimMode } from '../contexts/VimModeContext.js'; import { useVimMode } from '../contexts/VimModeContext.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import chalk from 'chalk'; import chalk from 'chalk';
import { cpSlice, cpLen } from '../utils/textUtils.js'; import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
interface SettingsDialogProps { interface SettingsDialogProps {
settings: LoadedSettings; settings: LoadedSettings;
@ -78,8 +78,8 @@ export function SettingsDialog({
new Set(), new Set(),
); );
// Preserve pending changes across scope switches (boolean and number values only) // Preserve pending changes across scope switches
type PendingValue = boolean | number; type PendingValue = boolean | number | string;
const [globalPendingChanges, setGlobalPendingChanges] = useState< const [globalPendingChanges, setGlobalPendingChanges] = useState<
Map<string, PendingValue> Map<string, PendingValue>
>(new Map()); >(new Map());
@ -99,7 +99,10 @@ export function SettingsDialog({
const def = getSettingDefinition(key); const def = getSettingDefinition(key);
if (def?.type === 'boolean' && typeof value === 'boolean') { if (def?.type === 'boolean' && typeof value === 'boolean') {
updated = setPendingSettingValue(key, value, updated); updated = setPendingSettingValue(key, value, updated);
} else if (def?.type === 'number' && typeof value === 'number') { } else if (
(def?.type === 'number' && typeof value === 'number') ||
(def?.type === 'string' && typeof value === 'string')
) {
updated = setPendingSettingValueAny(key, value, updated); updated = setPendingSettingValueAny(key, value, updated);
} }
newModified.add(key); newModified.add(key);
@ -123,7 +126,7 @@ export function SettingsDialog({
type: definition?.type, type: definition?.type,
toggle: () => { toggle: () => {
if (definition?.type !== 'boolean') { if (definition?.type !== 'boolean') {
// For non-boolean (e.g., number) items, toggle will be handled via edit mode. // For non-boolean items, toggle will be handled via edit mode.
return; return;
} }
const currentValue = getSettingValue(key, pendingSettings, {}); const currentValue = getSettingValue(key, pendingSettings, {});
@ -220,7 +223,7 @@ export function SettingsDialog({
const items = generateSettingsItems(); const items = generateSettingsItems();
// Number edit state // Generic edit state
const [editingKey, setEditingKey] = useState<string | null>(null); const [editingKey, setEditingKey] = useState<string | null>(null);
const [editBuffer, setEditBuffer] = useState<string>(''); const [editBuffer, setEditBuffer] = useState<string>('');
const [editCursorPos, setEditCursorPos] = useState<number>(0); // Cursor position within edit buffer const [editCursorPos, setEditCursorPos] = useState<number>(0); // Cursor position within edit buffer
@ -235,28 +238,39 @@ export function SettingsDialog({
return () => clearInterval(id); return () => clearInterval(id);
}, [editingKey]); }, [editingKey]);
const startEditingNumber = (key: string, initial?: string) => { const startEditing = (key: string, initial?: string) => {
setEditingKey(key); setEditingKey(key);
const initialValue = initial ?? ''; const initialValue = initial ?? '';
setEditBuffer(initialValue); setEditBuffer(initialValue);
setEditCursorPos(cpLen(initialValue)); // Position cursor at end of initial value setEditCursorPos(cpLen(initialValue)); // Position cursor at end of initial value
}; };
const commitNumberEdit = (key: string) => { const commitEdit = (key: string) => {
if (editBuffer.trim() === '') { const definition = getSettingDefinition(key);
// Nothing entered; cancel edit const type = definition?.type;
if (editBuffer.trim() === '' && type === 'number') {
// Nothing entered for a number; cancel edit
setEditingKey(null); setEditingKey(null);
setEditBuffer(''); setEditBuffer('');
setEditCursorPos(0); setEditCursorPos(0);
return; return;
} }
const parsed = Number(editBuffer.trim());
if (Number.isNaN(parsed)) { let parsed: string | number;
// Invalid number; cancel edit if (type === 'number') {
setEditingKey(null); const numParsed = Number(editBuffer.trim());
setEditBuffer(''); if (Number.isNaN(numParsed)) {
setEditCursorPos(0); // Invalid number; cancel edit
return; setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
return;
}
parsed = numParsed;
} else {
// For strings, use the buffer as is.
parsed = editBuffer;
} }
// Update pending // Update pending
@ -347,10 +361,16 @@ export function SettingsDialog({
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings')); setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
} }
if (focusSection === 'settings') { if (focusSection === 'settings') {
// If editing a number, capture numeric input and control keys // If editing, capture input and control keys
if (editingKey) { if (editingKey) {
const definition = getSettingDefinition(editingKey);
const type = definition?.type;
if (key.paste && key.sequence) { if (key.paste && key.sequence) {
const pasted = key.sequence.replace(/[^0-9\-+.]/g, ''); let pasted = key.sequence;
if (type === 'number') {
pasted = key.sequence.replace(/[^0-9\-+.]/g, '');
}
if (pasted) { if (pasted) {
setEditBuffer((b) => { setEditBuffer((b) => {
const before = cpSlice(b, 0, editCursorPos); const before = cpSlice(b, 0, editCursorPos);
@ -380,16 +400,27 @@ export function SettingsDialog({
return; return;
} }
if (name === 'escape') { if (name === 'escape') {
commitNumberEdit(editingKey); commitEdit(editingKey);
return; return;
} }
if (name === 'return') { if (name === 'return') {
commitNumberEdit(editingKey); commitEdit(editingKey);
return; return;
} }
// Allow digits, minus, plus, and dot
const ch = key.sequence; let ch = key.sequence;
if (/[0-9\-+.]/.test(ch)) { let isValidChar = false;
if (type === 'number') {
// Allow digits, minus, plus, and dot.
isValidChar = /[0-9\-+.]/.test(ch);
} else {
ch = stripUnsafeCharacters(ch);
// For strings, allow any single character that isn't a control
// sequence.
isValidChar = ch.length === 1;
}
if (isValidChar) {
setEditBuffer((currentBuffer) => { setEditBuffer((currentBuffer) => {
const beforeCursor = cpSlice(currentBuffer, 0, editCursorPos); const beforeCursor = cpSlice(currentBuffer, 0, editCursorPos);
const afterCursor = cpSlice(currentBuffer, editCursorPos); const afterCursor = cpSlice(currentBuffer, editCursorPos);
@ -398,6 +429,7 @@ export function SettingsDialog({
setEditCursorPos((pos) => pos + 1); setEditCursorPos((pos) => pos + 1);
return; return;
} }
// Arrow key navigation // Arrow key navigation
if (name === 'left') { if (name === 'left') {
setEditCursorPos((pos) => Math.max(0, pos - 1)); setEditCursorPos((pos) => Math.max(0, pos - 1));
@ -422,7 +454,7 @@ export function SettingsDialog({
if (name === 'up' || name === 'k') { if (name === 'up' || name === 'k') {
// If editing, commit first // If editing, commit first
if (editingKey) { if (editingKey) {
commitNumberEdit(editingKey); commitEdit(editingKey);
} }
const newIndex = const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1; activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
@ -436,7 +468,7 @@ export function SettingsDialog({
} else if (name === 'down' || name === 'j') { } else if (name === 'down' || name === 'j') {
// If editing, commit first // If editing, commit first
if (editingKey) { if (editingKey) {
commitNumberEdit(editingKey); commitEdit(editingKey);
} }
const newIndex = const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0; activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
@ -449,15 +481,18 @@ export function SettingsDialog({
} }
} else if (name === 'return' || name === 'space') { } else if (name === 'return' || name === 'space') {
const currentItem = items[activeSettingIndex]; const currentItem = items[activeSettingIndex];
if (currentItem?.type === 'number') { if (
startEditingNumber(currentItem.value); currentItem?.type === 'number' ||
currentItem?.type === 'string'
) {
startEditing(currentItem.value);
} else { } else {
currentItem?.toggle(); currentItem?.toggle();
} }
} else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) { } else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) {
const currentItem = items[activeSettingIndex]; const currentItem = items[activeSettingIndex];
if (currentItem?.type === 'number') { if (currentItem?.type === 'number') {
startEditingNumber(currentItem.value, key.sequence); startEditing(currentItem.value, key.sequence);
} }
} else if (ctrl && (name === 'c' || name === 'l')) { } else if (ctrl && (name === 'c' || name === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default // Ctrl+C or Ctrl+L: Clear current setting and reset to default
@ -475,8 +510,11 @@ export function SettingsDialog({
prev, prev,
), ),
); );
} else if (defType === 'number') { } else if (defType === 'number' || defType === 'string') {
if (typeof defaultValue === 'number') { if (
typeof defaultValue === 'number' ||
typeof defaultValue === 'string'
) {
setPendingSettings((prev) => setPendingSettings((prev) =>
setPendingSettingValueAny( setPendingSettingValueAny(
currentSetting.value, currentSetting.value,
@ -509,7 +547,8 @@ export function SettingsDialog({
? typeof defaultValue === 'boolean' ? typeof defaultValue === 'boolean'
? defaultValue ? defaultValue
: false : false
: typeof defaultValue === 'number' : typeof defaultValue === 'number' ||
typeof defaultValue === 'string'
? defaultValue ? defaultValue
: undefined; : undefined;
const immediateSettingsObject = const immediateSettingsObject =
@ -541,7 +580,9 @@ export function SettingsDialog({
(currentSetting.type === 'boolean' && (currentSetting.type === 'boolean' &&
typeof defaultValue === 'boolean') || typeof defaultValue === 'boolean') ||
(currentSetting.type === 'number' && (currentSetting.type === 'number' &&
typeof defaultValue === 'number') typeof defaultValue === 'number') ||
(currentSetting.type === 'string' &&
typeof defaultValue === 'string')
) { ) {
setGlobalPendingChanges((prev) => { setGlobalPendingChanges((prev) => {
const next = new Map(prev); const next = new Map(prev);
@ -584,7 +625,7 @@ export function SettingsDialog({
} }
if (name === 'escape') { if (name === 'escape') {
if (editingKey) { if (editingKey) {
commitNumberEdit(editingKey); commitEdit(editingKey);
} else { } else {
onSelect(undefined, selectedScope); onSelect(undefined, selectedScope);
} }
@ -637,8 +678,8 @@ export function SettingsDialog({
// Cursor not visible // Cursor not visible
displayValue = editBuffer; displayValue = editBuffer;
} }
} else if (item.type === 'number') { } else if (item.type === 'number' || item.type === 'string') {
// For numbers, get the actual current value from pending settings // For numbers/strings, get the actual current value from pending settings
const path = item.value.split('.'); const path = item.value.split('.');
const currentValue = getNestedValue(pendingSettings, path); const currentValue = getNestedValue(pendingSettings, path);

View File

@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
import { CompressionProps } from '../../types.js'; import { CompressionProps } from '../../types.js';
import Spinner from 'ink-spinner'; import Spinner from 'ink-spinner';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../constants.js';
export interface CompressionDisplayProps { export interface CompressionDisplayProps {
compression: CompressionProps; compression: CompressionProps;
@ -40,6 +41,7 @@ export const CompressionMessage: React.FC<CompressionDisplayProps> = ({
color={ color={
compression.isPending ? Colors.AccentPurple : Colors.AccentGreen compression.isPending ? Colors.AccentPurple : Colors.AccentGreen
} }
aria-label={SCREEN_READER_MODEL_PREFIX}
> >
{text} {text}
</Text> </Text>

View File

@ -8,6 +8,7 @@ import React from 'react';
import { Text, Box } from 'ink'; import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../constants.js';
interface GeminiMessageProps { interface GeminiMessageProps {
text: string; text: string;
@ -28,7 +29,12 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
return ( return (
<Box flexDirection="row"> <Box flexDirection="row">
<Box width={prefixWidth}> <Box width={prefixWidth}>
<Text color={Colors.AccentPurple}>{prefix}</Text> <Text
color={Colors.AccentPurple}
aria-label={SCREEN_READER_MODEL_PREFIX}
>
{prefix}
</Text>
</Box> </Box>
<Box flexGrow={1} flexDirection="column"> <Box flexGrow={1} flexDirection="column">
<MarkdownDisplay <MarkdownDisplay

View File

@ -7,6 +7,7 @@
import React from 'react'; import React from 'react';
import { Text, Box } from 'ink'; import { Text, Box } from 'ink';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
import { SCREEN_READER_USER_PREFIX } from '../../constants.js';
interface UserMessageProps { interface UserMessageProps {
text: string; text: string;
@ -31,7 +32,9 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
alignSelf="flex-start" alignSelf="flex-start"
> >
<Box width={prefixWidth}> <Box width={prefixWidth}>
<Text color={textColor}>{prefix}</Text> <Text color={textColor} aria-label={SCREEN_READER_USER_PREFIX}>
{prefix}
</Text>
</Box> </Box>
<Box flexGrow={1}> <Box flexGrow={1}>
<Text wrap="wrap" color={textColor}> <Text wrap="wrap" color={textColor}>

View File

@ -4,8 +4,6 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import stripAnsi from 'strip-ansi';
import { stripVTControlCharacters } from 'util';
import { spawnSync } from 'child_process'; import { spawnSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
@ -13,7 +11,12 @@ import pathMod from 'path';
import { useState, useCallback, useEffect, useMemo, useReducer } from 'react'; import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
import stringWidth from 'string-width'; import stringWidth from 'string-width';
import { unescapePath } from '@google/gemini-cli-core'; import { unescapePath } from '@google/gemini-cli-core';
import { toCodePoints, cpLen, cpSlice } from '../../utils/textUtils.js'; import {
toCodePoints,
cpLen,
cpSlice,
stripUnsafeCharacters,
} from '../../utils/textUtils.js';
import { handleVimAction, VimAction } from './vim-buffer-actions.js'; import { handleVimAction, VimAction } from './vim-buffer-actions.js';
export type Direction = export type Direction =
@ -494,51 +497,6 @@ export const replaceRangeInternal = (
}; };
}; };
/**
* Strip characters that can break terminal rendering.
*
* Uses Node.js built-in stripVTControlCharacters to handle VT sequences,
* then filters remaining control characters that can disrupt display.
*
* Characters stripped:
* - ANSI escape sequences (via strip-ansi)
* - VT control sequences (via Node.js util.stripVTControlCharacters)
* - C0 control chars (0x00-0x1F) except CR/LF which are handled elsewhere
* - C1 control chars (0x80-0x9F) that can cause display issues
*
* Characters preserved:
* - All printable Unicode including emojis
* - DEL (0x7F) - handled functionally by applyOperations, not a display issue
* - CR/LF (0x0D/0x0A) - needed for line breaks
*/
function stripUnsafeCharacters(str: string): string {
const strippedAnsi = stripAnsi(str);
const strippedVT = stripVTControlCharacters(strippedAnsi);
return toCodePoints(strippedVT)
.filter((char) => {
const code = char.codePointAt(0);
if (code === undefined) return false;
// Preserve CR/LF for line handling
if (code === 0x0a || code === 0x0d) return true;
// Remove C0 control chars (except CR/LF) that can break display
// Examples: BELL(0x07) makes noise, BS(0x08) moves cursor, VT(0x0B), FF(0x0C)
if (code >= 0x00 && code <= 0x1f) return false;
// Remove C1 control chars (0x80-0x9F) - legacy 8-bit control codes
if (code >= 0x80 && code <= 0x9f) return false;
// Preserve DEL (0x7F) - it's handled functionally by applyOperations as backspace
// and doesn't cause rendering issues when displayed
// Preserve all other characters including Unicode/emojis
return true;
})
.join('');
}
export interface Viewport { export interface Viewport {
height: number; height: number;
width: number; width: number;

View File

@ -15,3 +15,7 @@ export const UI_WIDTH =
export const STREAM_DEBOUNCE_MS = 100; export const STREAM_DEBOUNCE_MS = 100;
export const SHELL_COMMAND_NAME = 'Shell Command'; export const SHELL_COMMAND_NAME = 'Shell Command';
export const SCREEN_READER_USER_PREFIX = 'User: ';
export const SCREEN_READER_MODEL_PREFIX = 'Model: ';

View File

@ -134,7 +134,6 @@ export function useReactToolScheduler(
const scheduler = useMemo( const scheduler = useMemo(
() => () =>
new CoreToolScheduler({ new CoreToolScheduler({
toolRegistry: config.getToolRegistry(),
outputUpdateHandler, outputUpdateHandler,
onAllToolCallsComplete: allToolCallsCompleteHandler, onAllToolCallsComplete: allToolCallsCompleteHandler,
onToolCallsUpdate: toolCallsUpdateHandler, onToolCallsUpdate: toolCallsUpdateHandler,

View File

@ -4,6 +4,9 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import stripAnsi from 'strip-ansi';
import { stripVTControlCharacters } from 'util';
/** /**
* Calculates the maximum width of a multi-line ASCII art string. * Calculates the maximum width of a multi-line ASCII art string.
* @param asciiArt The ASCII art string. * @param asciiArt The ASCII art string.
@ -38,3 +41,48 @@ export function cpSlice(str: string, start: number, end?: number): string {
const arr = toCodePoints(str).slice(start, end); const arr = toCodePoints(str).slice(start, end);
return arr.join(''); return arr.join('');
} }
/**
* Strip characters that can break terminal rendering.
*
* Uses Node.js built-in stripVTControlCharacters to handle VT sequences,
* then filters remaining control characters that can disrupt display.
*
* Characters stripped:
* - ANSI escape sequences (via strip-ansi)
* - VT control sequences (via Node.js util.stripVTControlCharacters)
* - C0 control chars (0x00-0x1F) except CR/LF which are handled elsewhere
* - C1 control chars (0x80-0x9F) that can cause display issues
*
* Characters preserved:
* - All printable Unicode including emojis
* - DEL (0x7F) - handled functionally by applyOperations, not a display issue
* - CR/LF (0x0D/0x0A) - needed for line breaks
*/
export function stripUnsafeCharacters(str: string): string {
const strippedAnsi = stripAnsi(str);
const strippedVT = stripVTControlCharacters(strippedAnsi);
return toCodePoints(strippedVT)
.filter((char) => {
const code = char.codePointAt(0);
if (code === undefined) return false;
// Preserve CR/LF for line handling
if (code === 0x0a || code === 0x0d) return true;
// Remove C0 control chars (except CR/LF) that can break display
// Examples: BELL(0x07) makes noise, BS(0x08) moves cursor, VT(0x0B), FF(0x0C)
if (code >= 0x00 && code <= 0x1f) return false;
// Remove C1 control chars (0x80-0x9f) - legacy 8-bit control codes
if (code >= 0x80 && code <= 0x9f) return false;
// Preserve DEL (0x7f) - it's handled functionally by applyOperations as backspace
// and doesn't cause rendering issues when displayed
// Preserve all other characters including Unicode/emojis
return true;
})
.join('');
}

View File

@ -62,6 +62,7 @@ export enum ApprovalMode {
export interface AccessibilitySettings { export interface AccessibilitySettings {
disableLoadingPhrases?: boolean; disableLoadingPhrases?: boolean;
screenReader?: boolean;
} }
export interface BugCommandSettings { export interface BugCommandSettings {
@ -734,6 +735,10 @@ export class Config {
return this.skipNextSpeakerCheck; return this.skipNextSpeakerCheck;
} }
getScreenReader(): boolean {
return this.accessibility.screenReader ?? false;
}
getEnablePromptCompletion(): boolean { getEnablePromptCompletion(): boolean {
return this.enablePromptCompletion; return this.enablePromptCompletion;
} }

View File

@ -129,11 +129,11 @@ describe('CoreToolScheduler', () => {
model: 'test-model', model: 'test-model',
authType: 'oauth-personal', authType: 'oauth-personal',
}), }),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config; } as unknown as Config;
const scheduler = new CoreToolScheduler({ const scheduler = new CoreToolScheduler({
config: mockConfig, config: mockConfig,
toolRegistry: mockToolRegistry,
onAllToolCallsComplete, onAllToolCallsComplete,
onToolCallsUpdate, onToolCallsUpdate,
getPreferredEditor: () => 'vscode', getPreferredEditor: () => 'vscode',
@ -189,11 +189,11 @@ describe('CoreToolScheduler with payload', () => {
model: 'test-model', model: 'test-model',
authType: 'oauth-personal', authType: 'oauth-personal',
}), }),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config; } as unknown as Config;
const scheduler = new CoreToolScheduler({ const scheduler = new CoreToolScheduler({
config: mockConfig, config: mockConfig,
toolRegistry: mockToolRegistry,
onAllToolCallsComplete, onAllToolCallsComplete,
onToolCallsUpdate, onToolCallsUpdate,
getPreferredEditor: () => 'vscode', getPreferredEditor: () => 'vscode',
@ -462,15 +462,14 @@ class MockEditTool extends BaseDeclarativeTool<
describe('CoreToolScheduler edit cancellation', () => { describe('CoreToolScheduler edit cancellation', () => {
it('should preserve diff when an edit is cancelled', async () => { it('should preserve diff when an edit is cancelled', async () => {
const mockEditTool = new MockEditTool(); const mockEditTool = new MockEditTool();
const declarativeTool = mockEditTool;
const mockToolRegistry = { const mockToolRegistry = {
getTool: () => declarativeTool, getTool: () => mockEditTool,
getFunctionDeclarations: () => [], getFunctionDeclarations: () => [],
tools: new Map(), tools: new Map(),
discovery: {}, discovery: {},
registerTool: () => {}, registerTool: () => {},
getToolByName: () => declarativeTool, getToolByName: () => mockEditTool,
getToolByDisplayName: () => declarativeTool, getToolByDisplayName: () => mockEditTool,
getTools: () => [], getTools: () => [],
discoverTools: async () => {}, discoverTools: async () => {},
getAllTools: () => [], getAllTools: () => [],
@ -489,11 +488,11 @@ describe('CoreToolScheduler edit cancellation', () => {
model: 'test-model', model: 'test-model',
authType: 'oauth-personal', authType: 'oauth-personal',
}), }),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config; } as unknown as Config;
const scheduler = new CoreToolScheduler({ const scheduler = new CoreToolScheduler({
config: mockConfig, config: mockConfig,
toolRegistry: mockToolRegistry,
onAllToolCallsComplete, onAllToolCallsComplete,
onToolCallsUpdate, onToolCallsUpdate,
getPreferredEditor: () => 'vscode', getPreferredEditor: () => 'vscode',
@ -581,11 +580,11 @@ describe('CoreToolScheduler YOLO mode', () => {
model: 'test-model', model: 'test-model',
authType: 'oauth-personal', authType: 'oauth-personal',
}), }),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config; } as unknown as Config;
const scheduler = new CoreToolScheduler({ const scheduler = new CoreToolScheduler({
config: mockConfig, config: mockConfig,
toolRegistry: mockToolRegistry,
onAllToolCallsComplete, onAllToolCallsComplete,
onToolCallsUpdate, onToolCallsUpdate,
getPreferredEditor: () => 'vscode', getPreferredEditor: () => 'vscode',
@ -670,11 +669,11 @@ describe('CoreToolScheduler request queueing', () => {
model: 'test-model', model: 'test-model',
authType: 'oauth-personal', authType: 'oauth-personal',
}), }),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config; } as unknown as Config;
const scheduler = new CoreToolScheduler({ const scheduler = new CoreToolScheduler({
config: mockConfig, config: mockConfig,
toolRegistry: mockToolRegistry,
onAllToolCallsComplete, onAllToolCallsComplete,
onToolCallsUpdate, onToolCallsUpdate,
getPreferredEditor: () => 'vscode', getPreferredEditor: () => 'vscode',
@ -783,11 +782,11 @@ describe('CoreToolScheduler request queueing', () => {
model: 'test-model', model: 'test-model',
authType: 'oauth-personal', authType: 'oauth-personal',
}), }),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config; } as unknown as Config;
const scheduler = new CoreToolScheduler({ const scheduler = new CoreToolScheduler({
config: mockConfig, config: mockConfig,
toolRegistry: mockToolRegistry,
onAllToolCallsComplete, onAllToolCallsComplete,
onToolCallsUpdate, onToolCallsUpdate,
getPreferredEditor: () => 'vscode', getPreferredEditor: () => 'vscode',
@ -864,7 +863,9 @@ describe('CoreToolScheduler request queueing', () => {
getTools: () => [], getTools: () => [],
discoverTools: async () => {}, discoverTools: async () => {},
discovery: {}, discovery: {},
}; } as unknown as ToolRegistry;
mockConfig.getToolRegistry = () => toolRegistry;
const onAllToolCallsComplete = vi.fn(); const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn(); const onToolCallsUpdate = vi.fn();
@ -874,7 +875,6 @@ describe('CoreToolScheduler request queueing', () => {
const scheduler = new CoreToolScheduler({ const scheduler = new CoreToolScheduler({
config: mockConfig, config: mockConfig,
toolRegistry: toolRegistry as unknown as ToolRegistry,
onAllToolCallsComplete, onAllToolCallsComplete,
onToolCallsUpdate: (toolCalls) => { onToolCallsUpdate: (toolCalls) => {
onToolCallsUpdate(toolCalls); onToolCallsUpdate(toolCalls);

View File

@ -226,12 +226,11 @@ const createErrorResponse = (
}); });
interface CoreToolSchedulerOptions { interface CoreToolSchedulerOptions {
toolRegistry: ToolRegistry; config: Config;
outputUpdateHandler?: OutputUpdateHandler; outputUpdateHandler?: OutputUpdateHandler;
onAllToolCallsComplete?: AllToolCallsCompleteHandler; onAllToolCallsComplete?: AllToolCallsCompleteHandler;
onToolCallsUpdate?: ToolCallsUpdateHandler; onToolCallsUpdate?: ToolCallsUpdateHandler;
getPreferredEditor: () => EditorType | undefined; getPreferredEditor: () => EditorType | undefined;
config: Config;
onEditorClose: () => void; onEditorClose: () => void;
} }
@ -255,7 +254,7 @@ export class CoreToolScheduler {
constructor(options: CoreToolSchedulerOptions) { constructor(options: CoreToolSchedulerOptions) {
this.config = options.config; this.config = options.config;
this.toolRegistry = options.toolRegistry; this.toolRegistry = options.config.getToolRegistry();
this.outputUpdateHandler = options.outputUpdateHandler; this.outputUpdateHandler = options.outputUpdateHandler;
this.onAllToolCallsComplete = options.onAllToolCallsComplete; this.onAllToolCallsComplete = options.onAllToolCallsComplete;
this.onToolCallsUpdate = options.onToolCallsUpdate; this.onToolCallsUpdate = options.onToolCallsUpdate;

View File

@ -12,6 +12,7 @@ import {
ToolResult, ToolResult,
Config, Config,
ToolErrorType, ToolErrorType,
ApprovalMode,
} from '../index.js'; } from '../index.js';
import { Part } from '@google/genai'; import { Part } from '@google/genai';
import { MockTool } from '../test-utils/tools.js'; import { MockTool } from '../test-utils/tools.js';
@ -27,10 +28,11 @@ describe('executeToolCall', () => {
mockToolRegistry = { mockToolRegistry = {
getTool: vi.fn(), getTool: vi.fn(),
// Add other ToolRegistry methods if needed, or use a more complete mock
} as unknown as ToolRegistry; } as unknown as ToolRegistry;
mockConfig = { mockConfig = {
getToolRegistry: () => mockToolRegistry,
getApprovalMode: () => ApprovalMode.DEFAULT,
getSessionId: () => 'test-session-id', getSessionId: () => 'test-session-id',
getUsageStatisticsEnabled: () => true, getUsageStatisticsEnabled: () => true,
getDebugMode: () => false, getDebugMode: () => false,
@ -38,7 +40,6 @@ describe('executeToolCall', () => {
model: 'test-model', model: 'test-model',
authType: 'oauth-personal', authType: 'oauth-personal',
}), }),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config; } as unknown as Config;
abortController = new AbortController(); abortController = new AbortController();
@ -57,7 +58,7 @@ describe('executeToolCall', () => {
returnDisplay: 'Success!', returnDisplay: 'Success!',
}; };
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool); vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.spyOn(mockTool, 'validateBuildAndExecute').mockResolvedValue(toolResult); mockTool.executeFn.mockReturnValue(toolResult);
const response = await executeToolCall( const response = await executeToolCall(
mockConfig, mockConfig,
@ -66,18 +67,18 @@ describe('executeToolCall', () => {
); );
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('testTool'); expect(mockToolRegistry.getTool).toHaveBeenCalledWith('testTool');
expect(mockTool.validateBuildAndExecute).toHaveBeenCalledWith( expect(mockTool.executeFn).toHaveBeenCalledWith(request.args);
request.args, expect(response).toStrictEqual({
abortController.signal, callId: 'call1',
); error: undefined,
expect(response.callId).toBe('call1'); errorType: undefined,
expect(response.error).toBeUndefined(); resultDisplay: 'Success!',
expect(response.resultDisplay).toBe('Success!'); responseParts: {
expect(response.responseParts).toEqual({ functionResponse: {
functionResponse: { name: 'testTool',
name: 'testTool', id: 'call1',
id: 'call1', response: { output: 'Tool executed successfully' },
response: { output: 'Tool executed successfully' }, },
}, },
}); });
}); });
@ -98,23 +99,19 @@ describe('executeToolCall', () => {
abortController.signal, abortController.signal,
); );
expect(response.callId).toBe('call2'); expect(response).toStrictEqual({
expect(response.error).toBeInstanceOf(Error); callId: 'call2',
expect(response.error?.message).toBe( error: new Error('Tool "nonexistentTool" not found in registry.'),
'Tool "nonexistentTool" not found in registry.', errorType: ToolErrorType.TOOL_NOT_REGISTERED,
); resultDisplay: 'Tool "nonexistentTool" not found in registry.',
expect(response.resultDisplay).toBe( responseParts: {
'Tool "nonexistentTool" not found in registry.',
);
expect(response.responseParts).toEqual([
{
functionResponse: { functionResponse: {
name: 'nonexistentTool', name: 'nonexistentTool',
id: 'call2', id: 'call2',
response: { error: 'Tool "nonexistentTool" not found in registry.' }, response: { error: 'Tool "nonexistentTool" not found in registry.' },
}, },
}, },
]); });
}); });
it('should return an error if tool validation fails', async () => { it('should return an error if tool validation fails', async () => {
@ -125,24 +122,17 @@ describe('executeToolCall', () => {
isClientInitiated: false, isClientInitiated: false,
prompt_id: 'prompt-id-3', prompt_id: 'prompt-id-3',
}; };
const validationErrorResult: ToolResult = {
llmContent: 'Error: Invalid parameters',
returnDisplay: 'Invalid parameters',
error: {
message: 'Invalid parameters',
type: ToolErrorType.INVALID_TOOL_PARAMS,
},
};
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool); vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.spyOn(mockTool, 'validateBuildAndExecute').mockResolvedValue( vi.spyOn(mockTool, 'build').mockImplementation(() => {
validationErrorResult, throw new Error('Invalid parameters');
); });
const response = await executeToolCall( const response = await executeToolCall(
mockConfig, mockConfig,
request, request,
abortController.signal, abortController.signal,
); );
expect(response).toStrictEqual({ expect(response).toStrictEqual({
callId: 'call3', callId: 'call3',
error: new Error('Invalid parameters'), error: new Error('Invalid parameters'),
@ -152,7 +142,7 @@ describe('executeToolCall', () => {
id: 'call3', id: 'call3',
name: 'testTool', name: 'testTool',
response: { response: {
output: 'Error: Invalid parameters', error: 'Invalid parameters',
}, },
}, },
}, },
@ -177,9 +167,7 @@ describe('executeToolCall', () => {
}, },
}; };
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool); vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.spyOn(mockTool, 'validateBuildAndExecute').mockResolvedValue( mockTool.executeFn.mockReturnValue(executionErrorResult);
executionErrorResult,
);
const response = await executeToolCall( const response = await executeToolCall(
mockConfig, mockConfig,
@ -195,7 +183,7 @@ describe('executeToolCall', () => {
id: 'call4', id: 'call4',
name: 'testTool', name: 'testTool',
response: { response: {
output: 'Error: Execution failed', error: 'Execution failed',
}, },
}, },
}, },
@ -211,11 +199,10 @@ describe('executeToolCall', () => {
isClientInitiated: false, isClientInitiated: false,
prompt_id: 'prompt-id-5', prompt_id: 'prompt-id-5',
}; };
const executionError = new Error('Something went very wrong');
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool); vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.spyOn(mockTool, 'validateBuildAndExecute').mockRejectedValue( mockTool.executeFn.mockImplementation(() => {
executionError, throw new Error('Something went very wrong');
); });
const response = await executeToolCall( const response = await executeToolCall(
mockConfig, mockConfig,
@ -223,19 +210,19 @@ describe('executeToolCall', () => {
abortController.signal, abortController.signal,
); );
expect(response.callId).toBe('call5'); expect(response).toStrictEqual({
expect(response.error).toBe(executionError); callId: 'call5',
expect(response.errorType).toBe(ToolErrorType.UNHANDLED_EXCEPTION); error: new Error('Something went very wrong'),
expect(response.resultDisplay).toBe('Something went very wrong'); errorType: ToolErrorType.UNHANDLED_EXCEPTION,
expect(response.responseParts).toEqual([ resultDisplay: 'Something went very wrong',
{ responseParts: {
functionResponse: { functionResponse: {
name: 'testTool', name: 'testTool',
id: 'call5', id: 'call5',
response: { error: 'Something went very wrong' }, response: { error: 'Something went very wrong' },
}, },
}, },
]); });
}); });
it('should correctly format llmContent with inlineData', async () => { it('should correctly format llmContent with inlineData', async () => {
@ -254,7 +241,7 @@ describe('executeToolCall', () => {
returnDisplay: 'Image processed', returnDisplay: 'Image processed',
}; };
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool); vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.spyOn(mockTool, 'validateBuildAndExecute').mockResolvedValue(toolResult); mockTool.executeFn.mockReturnValue(toolResult);
const response = await executeToolCall( const response = await executeToolCall(
mockConfig, mockConfig,
@ -262,18 +249,23 @@ describe('executeToolCall', () => {
abortController.signal, abortController.signal,
); );
expect(response.resultDisplay).toBe('Image processed'); expect(response).toStrictEqual({
expect(response.responseParts).toEqual([ callId: 'call6',
{ error: undefined,
functionResponse: { errorType: undefined,
name: 'testTool', resultDisplay: 'Image processed',
id: 'call6', responseParts: [
response: { {
output: 'Binary content of type image/png was processed.', functionResponse: {
name: 'testTool',
id: 'call6',
response: {
output: 'Binary content of type image/png was processed.',
},
}, },
}, },
}, imageDataPart,
imageDataPart, ],
]); });
}); });
}); });

View File

@ -4,166 +4,27 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { import { ToolCallRequestInfo, ToolCallResponseInfo, Config } from '../index.js';
FileDiff, import { CoreToolScheduler } from './coreToolScheduler.js';
logToolCall,
ToolCallRequestInfo,
ToolCallResponseInfo,
ToolErrorType,
ToolResult,
} from '../index.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { Config } from '../config/config.js';
import { convertToFunctionResponse } from './coreToolScheduler.js';
import { ToolCallDecision } from '../telemetry/tool-call-decision.js';
/** /**
* Executes a single tool call non-interactively. * Executes a single tool call non-interactively by leveraging the CoreToolScheduler.
* It does not handle confirmations, multiple calls, or live updates.
*/ */
export async function executeToolCall( export async function executeToolCall(
config: Config, config: Config,
toolCallRequest: ToolCallRequestInfo, toolCallRequest: ToolCallRequestInfo,
abortSignal?: AbortSignal, abortSignal: AbortSignal,
): Promise<ToolCallResponseInfo> { ): Promise<ToolCallResponseInfo> {
const tool = config.getToolRegistry().getTool(toolCallRequest.name); return new Promise<ToolCallResponseInfo>((resolve, reject) => {
new CoreToolScheduler({
const startTime = Date.now(); config,
if (!tool) { getPreferredEditor: () => undefined,
const error = new Error( onEditorClose: () => {},
`Tool "${toolCallRequest.name}" not found in registry.`, onAllToolCallsComplete: async (completedToolCalls) => {
); resolve(completedToolCalls[0].response);
const durationMs = Date.now() - startTime; },
logToolCall(config, { })
'event.name': 'tool_call', .schedule(toolCallRequest, abortSignal)
'event.timestamp': new Date().toISOString(), .catch(reject);
function_name: toolCallRequest.name, });
function_args: toolCallRequest.args,
duration_ms: durationMs,
success: false,
error: error.message,
prompt_id: toolCallRequest.prompt_id,
tool_type: 'native',
});
// Ensure the response structure matches what the API expects for an error
return {
callId: toolCallRequest.callId,
responseParts: [
{
functionResponse: {
id: toolCallRequest.callId,
name: toolCallRequest.name,
response: { error: error.message },
},
},
],
resultDisplay: error.message,
error,
errorType: ToolErrorType.TOOL_NOT_REGISTERED,
};
}
try {
// Directly execute without confirmation or live output handling
const effectiveAbortSignal = abortSignal ?? new AbortController().signal;
const toolResult: ToolResult = await tool.validateBuildAndExecute(
toolCallRequest.args,
effectiveAbortSignal,
// No live output callback for non-interactive mode
);
const tool_output = toolResult.llmContent;
const tool_display = toolResult.returnDisplay;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let metadata: { [key: string]: any } = {};
if (
toolResult.error === undefined &&
typeof tool_display === 'object' &&
tool_display !== null &&
'diffStat' in tool_display
) {
const diffStat = (tool_display as FileDiff).diffStat;
if (diffStat) {
metadata = {
ai_added_lines: diffStat.ai_added_lines,
ai_removed_lines: diffStat.ai_removed_lines,
user_added_lines: diffStat.user_added_lines,
user_removed_lines: diffStat.user_removed_lines,
};
}
}
const durationMs = Date.now() - startTime;
logToolCall(config, {
'event.name': 'tool_call',
'event.timestamp': new Date().toISOString(),
function_name: toolCallRequest.name,
function_args: toolCallRequest.args,
duration_ms: durationMs,
success: toolResult.error === undefined,
error:
toolResult.error === undefined ? undefined : toolResult.error.message,
error_type:
toolResult.error === undefined ? undefined : toolResult.error.type,
prompt_id: toolCallRequest.prompt_id,
metadata,
decision: ToolCallDecision.AUTO_ACCEPT,
tool_type:
typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool
? 'mcp'
: 'native',
});
const response = convertToFunctionResponse(
toolCallRequest.name,
toolCallRequest.callId,
tool_output,
);
return {
callId: toolCallRequest.callId,
responseParts: response,
resultDisplay: tool_display,
error:
toolResult.error === undefined
? undefined
: new Error(toolResult.error.message),
errorType:
toolResult.error === undefined ? undefined : toolResult.error.type,
};
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
const durationMs = Date.now() - startTime;
logToolCall(config, {
'event.name': 'tool_call',
'event.timestamp': new Date().toISOString(),
function_name: toolCallRequest.name,
function_args: toolCallRequest.args,
duration_ms: durationMs,
success: false,
error: error.message,
error_type: ToolErrorType.UNHANDLED_EXCEPTION,
prompt_id: toolCallRequest.prompt_id,
tool_type:
typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool
? 'mcp'
: 'native',
});
return {
callId: toolCallRequest.callId,
responseParts: [
{
functionResponse: {
id: toolCallRequest.callId,
name: toolCallRequest.name,
response: { error: error.message },
},
},
],
resultDisplay: error.message,
error,
errorType: ToolErrorType.UNHANDLED_EXCEPTION,
};
}
} }

View File

@ -4,75 +4,224 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { describe, it, expect } from 'vitest'; import {
import * as path from 'path'; describe,
import { IdeClient } from './ide-client.js'; it,
expect,
vi,
beforeEach,
afterEach,
type Mocked,
} from 'vitest';
import { IdeClient, IDEConnectionStatus } from './ide-client.js';
import * as fs from 'node:fs';
import { getIdeProcessId } from './process-utils.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import {
detectIde,
DetectedIde,
getIdeInfo,
type IdeInfo,
} from './detect-ide.js';
import * as os from 'node:os';
import * as path from 'node:path';
describe('IdeClient.validateWorkspacePath', () => { vi.mock('node:fs', async (importOriginal) => {
it('should return valid if cwd is a subpath of the IDE workspace path', () => { const actual = await importOriginal();
const result = IdeClient.validateWorkspacePath( return {
'/Users/person/gemini-cli', ...(actual as object),
'VS Code', promises: {
'/Users/person/gemini-cli/sub-dir', readFile: vi.fn(),
); },
expect(result.isValid).toBe(true); realpathSync: (p: string) => p,
existsSync: () => false,
};
});
vi.mock('./process-utils.js');
vi.mock('@modelcontextprotocol/sdk/client/index.js');
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js');
vi.mock('@modelcontextprotocol/sdk/client/stdio.js');
vi.mock('./detect-ide.js');
vi.mock('node:os');
describe('IdeClient', () => {
let mockClient: Mocked<Client>;
let mockHttpTransport: Mocked<StreamableHTTPClientTransport>;
let mockStdioTransport: Mocked<StdioClientTransport>;
beforeEach(() => {
// Reset singleton instance for test isolation
(IdeClient as unknown as { instance: IdeClient | undefined }).instance =
undefined;
// Mock environment variables
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'] = '/test/workspace';
delete process.env['GEMINI_CLI_IDE_SERVER_PORT'];
delete process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'];
delete process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'];
// Mock dependencies
vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir');
vi.mocked(detectIde).mockReturnValue(DetectedIde.VSCode);
vi.mocked(getIdeInfo).mockReturnValue({
displayName: 'VS Code',
} as IdeInfo);
vi.mocked(getIdeProcessId).mockResolvedValue(12345);
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
// Mock MCP client and transports
mockClient = {
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn(),
setNotificationHandler: vi.fn(),
callTool: vi.fn(),
} as unknown as Mocked<Client>;
mockHttpTransport = {
close: vi.fn(),
} as unknown as Mocked<StreamableHTTPClientTransport>;
mockStdioTransport = {
close: vi.fn(),
} as unknown as Mocked<StdioClientTransport>;
vi.mocked(Client).mockReturnValue(mockClient);
vi.mocked(StreamableHTTPClientTransport).mockReturnValue(mockHttpTransport);
vi.mocked(StdioClientTransport).mockReturnValue(mockStdioTransport);
}); });
it('should return invalid if GEMINI_CLI_IDE_WORKSPACE_PATH is undefined', () => { afterEach(() => {
const result = IdeClient.validateWorkspacePath( vi.restoreAllMocks();
undefined,
'VS Code',
'/Users/person/gemini-cli/sub-dir',
);
expect(result.isValid).toBe(false);
expect(result.error).toContain('Failed to connect');
}); });
it('should return invalid if GEMINI_CLI_IDE_WORKSPACE_PATH is empty', () => { describe('connect', () => {
const result = IdeClient.validateWorkspacePath( it('should connect using HTTP when port is provided in config file', async () => {
'', const config = { port: '8080' };
'VS Code', vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
'/Users/person/gemini-cli/sub-dir',
);
expect(result.isValid).toBe(false);
expect(result.error).toContain('please open a workspace folder');
});
it('should return invalid if cwd is not within the IDE workspace path', () => { const ideClient = IdeClient.getInstance();
const result = IdeClient.validateWorkspacePath( await ideClient.connect();
'/some/other/path',
'VS Code',
'/Users/person/gemini-cli/sub-dir',
);
expect(result.isValid).toBe(false);
expect(result.error).toContain('Directory mismatch');
});
it('should handle multiple workspace paths and return valid', () => { expect(fs.promises.readFile).toHaveBeenCalledWith(
const result = IdeClient.validateWorkspacePath( path.join('/tmp', 'gemini-ide-server-12345.json'),
['/some/other/path', '/Users/person/gemini-cli'].join(path.delimiter), 'utf8',
'VS Code', );
'/Users/person/gemini-cli/sub-dir', expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
); new URL('http://localhost:8080/mcp'),
expect(result.isValid).toBe(true); expect.any(Object),
}); );
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
});
it('should return invalid if cwd is not in any of the multiple workspace paths', () => { it('should connect using stdio when stdio config is provided in file', async () => {
const result = IdeClient.validateWorkspacePath( const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
['/some/other/path', '/another/path'].join(path.delimiter), vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
'VS Code',
'/Users/person/gemini-cli/sub-dir',
);
expect(result.isValid).toBe(false);
expect(result.error).toContain('Directory mismatch');
});
it.skipIf(process.platform !== 'win32')('should handle windows paths', () => { const ideClient = IdeClient.getInstance();
const result = IdeClient.validateWorkspacePath( await ideClient.connect();
'c:/some/other/path;d:/Users/person/gemini-cli',
'VS Code', expect(StdioClientTransport).toHaveBeenCalledWith({
'd:/Users/person/gemini-cli/sub-dir', command: 'test-cmd',
); args: ['--foo'],
expect(result.isValid).toBe(true); });
expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
});
it('should prioritize port over stdio when both are in config file', async () => {
const config = {
port: '8080',
stdio: { command: 'test-cmd', args: ['--foo'] },
};
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
const ideClient = IdeClient.getInstance();
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalled();
expect(StdioClientTransport).not.toHaveBeenCalled();
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
});
it('should connect using HTTP when port is provided in environment variables', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(
new Error('File not found'),
);
process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090';
const ideClient = IdeClient.getInstance();
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:9090/mcp'),
expect.any(Object),
);
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
});
it('should connect using stdio when stdio config is in environment variables', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(
new Error('File not found'),
);
process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'] = 'env-cmd';
process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'] = '["--bar"]';
const ideClient = IdeClient.getInstance();
await ideClient.connect();
expect(StdioClientTransport).toHaveBeenCalledWith({
command: 'env-cmd',
args: ['--bar'],
});
expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
});
it('should prioritize file config over environment variables', async () => {
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090';
const ideClient = IdeClient.getInstance();
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:8080/mcp'),
expect.any(Object),
);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
});
it('should be disconnected if no config is found', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(
new Error('File not found'),
);
const ideClient = IdeClient.getInstance();
await ideClient.connect();
expect(StreamableHTTPClientTransport).not.toHaveBeenCalled();
expect(StdioClientTransport).not.toHaveBeenCalled();
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Disconnected,
);
expect(ideClient.getConnectionStatus().details).toContain(
'Failed to connect',
);
});
}); });
}); });

View File

@ -18,6 +18,7 @@ import {
import { getIdeProcessId } from './process-utils.js'; import { getIdeProcessId } from './process-utils.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import * as os from 'node:os'; import * as os from 'node:os';
import * as path from 'node:path'; import * as path from 'node:path';
import { EnvHttpProxyAgent } from 'undici'; import { EnvHttpProxyAgent } from 'undici';
@ -40,6 +41,16 @@ export enum IDEConnectionStatus {
Connecting = 'connecting', Connecting = 'connecting',
} }
type StdioConfig = {
command: string;
args: string[];
};
type ConnectionConfig = {
port?: string;
stdio?: StdioConfig;
};
function getRealPath(path: string): string { function getRealPath(path: string): string {
try { try {
return fs.realpathSync(path); return fs.realpathSync(path);
@ -104,9 +115,9 @@ export class IdeClient {
this.setState(IDEConnectionStatus.Connecting); this.setState(IDEConnectionStatus.Connecting);
const ideInfoFromFile = await this.getIdeInfoFromFile(); const configFromFile = await this.getConnectionConfigFromFile();
const workspacePath = const workspacePath =
ideInfoFromFile.workspacePath ?? configFromFile?.workspacePath ??
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
const { isValid, error } = IdeClient.validateWorkspacePath( const { isValid, error } = IdeClient.validateWorkspacePath(
@ -120,17 +131,36 @@ export class IdeClient {
return; return;
} }
const portFromFile = ideInfoFromFile.port; if (configFromFile) {
if (portFromFile) { if (configFromFile.port) {
const connected = await this.establishConnection(portFromFile); const connected = await this.establishHttpConnection(
if (connected) { configFromFile.port,
return; );
if (connected) {
return;
}
}
if (configFromFile.stdio) {
const connected = await this.establishStdioConnection(
configFromFile.stdio,
);
if (connected) {
return;
}
} }
} }
const portFromEnv = this.getPortFromEnv(); const portFromEnv = this.getPortFromEnv();
if (portFromEnv) { if (portFromEnv) {
const connected = await this.establishConnection(portFromEnv); const connected = await this.establishHttpConnection(portFromEnv);
if (connected) {
return;
}
}
const stdioConfigFromEnv = this.getStdioConfigFromEnv();
if (stdioConfigFromEnv) {
const connected = await this.establishStdioConnection(stdioConfigFromEnv);
if (connected) { if (connected) {
return; return;
} }
@ -316,10 +346,35 @@ export class IdeClient {
return port; return port;
} }
private async getIdeInfoFromFile(): Promise<{ private getStdioConfigFromEnv(): StdioConfig | undefined {
port?: string; const command = process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'];
workspacePath?: string; if (!command) {
}> { return undefined;
}
const argsStr = process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'];
let args: string[] = [];
if (argsStr) {
try {
const parsedArgs = JSON.parse(argsStr);
if (Array.isArray(parsedArgs)) {
args = parsedArgs;
} else {
logger.error(
'GEMINI_CLI_IDE_SERVER_STDIO_ARGS must be a JSON array string.',
);
}
} catch (e) {
logger.error('Failed to parse GEMINI_CLI_IDE_SERVER_STDIO_ARGS:', e);
}
}
return { command, args };
}
private async getConnectionConfigFromFile(): Promise<
(ConnectionConfig & { workspacePath?: string }) | undefined
> {
try { try {
const ideProcessId = await getIdeProcessId(); const ideProcessId = await getIdeProcessId();
const portFile = path.join( const portFile = path.join(
@ -327,13 +382,9 @@ export class IdeClient {
`gemini-ide-server-${ideProcessId}.json`, `gemini-ide-server-${ideProcessId}.json`,
); );
const portFileContents = await fs.promises.readFile(portFile, 'utf8'); const portFileContents = await fs.promises.readFile(portFile, 'utf8');
const ideInfo = JSON.parse(portFileContents); return JSON.parse(portFileContents);
return {
port: ideInfo?.port?.toString(),
workspacePath: ideInfo?.workspacePath,
};
} catch (_) { } catch (_) {
return {}; return undefined;
} }
} }
@ -414,9 +465,10 @@ export class IdeClient {
); );
} }
private async establishConnection(port: string): Promise<boolean> { private async establishHttpConnection(port: string): Promise<boolean> {
let transport: StreamableHTTPClientTransport | undefined; let transport: StreamableHTTPClientTransport | undefined;
try { try {
logger.debug('Attempting to connect to IDE via HTTP SSE');
this.client = new Client({ this.client = new Client({
name: 'streamable-http-client', name: 'streamable-http-client',
// TODO(#3487): use the CLI version here. // TODO(#3487): use the CLI version here.
@ -443,6 +495,39 @@ export class IdeClient {
return false; return false;
} }
} }
private async establishStdioConnection({
command,
args,
}: StdioConfig): Promise<boolean> {
let transport: StdioClientTransport | undefined;
try {
logger.debug('Attempting to connect to IDE via stdio');
this.client = new Client({
name: 'stdio-client',
// TODO(#3487): use the CLI version here.
version: '1.0.0',
});
transport = new StdioClientTransport({
command,
args,
});
await this.client.connect(transport);
this.registerClientHandlers();
this.setState(IDEConnectionStatus.Connected);
return true;
} catch (_error) {
if (transport) {
try {
await transport.close();
} catch (closeError) {
logger.debug('Failed to close transport:', closeError);
}
}
return false;
}
}
} }
function getIdeServerHost() { function getIdeServerHost() {

View File

@ -9,10 +9,14 @@ import { partListUnionToString } from '../core/geminiRequest.js';
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import os from 'os'; import os from 'os';
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { ToolErrorType } from './tool-error.js';
import * as glob from 'glob';
vi.mock('glob', { spy: true });
describe('GlobTool', () => { describe('GlobTool', () => {
let tempRootDir: string; // This will be the rootDirectory for the GlobTool instance let tempRootDir: string; // This will be the rootDirectory for the GlobTool instance
@ -203,6 +207,29 @@ describe('GlobTool', () => {
path.resolve(tempRootDir, 'older.sortme'), path.resolve(tempRootDir, 'older.sortme'),
); );
}); });
it('should return a PATH_NOT_IN_WORKSPACE error if path is outside workspace', async () => {
// Bypassing validation to test execute method directly
vi.spyOn(globTool, 'validateToolParams').mockReturnValue(null);
const params: GlobToolParams = { pattern: '*.txt', path: '/etc' };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.PATH_NOT_IN_WORKSPACE);
expect(result.returnDisplay).toBe('Path is not within workspace');
});
it('should return a GLOB_EXECUTION_ERROR on glob failure', async () => {
vi.mocked(glob.glob).mockRejectedValue(new Error('Glob failed'));
const params: GlobToolParams = { pattern: '*.txt' };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.GLOB_EXECUTION_ERROR);
expect(result.llmContent).toContain(
'Error during glob search operation: Glob failed',
);
// Reset glob.
vi.mocked(glob.glob).mockReset();
});
}); });
describe('validateToolParams', () => { describe('validateToolParams', () => {

View File

@ -16,6 +16,7 @@ import {
} from './tools.js'; } from './tools.js';
import { shortenPath, makeRelative } from '../utils/paths.js'; import { shortenPath, makeRelative } from '../utils/paths.js';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
import { ToolErrorType } from './tool-error.js';
// Subset of 'Path' interface provided by 'glob' that we can implement for testing // Subset of 'Path' interface provided by 'glob' that we can implement for testing
export interface GlobPath { export interface GlobPath {
@ -115,9 +116,14 @@ class GlobToolInvocation extends BaseToolInvocation<
this.params.path, this.params.path,
); );
if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) { if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) {
const rawError = `Error: Path "${this.params.path}" is not within any workspace directory`;
return { return {
llmContent: `Error: Path "${this.params.path}" is not within any workspace directory`, llmContent: rawError,
returnDisplay: `Path is not within workspace`, returnDisplay: `Path is not within workspace`,
error: {
message: rawError,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
}; };
} }
searchDirectories = [searchDirAbsolute]; searchDirectories = [searchDirAbsolute];
@ -234,9 +240,14 @@ class GlobToolInvocation extends BaseToolInvocation<
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
console.error(`GlobLogic execute Error: ${errorMessage}`, error); console.error(`GlobLogic execute Error: ${errorMessage}`, error);
const rawError = `Error during glob search operation: ${errorMessage}`;
return { return {
llmContent: `Error during glob search operation: ${errorMessage}`, llmContent: rawError,
returnDisplay: `Error: An unexpected error occurred.`, returnDisplay: `Error: An unexpected error occurred.`,
error: {
message: rawError,
type: ToolErrorType.GLOB_EXECUTION_ERROR,
},
}; };
} }
} }

View File

@ -11,6 +11,10 @@ import fs from 'fs/promises';
import os from 'os'; import os from 'os';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { ToolErrorType } from './tool-error.js';
import * as glob from 'glob';
vi.mock('glob', { spy: true });
// Mock the child_process module to control grep/git grep behavior // Mock the child_process module to control grep/git grep behavior
vi.mock('child_process', () => ({ vi.mock('child_process', () => ({
@ -223,6 +227,15 @@ describe('GrepTool', () => {
/params must have required property 'pattern'/, /params must have required property 'pattern'/,
); );
}); });
it('should return a GREP_EXECUTION_ERROR on failure', async () => {
vi.mocked(glob.globStream).mockRejectedValue(new Error('Glob failed'));
const params: GrepToolParams = { pattern: 'hello' };
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.GREP_EXECUTION_ERROR);
vi.mocked(glob.globStream).mockReset();
});
}); });
describe('multi-directory workspace', () => { describe('multi-directory workspace', () => {

View File

@ -21,6 +21,7 @@ import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { isGitRepository } from '../utils/gitUtils.js'; import { isGitRepository } from '../utils/gitUtils.js';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
import { ToolErrorType } from './tool-error.js';
// --- Interfaces --- // --- Interfaces ---
@ -198,6 +199,10 @@ class GrepToolInvocation extends BaseToolInvocation<
return { return {
llmContent: `Error during grep search operation: ${errorMessage}`, llmContent: `Error during grep search operation: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`, returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.GREP_EXECUTION_ERROR,
},
}; };
} }
} }

View File

@ -23,6 +23,7 @@ import { LSTool } from './ls.js';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
import { WorkspaceContext } from '../utils/workspaceContext.js'; import { WorkspaceContext } from '../utils/workspaceContext.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { ToolErrorType } from './tool-error.js';
describe('LSTool', () => { describe('LSTool', () => {
let lsTool: LSTool; let lsTool: LSTool;
@ -288,6 +289,7 @@ describe('LSTool', () => {
expect(result.llmContent).toContain('Path is not a directory'); expect(result.llmContent).toContain('Path is not a directory');
expect(result.returnDisplay).toBe('Error: Path is not a directory.'); expect(result.returnDisplay).toBe('Error: Path is not a directory.');
expect(result.error?.type).toBe(ToolErrorType.PATH_IS_NOT_A_DIRECTORY);
}); });
it('should handle non-existent paths', async () => { it('should handle non-existent paths', async () => {
@ -302,6 +304,7 @@ describe('LSTool', () => {
expect(result.llmContent).toContain('Error listing directory'); expect(result.llmContent).toContain('Error listing directory');
expect(result.returnDisplay).toBe('Error: Failed to list directory.'); expect(result.returnDisplay).toBe('Error: Failed to list directory.');
expect(result.error?.type).toBe(ToolErrorType.LS_EXECUTION_ERROR);
}); });
it('should sort directories first, then files alphabetically', async () => { it('should sort directories first, then files alphabetically', async () => {
@ -357,6 +360,7 @@ describe('LSTool', () => {
expect(result.llmContent).toContain('Error listing directory'); expect(result.llmContent).toContain('Error listing directory');
expect(result.llmContent).toContain('permission denied'); expect(result.llmContent).toContain('permission denied');
expect(result.returnDisplay).toBe('Error: Failed to list directory.'); expect(result.returnDisplay).toBe('Error: Failed to list directory.');
expect(result.error?.type).toBe(ToolErrorType.LS_EXECUTION_ERROR);
}); });
it('should throw for invalid params at build time', async () => { it('should throw for invalid params at build time', async () => {

View File

@ -15,6 +15,7 @@ import {
} from './tools.js'; } from './tools.js';
import { makeRelative, shortenPath } from '../utils/paths.js'; import { makeRelative, shortenPath } from '../utils/paths.js';
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js'; import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
import { ToolErrorType } from './tool-error.js';
/** /**
* Parameters for the LS tool * Parameters for the LS tool
@ -114,11 +115,19 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
} }
// Helper for consistent error formatting // Helper for consistent error formatting
private errorResult(llmContent: string, returnDisplay: string): ToolResult { private errorResult(
llmContent: string,
returnDisplay: string,
type: ToolErrorType,
): ToolResult {
return { return {
llmContent, llmContent,
// Keep returnDisplay simpler in core logic // Keep returnDisplay simpler in core logic
returnDisplay: `Error: ${returnDisplay}`, returnDisplay: `Error: ${returnDisplay}`,
error: {
message: llmContent,
type,
},
}; };
} }
@ -135,12 +144,14 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
return this.errorResult( return this.errorResult(
`Error: Directory not found or inaccessible: ${this.params.path}`, `Error: Directory not found or inaccessible: ${this.params.path}`,
`Directory not found or inaccessible.`, `Directory not found or inaccessible.`,
ToolErrorType.FILE_NOT_FOUND,
); );
} }
if (!stats.isDirectory()) { if (!stats.isDirectory()) {
return this.errorResult( return this.errorResult(
`Error: Path is not a directory: ${this.params.path}`, `Error: Path is not a directory: ${this.params.path}`,
`Path is not a directory.`, `Path is not a directory.`,
ToolErrorType.PATH_IS_NOT_A_DIRECTORY,
); );
} }
@ -253,7 +264,11 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
}; };
} catch (error) { } catch (error) {
const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`; const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`;
return this.errorResult(errorMsg, 'Failed to list directory.'); return this.errorResult(
errorMsg,
'Failed to list directory.',
ToolErrorType.LS_EXECUTION_ERROR,
);
} }
} }
} }

View File

@ -17,6 +17,7 @@ import {
import { DiscoveredMCPTool, generateValidName } from './mcp-tool.js'; // Added getStringifiedResultForDisplay import { DiscoveredMCPTool, generateValidName } from './mcp-tool.js'; // Added getStringifiedResultForDisplay
import { ToolResult, ToolConfirmationOutcome } from './tools.js'; // Added ToolConfirmationOutcome import { ToolResult, ToolConfirmationOutcome } from './tools.js'; // Added ToolConfirmationOutcome
import { CallableTool, Part } from '@google/genai'; import { CallableTool, Part } from '@google/genai';
import { ToolErrorType } from './tool-error.js';
// Mock @google/genai mcpToTool and CallableTool // Mock @google/genai mcpToTool and CallableTool
// We only need to mock the parts of CallableTool that DiscoveredMCPTool uses. // We only need to mock the parts of CallableTool that DiscoveredMCPTool uses.
@ -189,7 +190,7 @@ describe('DiscoveredMCPTool', () => {
{ isErrorValue: true, description: 'true (bool)' }, { isErrorValue: true, description: 'true (bool)' },
{ isErrorValue: 'true', description: '"true" (str)' }, { isErrorValue: 'true', description: '"true" (str)' },
])( ])(
'should consider a ToolResult with isError $description to be a failure', 'should return a structured error if MCP tool reports an error',
async ({ isErrorValue }) => { async ({ isErrorValue }) => {
const tool = new DiscoveredMCPTool( const tool = new DiscoveredMCPTool(
mockCallableToolInstance, mockCallableToolInstance,
@ -210,16 +211,18 @@ describe('DiscoveredMCPTool', () => {
}, },
]; ];
mockCallTool.mockResolvedValue(mockMcpToolResponseParts); mockCallTool.mockResolvedValue(mockMcpToolResponseParts);
const expectedError = new Error( const expectedErrorMessage = `MCP tool '${serverToolName}' reported tool error with response: ${JSON.stringify(
`MCP tool '${serverToolName}' reported tool error with response: ${JSON.stringify( mockMcpToolResponseParts,
mockMcpToolResponseParts, )}`;
)}`,
);
const invocation = tool.build(params); const invocation = tool.build(params);
await expect( const result = await invocation.execute(new AbortController().signal);
invocation.execute(new AbortController().signal),
).rejects.toThrow(expectedError); expect(result.error?.type).toBe(ToolErrorType.MCP_TOOL_ERROR);
expect(result.llmContent).toBe(expectedErrorMessage);
expect(result.returnDisplay).toContain(
`Error: MCP tool '${serverToolName}' reported an error.`,
);
}, },
); );

View File

@ -16,6 +16,7 @@ import {
ToolResult, ToolResult,
} from './tools.js'; } from './tools.js';
import { CallableTool, FunctionCall, Part } from '@google/genai'; import { CallableTool, FunctionCall, Part } from '@google/genai';
import { ToolErrorType } from './tool-error.js';
type ToolParams = Record<string, unknown>; type ToolParams = Record<string, unknown>;
@ -139,9 +140,19 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
// Ensure the response is not an error // Ensure the response is not an error
if (this.isMCPToolError(rawResponseParts)) { if (this.isMCPToolError(rawResponseParts)) {
throw new Error( const errorMessage = `MCP tool '${
`MCP tool '${this.serverToolName}' reported tool error with response: ${JSON.stringify(rawResponseParts)}`, this.serverToolName
); }' reported tool error with response: ${JSON.stringify(
rawResponseParts,
)}`;
return {
llmContent: errorMessage,
returnDisplay: `Error: MCP tool '${this.serverToolName}' reported an error.`,
error: {
message: errorMessage,
type: ToolErrorType.MCP_TOOL_ERROR,
},
};
} }
const transformedParts = transformMcpContentToParts(rawResponseParts); const transformedParts = transformMcpContentToParts(rawResponseParts);

View File

@ -16,6 +16,7 @@ import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import { ToolConfirmationOutcome } from './tools.js'; import { ToolConfirmationOutcome } from './tools.js';
import { ToolErrorType } from './tool-error.js';
// Mock dependencies // Mock dependencies
vi.mock(import('fs/promises'), async (importOriginal) => { vi.mock(import('fs/promises'), async (importOriginal) => {
@ -287,6 +288,9 @@ describe('MemoryTool', () => {
expect(result.returnDisplay).toBe( expect(result.returnDisplay).toBe(
`Error saving memory: ${underlyingError.message}`, `Error saving memory: ${underlyingError.message}`,
); );
expect(result.error?.type).toBe(
ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
);
}); });
}); });

View File

@ -20,6 +20,7 @@ import * as Diff from 'diff';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
import { tildeifyPath } from '../utils/paths.js'; import { tildeifyPath } from '../utils/paths.js';
import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js'; import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js';
import { ToolErrorType } from './tool-error.js';
const memoryToolSchemaData: FunctionDeclaration = { const memoryToolSchemaData: FunctionDeclaration = {
name: 'save_memory', name: 'save_memory',
@ -273,6 +274,10 @@ class MemoryToolInvocation extends BaseToolInvocation<
error: `Failed to save memory. Detail: ${errorMessage}`, error: `Failed to save memory. Detail: ${errorMessage}`,
}), }),
returnDisplay: `Error saving memory: ${errorMessage}`, returnDisplay: `Error saving memory: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
},
}; };
} }
} }

View File

@ -15,6 +15,10 @@ import os from 'os';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
import { WorkspaceContext } from '../utils/workspaceContext.js'; import { WorkspaceContext } from '../utils/workspaceContext.js';
import { StandardFileSystemService } from '../services/fileSystemService.js'; import { StandardFileSystemService } from '../services/fileSystemService.js';
import { ToolErrorType } from './tool-error.js';
import * as glob from 'glob';
vi.mock('glob', { spy: true });
vi.mock('mime-types', () => { vi.mock('mime-types', () => {
const lookup = (filename: string) => { const lookup = (filename: string) => {
@ -566,6 +570,28 @@ Content of file[1]
}); });
}); });
describe('Error handling', () => {
it('should return an INVALID_TOOL_PARAMS error if no paths are provided', async () => {
const params = { paths: [], include: [] };
expect(() => {
tool.build(params);
}).toThrow('params/paths must NOT have fewer than 1 items');
});
it('should return a READ_MANY_FILES_SEARCH_ERROR on glob failure', async () => {
vi.mocked(glob.glob).mockRejectedValue(new Error('Glob failed'));
const params = { paths: ['*.txt'] };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(
ToolErrorType.READ_MANY_FILES_SEARCH_ERROR,
);
expect(result.llmContent).toBe('Error during file search: Glob failed');
// Reset glob.
vi.mocked(glob.glob).mockReset();
});
});
describe('Batch Processing', () => { describe('Batch Processing', () => {
const createMultipleFiles = (count: number, contentPrefix = 'Content') => { const createMultipleFiles = (count: number, contentPrefix = 'Content') => {
const files: string[] = []; const files: string[] = [];

View File

@ -29,6 +29,7 @@ import {
recordFileOperationMetric, recordFileOperationMetric,
FileOperation, FileOperation,
} from '../telemetry/metrics.js'; } from '../telemetry/metrics.js';
import { ToolErrorType } from './tool-error.js';
/** /**
* Parameters for the ReadManyFilesTool. * Parameters for the ReadManyFilesTool.
@ -232,13 +233,6 @@ ${finalExclusionPatternsForDescription
: [...exclude]; : [...exclude];
const searchPatterns = [...inputPatterns, ...include]; const searchPatterns = [...inputPatterns, ...include];
if (searchPatterns.length === 0) {
return {
llmContent: 'No search paths or include patterns provided.',
returnDisplay: `## Information\n\nNo search paths or include patterns were specified. Nothing to read or concatenate.`,
};
}
try { try {
const allEntries = new Set<string>(); const allEntries = new Set<string>();
const workspaceDirs = this.config.getWorkspaceContext().getDirectories(); const workspaceDirs = this.config.getWorkspaceContext().getDirectories();
@ -352,9 +346,14 @@ ${finalExclusionPatternsForDescription
}); });
} }
} catch (error) { } catch (error) {
const errorMessage = `Error during file search: ${getErrorMessage(error)}`;
return { return {
llmContent: `Error during file search: ${getErrorMessage(error)}`, llmContent: errorMessage,
returnDisplay: `## File Search Error\n\nAn error occurred while searching for files:\n\`\`\`\n${getErrorMessage(error)}\n\`\`\``, returnDisplay: `## File Search Error\n\nAn error occurred while searching for files:\n\`\`\`\n${getErrorMessage(error)}\n\`\`\``,
error: {
message: errorMessage,
type: ToolErrorType.READ_MANY_FILES_SEARCH_ERROR,
},
}; };
} }

View File

@ -24,10 +24,43 @@ export enum ToolErrorType {
PERMISSION_DENIED = 'permission_denied', PERMISSION_DENIED = 'permission_denied',
NO_SPACE_LEFT = 'no_space_left', NO_SPACE_LEFT = 'no_space_left',
TARGET_IS_DIRECTORY = 'target_is_directory', TARGET_IS_DIRECTORY = 'target_is_directory',
PATH_NOT_IN_WORKSPACE = 'path_not_in_workspace',
SEARCH_PATH_NOT_FOUND = 'search_path_not_found',
SEARCH_PATH_NOT_A_DIRECTORY = 'search_path_not_a_directory',
// Edit-specific Errors // Edit-specific Errors
EDIT_PREPARATION_FAILURE = 'edit_preparation_failure', EDIT_PREPARATION_FAILURE = 'edit_preparation_failure',
EDIT_NO_OCCURRENCE_FOUND = 'edit_no_occurrence_found', EDIT_NO_OCCURRENCE_FOUND = 'edit_no_occurrence_found',
EDIT_EXPECTED_OCCURRENCE_MISMATCH = 'edit_expected_occurrence_mismatch', EDIT_EXPECTED_OCCURRENCE_MISMATCH = 'edit_expected_occurrence_mismatch',
EDIT_NO_CHANGE = 'edit_no_change', EDIT_NO_CHANGE = 'edit_no_change',
// Glob-specific Errors
GLOB_EXECUTION_ERROR = 'glob_execution_error',
// Grep-specific Errors
GREP_EXECUTION_ERROR = 'grep_execution_error',
// Ls-specific Errors
LS_EXECUTION_ERROR = 'ls_execution_error',
PATH_IS_NOT_A_DIRECTORY = 'path_is_not_a_directory',
// MCP-specific Errors
MCP_TOOL_ERROR = 'mcp_tool_error',
// Memory-specific Errors
MEMORY_TOOL_EXECUTION_ERROR = 'memory_tool_execution_error',
// ReadManyFiles-specific Errors
READ_MANY_FILES_SEARCH_ERROR = 'read_many_files_search_error',
// DiscoveredTool-specific Errors
DISCOVERED_TOOL_EXECUTION_ERROR = 'discovered_tool_execution_error',
// WebFetch-specific Errors
WEB_FETCH_NO_URL_IN_PROMPT = 'web_fetch_no_url_in_prompt',
WEB_FETCH_FALLBACK_FAILED = 'web_fetch_fallback_failed',
WEB_FETCH_PROCESSING_ERROR = 'web_fetch_processing_error',
// WebSearch-specific Errors
WEB_SEARCH_FAILED = 'web_search_failed',
} }

View File

@ -24,6 +24,7 @@ import fs from 'node:fs';
import { MockTool } from '../test-utils/tools.js'; import { MockTool } from '../test-utils/tools.js';
import { McpClientManager } from './mcp-client-manager.js'; import { McpClientManager } from './mcp-client-manager.js';
import { ToolErrorType } from './tool-error.js';
vi.mock('node:fs'); vi.mock('node:fs');
@ -311,6 +312,81 @@ describe('ToolRegistry', () => {
}); });
}); });
it('should return a DISCOVERED_TOOL_EXECUTION_ERROR on tool failure', async () => {
const discoveryCommand = 'my-discovery-command';
mockConfigGetToolDiscoveryCommand.mockReturnValue(discoveryCommand);
vi.spyOn(config, 'getToolCallCommand').mockReturnValue('my-call-command');
const toolDeclaration: FunctionDeclaration = {
name: 'failing-tool',
description: 'A tool that fails',
parametersJsonSchema: {
type: 'object',
properties: {},
},
};
const mockSpawn = vi.mocked(spawn);
// --- Discovery Mock ---
const discoveryProcess = {
stdout: { on: vi.fn(), removeListener: vi.fn() },
stderr: { on: vi.fn(), removeListener: vi.fn() },
on: vi.fn(),
};
mockSpawn.mockReturnValueOnce(discoveryProcess as any);
discoveryProcess.stdout.on.mockImplementation((event, callback) => {
if (event === 'data') {
callback(
Buffer.from(
JSON.stringify([{ functionDeclarations: [toolDeclaration] }]),
),
);
}
});
discoveryProcess.on.mockImplementation((event, callback) => {
if (event === 'close') {
callback(0);
}
});
await toolRegistry.discoverAllTools();
const discoveredTool = toolRegistry.getTool('failing-tool');
expect(discoveredTool).toBeDefined();
// --- Execution Mock ---
const executionProcess = {
stdout: { on: vi.fn(), removeListener: vi.fn() },
stderr: { on: vi.fn(), removeListener: vi.fn() },
stdin: { write: vi.fn(), end: vi.fn() },
on: vi.fn(),
connected: true,
disconnect: vi.fn(),
removeListener: vi.fn(),
};
mockSpawn.mockReturnValueOnce(executionProcess as any);
executionProcess.stderr.on.mockImplementation((event, callback) => {
if (event === 'data') {
callback(Buffer.from('Something went wrong'));
}
});
executionProcess.on.mockImplementation((event, callback) => {
if (event === 'close') {
callback(1); // Non-zero exit code
}
});
const invocation = (discoveredTool as DiscoveredTool).build({});
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(
ToolErrorType.DISCOVERED_TOOL_EXECUTION_ERROR,
);
expect(result.llmContent).toContain('Stderr: Something went wrong');
expect(result.llmContent).toContain('Exit Code: 1');
});
it('should discover tools using MCP servers defined in getMcpServers', async () => { it('should discover tools using MCP servers defined in getMcpServers', async () => {
const discoverSpy = vi.spyOn( const discoverSpy = vi.spyOn(
McpClientManager.prototype, McpClientManager.prototype,

View File

@ -20,6 +20,7 @@ import { connectAndDiscover } from './mcp-client.js';
import { McpClientManager } from './mcp-client-manager.js'; import { McpClientManager } from './mcp-client-manager.js';
import { DiscoveredMCPTool } from './mcp-tool.js'; import { DiscoveredMCPTool } from './mcp-tool.js';
import { parse } from 'shell-quote'; import { parse } from 'shell-quote';
import { ToolErrorType } from './tool-error.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js'; import { safeJsonStringify } from '../utils/safeJsonStringify.js';
type ToolParams = Record<string, unknown>; type ToolParams = Record<string, unknown>;
@ -106,6 +107,10 @@ class DiscoveredToolInvocation extends BaseToolInvocation<
return { return {
llmContent, llmContent,
returnDisplay: llmContent, returnDisplay: llmContent,
error: {
message: llmContent,
type: ToolErrorType.DISCOVERED_TOOL_EXECUTION_ERROR,
},
}; };
} }

View File

@ -4,17 +4,72 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WebFetchTool } from './web-fetch.js'; import { WebFetchTool } from './web-fetch.js';
import { Config, ApprovalMode } from '../config/config.js'; import { Config, ApprovalMode } from '../config/config.js';
import { ToolConfirmationOutcome } from './tools.js'; import { ToolConfirmationOutcome } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import * as fetchUtils from '../utils/fetch.js';
const mockGenerateContent = vi.fn();
const mockGetGeminiClient = vi.fn(() => ({
generateContent: mockGenerateContent,
}));
vi.mock('../utils/fetch.js', async (importOriginal) => {
const actual = await importOriginal<typeof fetchUtils>();
return {
...actual,
fetchWithTimeout: vi.fn(),
isPrivateIp: vi.fn(),
};
});
describe('WebFetchTool', () => { describe('WebFetchTool', () => {
const mockConfig = { let mockConfig: Config;
getApprovalMode: vi.fn(),
setApprovalMode: vi.fn(), beforeEach(() => {
getProxy: vi.fn(), vi.resetAllMocks();
} as unknown as Config; mockConfig = {
getApprovalMode: vi.fn(),
setApprovalMode: vi.fn(),
getProxy: vi.fn(),
getGeminiClient: mockGetGeminiClient,
} as unknown as Config;
});
describe('execute', () => {
it('should return WEB_FETCH_NO_URL_IN_PROMPT when no URL is in the prompt for fallback', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true);
const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'no url here' };
expect(() => tool.build(params)).toThrow(
"The 'prompt' must contain at least one valid URL (starting with http:// or https://).",
);
});
it('should return WEB_FETCH_FALLBACK_FAILED on fallback fetch failure', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true);
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue(
new Error('fetch failed'),
);
const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'fetch https://private.ip' };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_FALLBACK_FAILED);
});
it('should return WEB_FETCH_PROCESSING_ERROR on general processing failure', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
mockGenerateContent.mockRejectedValue(new Error('API error'));
const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'fetch https://public.ip' };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_PROCESSING_ERROR);
});
});
describe('shouldConfirmExecute', () => { describe('shouldConfirmExecute', () => {
it('should return confirmation details with the correct prompt and urls', async () => { it('should return confirmation details with the correct prompt and urls', async () => {
@ -58,10 +113,10 @@ describe('WebFetchTool', () => {
}); });
it('should return false if approval mode is AUTO_EDIT', async () => { it('should return false if approval mode is AUTO_EDIT', async () => {
const tool = new WebFetchTool({ vi.spyOn(mockConfig, 'getApprovalMode').mockReturnValue(
...mockConfig, ApprovalMode.AUTO_EDIT,
getApprovalMode: () => ApprovalMode.AUTO_EDIT, );
} as unknown as Config); const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'fetch https://example.com' }; const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params); const invocation = tool.build(params);
const confirmationDetails = await invocation.shouldConfirmExecute( const confirmationDetails = await invocation.shouldConfirmExecute(
@ -72,11 +127,7 @@ describe('WebFetchTool', () => {
}); });
it('should call setApprovalMode when onConfirm is called with ProceedAlways', async () => { it('should call setApprovalMode when onConfirm is called with ProceedAlways', async () => {
const setApprovalMode = vi.fn(); const tool = new WebFetchTool(mockConfig);
const tool = new WebFetchTool({
...mockConfig,
setApprovalMode,
} as unknown as Config);
const params = { prompt: 'fetch https://example.com' }; const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params); const invocation = tool.build(params);
const confirmationDetails = await invocation.shouldConfirmExecute( const confirmationDetails = await invocation.shouldConfirmExecute(
@ -93,7 +144,9 @@ describe('WebFetchTool', () => {
); );
} }
expect(setApprovalMode).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT); expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
}); });
}); });
}); });

View File

@ -13,6 +13,7 @@ import {
ToolInvocation, ToolInvocation,
ToolResult, ToolResult,
} from './tools.js'; } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import { getErrorMessage } from '../utils/errors.js'; import { getErrorMessage } from '../utils/errors.js';
import { ApprovalMode, Config } from '../config/config.js'; import { ApprovalMode, Config } from '../config/config.js';
import { getResponseText } from '../utils/generateContentResponseUtilities.js'; import { getResponseText } from '../utils/generateContentResponseUtilities.js';
@ -73,12 +74,6 @@ class WebFetchToolInvocation extends BaseToolInvocation<
private async executeFallback(signal: AbortSignal): Promise<ToolResult> { private async executeFallback(signal: AbortSignal): Promise<ToolResult> {
const urls = extractUrls(this.params.prompt); const urls = extractUrls(this.params.prompt);
if (urls.length === 0) {
return {
llmContent: 'Error: No URL found in the prompt for fallback.',
returnDisplay: 'Error: No URL found in the prompt for fallback.',
};
}
// For now, we only support one URL for fallback // For now, we only support one URL for fallback
let url = urls[0]; let url = urls[0];
@ -130,6 +125,10 @@ ${textContent}
return { return {
llmContent: `Error: ${errorMessage}`, llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`, returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_FETCH_FALLBACK_FAILED,
},
}; };
} }
} }
@ -300,6 +299,10 @@ ${sourceListFormatted.join('\n')}`;
return { return {
llmContent: `Error: ${errorMessage}`, llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`, returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_FETCH_PROCESSING_ERROR,
},
}; };
} }
} }

View File

@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { WebSearchTool, WebSearchToolParams } from './web-search.js'; import { WebSearchTool, WebSearchToolParams } from './web-search.js';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
import { GeminiClient } from '../core/client.js'; import { GeminiClient } from '../core/client.js';
import { ToolErrorType } from './tool-error.js';
// Mock GeminiClient and Config constructor // Mock GeminiClient and Config constructor
vi.mock('../core/client.js'); vi.mock('../core/client.js');
@ -112,7 +113,7 @@ describe('WebSearchTool', () => {
expect(result.returnDisplay).toBe('No information found.'); expect(result.returnDisplay).toBe('No information found.');
}); });
it('should handle API errors gracefully', async () => { it('should return a WEB_SEARCH_FAILED error on failure', async () => {
const params: WebSearchToolParams = { query: 'error query' }; const params: WebSearchToolParams = { query: 'error query' };
const testError = new Error('API Failure'); const testError = new Error('API Failure');
(mockGeminiClient.generateContent as Mock).mockRejectedValue(testError); (mockGeminiClient.generateContent as Mock).mockRejectedValue(testError);
@ -120,6 +121,7 @@ describe('WebSearchTool', () => {
const invocation = tool.build(params); const invocation = tool.build(params);
const result = await invocation.execute(abortSignal); const result = await invocation.execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.WEB_SEARCH_FAILED);
expect(result.llmContent).toContain('Error:'); expect(result.llmContent).toContain('Error:');
expect(result.llmContent).toContain('API Failure'); expect(result.llmContent).toContain('API Failure');
expect(result.returnDisplay).toBe('Error performing web search.'); expect(result.returnDisplay).toBe('Error performing web search.');

View File

@ -12,6 +12,7 @@ import {
ToolInvocation, ToolInvocation,
ToolResult, ToolResult,
} from './tools.js'; } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import { getErrorMessage } from '../utils/errors.js'; import { getErrorMessage } from '../utils/errors.js';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
@ -153,6 +154,10 @@ class WebSearchToolInvocation extends BaseToolInvocation<
return { return {
llmContent: `Error: ${errorMessage}`, llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error performing web search.`, returnDisplay: `Error performing web search.`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_SEARCH_FAILED,
},
}; };
} }
} }

View File

@ -121,6 +121,7 @@ export class DiffManager {
diffTitle, diffTitle,
{ {
preview: false, preview: false,
preserveFocus: true,
}, },
); );
await vscode.commands.executeCommand( await vscode.commands.executeCommand(