Compare commits
10 Commits
1e5ead6960
...
5be9172ad5
Author | SHA1 | Date |
---|---|---|
|
5be9172ad5 | |
|
14ca687c05 | |
|
15c62bade3 | |
|
29699274bb | |
|
10286934e6 | |
|
679acc45b2 | |
|
2dd15572ea | |
|
ec41b8db8e | |
|
299bf58309 | |
|
720eb81890 |
|
@ -185,8 +185,13 @@ jobs:
|
|||
core.info(`Raw duplicates JSON: ${rawJson}`);
|
||||
let parsedJson;
|
||||
try {
|
||||
const trimmedJson = rawJson.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '').trim();
|
||||
parsedJson = JSON.parse(trimmedJson);
|
||||
const jsonStringMatch = rawJson.match(/{[\s\S]*}/);
|
||||
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)}`);
|
||||
} catch (err) {
|
||||
core.setFailed(`Failed to parse duplicates JSON from Gemini output: ${err.message}\nRaw output: ${rawJson}`);
|
||||
|
|
|
@ -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.
|
||||
- **Usage:** `/chat save <tag>`
|
||||
- **Details on Checkpoint Location:** The default locations for saved chat checkpoints are:
|
||||
- Linux/macOS: `~/.config/google-generative-ai/checkpoints/`
|
||||
- Windows: `C:\Users\<YourUsername>\AppData\Roaming\google-generative-ai\checkpoints\`
|
||||
- Linux/macOS: `~/.gemini/tmp/<project_hash>/`
|
||||
- Windows: `C:\Users\<YourUsername>\.gemini\tmp\<project_hash>\`
|
||||
- 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).
|
||||
- **`resume`**
|
||||
|
|
|
@ -308,6 +308,20 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
|||
"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`:
|
||||
|
||||
```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.
|
||||
- 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`
|
||||
- **`--screen-reader`**:
|
||||
- Enables screen reader mode for accessibility.
|
||||
- **`--version`**:
|
||||
- Displays the version of the CLI.
|
||||
|
||||
|
|
|
@ -9,16 +9,38 @@ if (process.env.NO_COLOR !== undefined) {
|
|||
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 { 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 rootDir = join(__dirname, '..');
|
||||
const integrationTestsDir = join(rootDir, '.integration-tests');
|
||||
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() {
|
||||
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()}`);
|
||||
await mkdir(runDir, { recursive: true });
|
||||
|
||||
|
@ -57,4 +79,15 @@ export async function teardown() {
|
|||
if (process.env.KEEP_OUTPUT !== 'true' && runDir) {
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,5 +4,6 @@
|
|||
"noEmit": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
"include": ["**/*.ts"],
|
||||
"references": [{ "path": "../packages/core" }]
|
||||
}
|
||||
|
|
|
@ -63,16 +63,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz",
|
||||
"integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.0.tgz",
|
||||
"integrity": "sha512-qI/5TaaaCZE4yeSZ83lu0+xi1r88JSxUjnH4OP/iZF7+KKZ75u3ee5isd0LxX+6N8U0npL61YrpbthILHB6BnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^4.0.0"
|
||||
"is-fullwidth-code-point": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.13.1"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": {
|
||||
|
@ -88,12 +88,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
|
||||
"integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz",
|
||||
"integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
|
@ -5175,9 +5178,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.39.5",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.5.tgz",
|
||||
"integrity": "sha512-z9V0qU4lx1TBXDNFWfAASWk6RNU6c6+TJBKE+FLIg8u0XJ6Yw58Hi0yX8ftEouj6p1QARRlXLFfHbIli93BdQQ==",
|
||||
"version": "1.39.10",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz",
|
||||
"integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
|
@ -6859,26 +6862,26 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ink/-/ink-6.1.1.tgz",
|
||||
"integrity": "sha512-Bqw78FX+1TSIGxs6bdvohgoy6mTfqjFJVNyYzXn8HIyZyVmwLX8XdnhUtUwyaelLCqLz8uuFseCbomRZWjyo5g==",
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ink/-/ink-6.2.2.tgz",
|
||||
"integrity": "sha512-LN1f+/D8KKqMqRux08fIfA9wsEAJ9Bu9CiI3L6ih7bnqNSDUXT/JVJ0rUIc4NkjPiPaeI3BVNREcLYLz9ePSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.1.3",
|
||||
"@alcalzone/ansi-tokenize": "^0.2.0",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"ansi-styles": "^6.2.1",
|
||||
"auto-bind": "^5.0.1",
|
||||
"chalk": "^5.3.0",
|
||||
"chalk": "^5.6.0",
|
||||
"cli-boxes": "^3.0.0",
|
||||
"cli-cursor": "^4.0.0",
|
||||
"cli-truncate": "^4.0.0",
|
||||
"code-excerpt": "^4.0.0",
|
||||
"es-toolkit": "^1.22.0",
|
||||
"es-toolkit": "^1.39.10",
|
||||
"indent-string": "^5.0.0",
|
||||
"is-in-ci": "^1.0.0",
|
||||
"is-in-ci": "^2.0.0",
|
||||
"patch-console": "^2.0.0",
|
||||
"react-reconciler": "^0.32.0",
|
||||
"scheduler": "^0.23.0",
|
||||
"scheduler": "^0.26.0",
|
||||
"signal-exit": "^3.0.7",
|
||||
"slice-ansi": "^7.1.0",
|
||||
"stack-utils": "^2.0.6",
|
||||
|
@ -7030,9 +7033,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ink/node_modules/chalk": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
|
||||
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz",
|
||||
"integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
|
@ -7047,6 +7050,21 @@
|
|||
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
|
||||
"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": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
|
@ -9733,13 +9751,6 @@
|
|||
"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": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
@ -9761,12 +9772,6 @@
|
|||
"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": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz",
|
||||
|
@ -10224,13 +10229,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/selderee": {
|
||||
"version": "0.11.0",
|
||||
|
|
|
@ -73,6 +73,7 @@ export interface CliArgs {
|
|||
listExtensions: boolean | undefined;
|
||||
proxy: string | undefined;
|
||||
includeDirectories: string[] | undefined;
|
||||
screenReader: boolean | undefined;
|
||||
}
|
||||
|
||||
export async function parseArguments(): Promise<CliArgs> {
|
||||
|
@ -229,6 +230,11 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
// Handle comma-separated values
|
||||
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) => {
|
||||
if (argv.prompt && argv['promptInteractive']) {
|
||||
|
@ -465,6 +471,9 @@ export async function loadCliConfig(
|
|||
|
||||
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({
|
||||
sessionId,
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
|
@ -490,7 +499,10 @@ export async function loadCliConfig(
|
|||
argv.show_memory_usage ||
|
||||
settings.showMemoryUsage ||
|
||||
false,
|
||||
accessibility: settings.accessibility,
|
||||
accessibility: {
|
||||
...settings.accessibility,
|
||||
screenReader,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: argv.telemetry ?? settings.telemetry?.enabled,
|
||||
target: (argv.telemetryTarget ??
|
||||
|
|
|
@ -58,6 +58,7 @@ export interface SummarizeToolOutputSettings {
|
|||
|
||||
export interface AccessibilitySettings {
|
||||
disableLoadingPhrases?: boolean;
|
||||
screenReader?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
|
|
|
@ -206,6 +206,16 @@ export const SETTINGS_SCHEMA = {
|
|||
description: 'Disable loading phrases for accessibility',
|
||||
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: {
|
||||
|
|
|
@ -316,7 +316,7 @@ export async function main() {
|
|||
/>
|
||||
</SettingsContext.Provider>
|
||||
</React.StrictMode>,
|
||||
{ exitOnCtrlC: false },
|
||||
{ exitOnCtrlC: false, isScreenReaderEnabled: config.getScreenReader() },
|
||||
);
|
||||
|
||||
checkForUpdates()
|
||||
|
|
|
@ -18,6 +18,7 @@ import { runNonInteractive } from './nonInteractiveCli.js';
|
|||
import { vi } from 'vitest';
|
||||
|
||||
// Mock core modules
|
||||
vi.mock('./ui/hooks/atCommandProcessor.js');
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
|
@ -41,7 +42,7 @@ describe('runNonInteractive', () => {
|
|||
sendMessageStream: vi.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
|
||||
mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
|
||||
|
||||
|
@ -72,6 +73,14 @@ describe('runNonInteractive', () => {
|
|||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
const { handleAtCommand } = await import(
|
||||
'./ui/hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({
|
||||
processedQuery: [{ text: query }],
|
||||
shouldProceed: true,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -163,7 +172,8 @@ describe('runNonInteractive', () => {
|
|||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
error: new Error('Execution failed'),
|
||||
errorType: ToolErrorType.EXECUTION_FAILED,
|
||||
responseParts: {
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'errorTool',
|
||||
response: {
|
||||
|
@ -171,6 +181,7 @@ describe('runNonInteractive', () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
resultDisplay: 'Execution failed',
|
||||
});
|
||||
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.',
|
||||
);
|
||||
});
|
||||
|
||||
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.');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,9 +13,10 @@ import {
|
|||
GeminiEventType,
|
||||
parseAndFormatApiError,
|
||||
} 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 { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||
|
||||
export async function runNonInteractive(
|
||||
config: Config,
|
||||
|
@ -40,9 +41,27 @@ export async function runNonInteractive(
|
|||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
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[] = [
|
||||
{ role: 'user', parts: [{ text: input }] },
|
||||
{ role: 'user', parts: processedQuery as Part[] },
|
||||
];
|
||||
|
||||
let turnCount = 0;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
|
@ -55,7 +74,7 @@ export async function runNonInteractive(
|
|||
);
|
||||
return;
|
||||
}
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
|
||||
const responseStream = geminiClient.sendMessageStream(
|
||||
currentMessages[0]?.parts || [],
|
||||
|
@ -72,29 +91,13 @@ export async function runNonInteractive(
|
|||
if (event.type === GeminiEventType.Content) {
|
||||
process.stdout.write(event.value);
|
||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
const toolCallRequest = event.value;
|
||||
const fc: FunctionCall = {
|
||||
name: toolCallRequest.name,
|
||||
args: toolCallRequest.args,
|
||||
id: toolCallRequest.callId,
|
||||
};
|
||||
functionCalls.push(fc);
|
||||
toolCallRequests.push(event.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
if (toolCallRequests.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
for (const requestInfo of toolCallRequests) {
|
||||
const toolResponse = await executeToolCall(
|
||||
config,
|
||||
requestInfo,
|
||||
|
@ -103,7 +106,7 @@ export async function runNonInteractive(
|
|||
|
||||
if (toolResponse.error) {
|
||||
console.error(
|
||||
`Error executing tool ${fc.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
|
||||
`Error executing tool ${requestInfo.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -87,6 +87,7 @@ interface MockServerConfig {
|
|||
getGeminiClient: Mock<() => GeminiClient | undefined>;
|
||||
getUserTier: Mock<() => Promise<string | undefined>>;
|
||||
getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>;
|
||||
getScreenReader: Mock<() => boolean>;
|
||||
}
|
||||
|
||||
// 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'),
|
||||
})),
|
||||
isTrustedFolder: vi.fn(() => true),
|
||||
getScreenReader: vi.fn(() => false),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -923,10 +923,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
key={staticKey}
|
||||
items={[
|
||||
<Box flexDirection="column" key="header">
|
||||
{!settings.merged.hideBanner && (
|
||||
{!(settings.merged.hideBanner || config.getScreenReader()) && (
|
||||
<Header version={version} nightly={nightly} />
|
||||
)}
|
||||
{!settings.merged.hideTips && <Tips config={config} />}
|
||||
{!(settings.merged.hideTips || config.getScreenReader()) && (
|
||||
<Tips config={config} />
|
||||
)}
|
||||
</Box>,
|
||||
...history.map((h) => (
|
||||
<HistoryItemDisplay
|
||||
|
@ -1093,12 +1095,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
<LoadingIndicator
|
||||
thought={
|
||||
streamingState === StreamingState.WaitingForConfirmation ||
|
||||
config.getAccessibility()?.disableLoadingPhrases
|
||||
config.getAccessibility()?.disableLoadingPhrases ||
|
||||
config.getScreenReader()
|
||||
? undefined
|
||||
: thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
config.getAccessibility()?.disableLoadingPhrases
|
||||
config.getAccessibility()?.disableLoadingPhrases ||
|
||||
config.getScreenReader()
|
||||
? undefined
|
||||
: currentLoadingPhrase
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import {
|
|||
cleanupOldClipboardImages,
|
||||
} from '../utils/clipboardUtils.js';
|
||||
import * as path from 'path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../constants.js';
|
||||
|
||||
export interface InputPromptProps {
|
||||
buffer: TextBuffer;
|
||||
|
@ -688,7 +689,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
>
|
||||
{shellModeActive ? (
|
||||
reverseSearchActive ? (
|
||||
<Text color={theme.text.link}>(r:) </Text>
|
||||
<Text
|
||||
color={theme.text.link}
|
||||
aria-label={SCREEN_READER_USER_PREFIX}
|
||||
>
|
||||
(r:){' '}
|
||||
</Text>
|
||||
) : (
|
||||
'! '
|
||||
)
|
||||
|
|
|
@ -27,11 +27,61 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
|
||||
// Mock the VimModeContext
|
||||
const mockToggleVimEnabled = 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 () => {
|
||||
const actual = await vi.importActual('../contexts/VimModeContext.js');
|
||||
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)
|
||||
// const simulateKeyPress = async (keyData: Partial<Key> & { name: string }) => {
|
||||
// if (currentKeypressHandler) {
|
||||
|
@ -149,7 +177,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
|
@ -163,7 +193,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
|
@ -176,7 +208,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
|
@ -191,7 +225,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Press down arrow
|
||||
|
@ -207,7 +243,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// First go down, then up
|
||||
|
@ -224,7 +262,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Navigate with vim keys
|
||||
|
@ -241,7 +281,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
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
|
||||
|
@ -259,7 +301,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Press Enter to toggle current setting
|
||||
|
@ -274,7 +318,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Press Space to toggle current setting
|
||||
|
@ -289,7 +335,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
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
|
||||
|
@ -308,7 +356,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Switch to scope focus
|
||||
|
@ -327,7 +377,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Wait for initial render
|
||||
|
@ -352,11 +404,13 @@ describe('SettingsDialog', () => {
|
|||
const onRestartRequest = vi.fn();
|
||||
|
||||
const { unmount } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
onSelect={() => {}}
|
||||
onRestartRequest={onRestartRequest}
|
||||
/>,
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// This test would need to trigger a restart-required setting change
|
||||
|
@ -371,11 +425,13 @@ describe('SettingsDialog', () => {
|
|||
const onRestartRequest = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
onSelect={() => {}}
|
||||
onRestartRequest={onRestartRequest}
|
||||
/>,
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Press 'r' key (this would only work if restart prompt is showing)
|
||||
|
@ -393,7 +449,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Wait for initial render
|
||||
|
@ -418,7 +476,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Switch to scope selector
|
||||
|
@ -442,7 +502,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Should show user scope values initially
|
||||
|
@ -459,7 +521,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
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)
|
||||
|
@ -477,7 +541,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Toggle a setting
|
||||
|
@ -499,7 +565,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
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
|
||||
|
@ -519,7 +587,9 @@ describe('SettingsDialog', () => {
|
|||
|
||||
const { stdin, unmount } = render(
|
||||
<VimModeProvider settings={settings}>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>
|
||||
</VimModeProvider>,
|
||||
);
|
||||
|
||||
|
@ -542,7 +612,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
|
@ -555,7 +627,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
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)
|
||||
|
@ -571,7 +645,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
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
|
||||
|
@ -591,7 +667,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
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
|
||||
|
@ -609,7 +687,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
|
@ -626,7 +706,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
|
@ -641,7 +723,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Rapid navigation
|
||||
|
@ -660,7 +744,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
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
|
||||
|
@ -676,7 +762,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
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
|
||||
|
@ -692,7 +780,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
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
|
||||
|
@ -709,7 +799,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Wait for initial render
|
||||
|
@ -739,7 +831,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Should still render without crashing
|
||||
|
@ -752,7 +846,9 @@ describe('SettingsDialog', () => {
|
|||
|
||||
// Should not crash even if some settings are missing definitions
|
||||
const { lastFrame } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Settings');
|
||||
|
@ -765,7 +861,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Wait for initial render
|
||||
|
@ -793,7 +891,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Toggle first setting (should require restart)
|
||||
|
@ -822,7 +922,9 @@ describe('SettingsDialog', () => {
|
|||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Multiple scope changes
|
||||
|
@ -846,11 +948,13 @@ describe('SettingsDialog', () => {
|
|||
const onRestartRequest = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
onSelect={() => {}}
|
||||
onRestartRequest={onRestartRequest}
|
||||
/>,
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// This would test the restart workflow if we could trigger it
|
||||
|
@ -863,4 +967,58 @@ describe('SettingsDialog', () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -35,7 +35,7 @@ import {
|
|||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import chalk from 'chalk';
|
||||
import { cpSlice, cpLen } from '../utils/textUtils.js';
|
||||
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
|
||||
|
||||
interface SettingsDialogProps {
|
||||
settings: LoadedSettings;
|
||||
|
@ -78,8 +78,8 @@ export function SettingsDialog({
|
|||
new Set(),
|
||||
);
|
||||
|
||||
// Preserve pending changes across scope switches (boolean and number values only)
|
||||
type PendingValue = boolean | number;
|
||||
// Preserve pending changes across scope switches
|
||||
type PendingValue = boolean | number | string;
|
||||
const [globalPendingChanges, setGlobalPendingChanges] = useState<
|
||||
Map<string, PendingValue>
|
||||
>(new Map());
|
||||
|
@ -99,7 +99,10 @@ export function SettingsDialog({
|
|||
const def = getSettingDefinition(key);
|
||||
if (def?.type === 'boolean' && typeof value === 'boolean') {
|
||||
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);
|
||||
}
|
||||
newModified.add(key);
|
||||
|
@ -123,7 +126,7 @@ export function SettingsDialog({
|
|||
type: definition?.type,
|
||||
toggle: () => {
|
||||
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;
|
||||
}
|
||||
const currentValue = getSettingValue(key, pendingSettings, {});
|
||||
|
@ -220,7 +223,7 @@ export function SettingsDialog({
|
|||
|
||||
const items = generateSettingsItems();
|
||||
|
||||
// Number edit state
|
||||
// Generic edit state
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editBuffer, setEditBuffer] = useState<string>('');
|
||||
const [editCursorPos, setEditCursorPos] = useState<number>(0); // Cursor position within edit buffer
|
||||
|
@ -235,29 +238,40 @@ export function SettingsDialog({
|
|||
return () => clearInterval(id);
|
||||
}, [editingKey]);
|
||||
|
||||
const startEditingNumber = (key: string, initial?: string) => {
|
||||
const startEditing = (key: string, initial?: string) => {
|
||||
setEditingKey(key);
|
||||
const initialValue = initial ?? '';
|
||||
setEditBuffer(initialValue);
|
||||
setEditCursorPos(cpLen(initialValue)); // Position cursor at end of initial value
|
||||
};
|
||||
|
||||
const commitNumberEdit = (key: string) => {
|
||||
if (editBuffer.trim() === '') {
|
||||
// Nothing entered; cancel edit
|
||||
const commitEdit = (key: string) => {
|
||||
const definition = getSettingDefinition(key);
|
||||
const type = definition?.type;
|
||||
|
||||
if (editBuffer.trim() === '' && type === 'number') {
|
||||
// Nothing entered for a number; cancel edit
|
||||
setEditingKey(null);
|
||||
setEditBuffer('');
|
||||
setEditCursorPos(0);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(editBuffer.trim());
|
||||
if (Number.isNaN(parsed)) {
|
||||
|
||||
let parsed: string | number;
|
||||
if (type === 'number') {
|
||||
const numParsed = Number(editBuffer.trim());
|
||||
if (Number.isNaN(numParsed)) {
|
||||
// Invalid number; cancel edit
|
||||
setEditingKey(null);
|
||||
setEditBuffer('');
|
||||
setEditCursorPos(0);
|
||||
return;
|
||||
}
|
||||
parsed = numParsed;
|
||||
} else {
|
||||
// For strings, use the buffer as is.
|
||||
parsed = editBuffer;
|
||||
}
|
||||
|
||||
// Update pending
|
||||
setPendingSettings((prev) => setPendingSettingValueAny(key, parsed, prev));
|
||||
|
@ -347,10 +361,16 @@ export function SettingsDialog({
|
|||
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
|
||||
}
|
||||
if (focusSection === 'settings') {
|
||||
// If editing a number, capture numeric input and control keys
|
||||
// If editing, capture input and control keys
|
||||
if (editingKey) {
|
||||
const definition = getSettingDefinition(editingKey);
|
||||
const type = definition?.type;
|
||||
|
||||
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) {
|
||||
setEditBuffer((b) => {
|
||||
const before = cpSlice(b, 0, editCursorPos);
|
||||
|
@ -380,16 +400,27 @@ export function SettingsDialog({
|
|||
return;
|
||||
}
|
||||
if (name === 'escape') {
|
||||
commitNumberEdit(editingKey);
|
||||
commitEdit(editingKey);
|
||||
return;
|
||||
}
|
||||
if (name === 'return') {
|
||||
commitNumberEdit(editingKey);
|
||||
commitEdit(editingKey);
|
||||
return;
|
||||
}
|
||||
// Allow digits, minus, plus, and dot
|
||||
const ch = key.sequence;
|
||||
if (/[0-9\-+.]/.test(ch)) {
|
||||
|
||||
let ch = key.sequence;
|
||||
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) => {
|
||||
const beforeCursor = cpSlice(currentBuffer, 0, editCursorPos);
|
||||
const afterCursor = cpSlice(currentBuffer, editCursorPos);
|
||||
|
@ -398,6 +429,7 @@ export function SettingsDialog({
|
|||
setEditCursorPos((pos) => pos + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow key navigation
|
||||
if (name === 'left') {
|
||||
setEditCursorPos((pos) => Math.max(0, pos - 1));
|
||||
|
@ -422,7 +454,7 @@ export function SettingsDialog({
|
|||
if (name === 'up' || name === 'k') {
|
||||
// If editing, commit first
|
||||
if (editingKey) {
|
||||
commitNumberEdit(editingKey);
|
||||
commitEdit(editingKey);
|
||||
}
|
||||
const newIndex =
|
||||
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
|
||||
|
@ -436,7 +468,7 @@ export function SettingsDialog({
|
|||
} else if (name === 'down' || name === 'j') {
|
||||
// If editing, commit first
|
||||
if (editingKey) {
|
||||
commitNumberEdit(editingKey);
|
||||
commitEdit(editingKey);
|
||||
}
|
||||
const newIndex =
|
||||
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
|
||||
|
@ -449,15 +481,18 @@ export function SettingsDialog({
|
|||
}
|
||||
} else if (name === 'return' || name === 'space') {
|
||||
const currentItem = items[activeSettingIndex];
|
||||
if (currentItem?.type === 'number') {
|
||||
startEditingNumber(currentItem.value);
|
||||
if (
|
||||
currentItem?.type === 'number' ||
|
||||
currentItem?.type === 'string'
|
||||
) {
|
||||
startEditing(currentItem.value);
|
||||
} else {
|
||||
currentItem?.toggle();
|
||||
}
|
||||
} else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) {
|
||||
const currentItem = items[activeSettingIndex];
|
||||
if (currentItem?.type === 'number') {
|
||||
startEditingNumber(currentItem.value, key.sequence);
|
||||
startEditing(currentItem.value, key.sequence);
|
||||
}
|
||||
} else if (ctrl && (name === 'c' || name === 'l')) {
|
||||
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
|
||||
|
@ -475,8 +510,11 @@ export function SettingsDialog({
|
|||
prev,
|
||||
),
|
||||
);
|
||||
} else if (defType === 'number') {
|
||||
if (typeof defaultValue === 'number') {
|
||||
} else if (defType === 'number' || defType === 'string') {
|
||||
if (
|
||||
typeof defaultValue === 'number' ||
|
||||
typeof defaultValue === 'string'
|
||||
) {
|
||||
setPendingSettings((prev) =>
|
||||
setPendingSettingValueAny(
|
||||
currentSetting.value,
|
||||
|
@ -509,7 +547,8 @@ export function SettingsDialog({
|
|||
? typeof defaultValue === 'boolean'
|
||||
? defaultValue
|
||||
: false
|
||||
: typeof defaultValue === 'number'
|
||||
: typeof defaultValue === 'number' ||
|
||||
typeof defaultValue === 'string'
|
||||
? defaultValue
|
||||
: undefined;
|
||||
const immediateSettingsObject =
|
||||
|
@ -541,7 +580,9 @@ export function SettingsDialog({
|
|||
(currentSetting.type === 'boolean' &&
|
||||
typeof defaultValue === 'boolean') ||
|
||||
(currentSetting.type === 'number' &&
|
||||
typeof defaultValue === 'number')
|
||||
typeof defaultValue === 'number') ||
|
||||
(currentSetting.type === 'string' &&
|
||||
typeof defaultValue === 'string')
|
||||
) {
|
||||
setGlobalPendingChanges((prev) => {
|
||||
const next = new Map(prev);
|
||||
|
@ -584,7 +625,7 @@ export function SettingsDialog({
|
|||
}
|
||||
if (name === 'escape') {
|
||||
if (editingKey) {
|
||||
commitNumberEdit(editingKey);
|
||||
commitEdit(editingKey);
|
||||
} else {
|
||||
onSelect(undefined, selectedScope);
|
||||
}
|
||||
|
@ -637,8 +678,8 @@ export function SettingsDialog({
|
|||
// Cursor not visible
|
||||
displayValue = editBuffer;
|
||||
}
|
||||
} else if (item.type === 'number') {
|
||||
// For numbers, get the actual current value from pending settings
|
||||
} else if (item.type === 'number' || item.type === 'string') {
|
||||
// For numbers/strings, get the actual current value from pending settings
|
||||
const path = item.value.split('.');
|
||||
const currentValue = getNestedValue(pendingSettings, path);
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
|
|||
import { CompressionProps } from '../../types.js';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { SCREEN_READER_MODEL_PREFIX } from '../../constants.js';
|
||||
|
||||
export interface CompressionDisplayProps {
|
||||
compression: CompressionProps;
|
||||
|
@ -40,6 +41,7 @@ export const CompressionMessage: React.FC<CompressionDisplayProps> = ({
|
|||
color={
|
||||
compression.isPending ? Colors.AccentPurple : Colors.AccentGreen
|
||||
}
|
||||
aria-label={SCREEN_READER_MODEL_PREFIX}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
|
|
|
@ -8,6 +8,7 @@ import React from 'react';
|
|||
import { Text, Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { SCREEN_READER_MODEL_PREFIX } from '../../constants.js';
|
||||
|
||||
interface GeminiMessageProps {
|
||||
text: string;
|
||||
|
@ -28,7 +29,12 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
|||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={Colors.AccentPurple}>{prefix}</Text>
|
||||
<Text
|
||||
color={Colors.AccentPurple}
|
||||
aria-label={SCREEN_READER_MODEL_PREFIX}
|
||||
>
|
||||
{prefix}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../../constants.js';
|
||||
|
||||
interface UserMessageProps {
|
||||
text: string;
|
||||
|
@ -31,7 +32,9 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
|
|||
alignSelf="flex-start"
|
||||
>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={textColor}>{prefix}</Text>
|
||||
<Text color={textColor} aria-label={SCREEN_READER_USER_PREFIX}>
|
||||
{prefix}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text wrap="wrap" color={textColor}>
|
||||
|
|
|
@ -4,8 +4,6 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { stripVTControlCharacters } from 'util';
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
|
@ -13,7 +11,12 @@ import pathMod from 'path';
|
|||
import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
|
||||
import stringWidth from 'string-width';
|
||||
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';
|
||||
|
||||
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 {
|
||||
height: number;
|
||||
width: number;
|
||||
|
|
|
@ -15,3 +15,7 @@ export const UI_WIDTH =
|
|||
export const STREAM_DEBOUNCE_MS = 100;
|
||||
|
||||
export const SHELL_COMMAND_NAME = 'Shell Command';
|
||||
|
||||
export const SCREEN_READER_USER_PREFIX = 'User: ';
|
||||
|
||||
export const SCREEN_READER_MODEL_PREFIX = 'Model: ';
|
||||
|
|
|
@ -134,7 +134,6 @@ export function useReactToolScheduler(
|
|||
const scheduler = useMemo(
|
||||
() =>
|
||||
new CoreToolScheduler({
|
||||
toolRegistry: config.getToolRegistry(),
|
||||
outputUpdateHandler,
|
||||
onAllToolCallsComplete: allToolCallsCompleteHandler,
|
||||
onToolCallsUpdate: toolCallsUpdateHandler,
|
||||
|
|
|
@ -4,6 +4,9 @@
|
|||
* 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.
|
||||
* @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);
|
||||
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('');
|
||||
}
|
||||
|
|
|
@ -62,6 +62,7 @@ export enum ApprovalMode {
|
|||
|
||||
export interface AccessibilitySettings {
|
||||
disableLoadingPhrases?: boolean;
|
||||
screenReader?: boolean;
|
||||
}
|
||||
|
||||
export interface BugCommandSettings {
|
||||
|
@ -734,6 +735,10 @@ export class Config {
|
|||
return this.skipNextSpeakerCheck;
|
||||
}
|
||||
|
||||
getScreenReader(): boolean {
|
||||
return this.accessibility.screenReader ?? false;
|
||||
}
|
||||
|
||||
getEnablePromptCompletion(): boolean {
|
||||
return this.enablePromptCompletion;
|
||||
}
|
||||
|
|
|
@ -129,11 +129,11 @@ describe('CoreToolScheduler', () => {
|
|||
model: 'test-model',
|
||||
authType: 'oauth-personal',
|
||||
}),
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
} as unknown as Config;
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
toolRegistry: mockToolRegistry,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
|
@ -189,11 +189,11 @@ describe('CoreToolScheduler with payload', () => {
|
|||
model: 'test-model',
|
||||
authType: 'oauth-personal',
|
||||
}),
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
} as unknown as Config;
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
toolRegistry: mockToolRegistry,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
|
@ -462,15 +462,14 @@ class MockEditTool extends BaseDeclarativeTool<
|
|||
describe('CoreToolScheduler edit cancellation', () => {
|
||||
it('should preserve diff when an edit is cancelled', async () => {
|
||||
const mockEditTool = new MockEditTool();
|
||||
const declarativeTool = mockEditTool;
|
||||
const mockToolRegistry = {
|
||||
getTool: () => declarativeTool,
|
||||
getTool: () => mockEditTool,
|
||||
getFunctionDeclarations: () => [],
|
||||
tools: new Map(),
|
||||
discovery: {},
|
||||
registerTool: () => {},
|
||||
getToolByName: () => declarativeTool,
|
||||
getToolByDisplayName: () => declarativeTool,
|
||||
getToolByName: () => mockEditTool,
|
||||
getToolByDisplayName: () => mockEditTool,
|
||||
getTools: () => [],
|
||||
discoverTools: async () => {},
|
||||
getAllTools: () => [],
|
||||
|
@ -489,11 +488,11 @@ describe('CoreToolScheduler edit cancellation', () => {
|
|||
model: 'test-model',
|
||||
authType: 'oauth-personal',
|
||||
}),
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
} as unknown as Config;
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
toolRegistry: mockToolRegistry,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
|
@ -581,11 +580,11 @@ describe('CoreToolScheduler YOLO mode', () => {
|
|||
model: 'test-model',
|
||||
authType: 'oauth-personal',
|
||||
}),
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
} as unknown as Config;
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
toolRegistry: mockToolRegistry,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
|
@ -670,11 +669,11 @@ describe('CoreToolScheduler request queueing', () => {
|
|||
model: 'test-model',
|
||||
authType: 'oauth-personal',
|
||||
}),
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
} as unknown as Config;
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
toolRegistry: mockToolRegistry,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
|
@ -783,11 +782,11 @@ describe('CoreToolScheduler request queueing', () => {
|
|||
model: 'test-model',
|
||||
authType: 'oauth-personal',
|
||||
}),
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
} as unknown as Config;
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
toolRegistry: mockToolRegistry,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
|
@ -864,7 +863,9 @@ describe('CoreToolScheduler request queueing', () => {
|
|||
getTools: () => [],
|
||||
discoverTools: async () => {},
|
||||
discovery: {},
|
||||
};
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
mockConfig.getToolRegistry = () => toolRegistry;
|
||||
|
||||
const onAllToolCallsComplete = vi.fn();
|
||||
const onToolCallsUpdate = vi.fn();
|
||||
|
@ -874,7 +875,6 @@ describe('CoreToolScheduler request queueing', () => {
|
|||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
toolRegistry: toolRegistry as unknown as ToolRegistry,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate: (toolCalls) => {
|
||||
onToolCallsUpdate(toolCalls);
|
||||
|
|
|
@ -226,12 +226,11 @@ const createErrorResponse = (
|
|||
});
|
||||
|
||||
interface CoreToolSchedulerOptions {
|
||||
toolRegistry: ToolRegistry;
|
||||
config: Config;
|
||||
outputUpdateHandler?: OutputUpdateHandler;
|
||||
onAllToolCallsComplete?: AllToolCallsCompleteHandler;
|
||||
onToolCallsUpdate?: ToolCallsUpdateHandler;
|
||||
getPreferredEditor: () => EditorType | undefined;
|
||||
config: Config;
|
||||
onEditorClose: () => void;
|
||||
}
|
||||
|
||||
|
@ -255,7 +254,7 @@ export class CoreToolScheduler {
|
|||
|
||||
constructor(options: CoreToolSchedulerOptions) {
|
||||
this.config = options.config;
|
||||
this.toolRegistry = options.toolRegistry;
|
||||
this.toolRegistry = options.config.getToolRegistry();
|
||||
this.outputUpdateHandler = options.outputUpdateHandler;
|
||||
this.onAllToolCallsComplete = options.onAllToolCallsComplete;
|
||||
this.onToolCallsUpdate = options.onToolCallsUpdate;
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
ToolResult,
|
||||
Config,
|
||||
ToolErrorType,
|
||||
ApprovalMode,
|
||||
} from '../index.js';
|
||||
import { Part } from '@google/genai';
|
||||
import { MockTool } from '../test-utils/tools.js';
|
||||
|
@ -27,10 +28,11 @@ describe('executeToolCall', () => {
|
|||
|
||||
mockToolRegistry = {
|
||||
getTool: vi.fn(),
|
||||
// Add other ToolRegistry methods if needed, or use a more complete mock
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
mockConfig = {
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
getApprovalMode: () => ApprovalMode.DEFAULT,
|
||||
getSessionId: () => 'test-session-id',
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
|
@ -38,7 +40,6 @@ describe('executeToolCall', () => {
|
|||
model: 'test-model',
|
||||
authType: 'oauth-personal',
|
||||
}),
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
} as unknown as Config;
|
||||
|
||||
abortController = new AbortController();
|
||||
|
@ -57,7 +58,7 @@ describe('executeToolCall', () => {
|
|||
returnDisplay: 'Success!',
|
||||
};
|
||||
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
|
||||
vi.spyOn(mockTool, 'validateBuildAndExecute').mockResolvedValue(toolResult);
|
||||
mockTool.executeFn.mockReturnValue(toolResult);
|
||||
|
||||
const response = await executeToolCall(
|
||||
mockConfig,
|
||||
|
@ -66,19 +67,19 @@ describe('executeToolCall', () => {
|
|||
);
|
||||
|
||||
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('testTool');
|
||||
expect(mockTool.validateBuildAndExecute).toHaveBeenCalledWith(
|
||||
request.args,
|
||||
abortController.signal,
|
||||
);
|
||||
expect(response.callId).toBe('call1');
|
||||
expect(response.error).toBeUndefined();
|
||||
expect(response.resultDisplay).toBe('Success!');
|
||||
expect(response.responseParts).toEqual({
|
||||
expect(mockTool.executeFn).toHaveBeenCalledWith(request.args);
|
||||
expect(response).toStrictEqual({
|
||||
callId: 'call1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
resultDisplay: 'Success!',
|
||||
responseParts: {
|
||||
functionResponse: {
|
||||
name: 'testTool',
|
||||
id: 'call1',
|
||||
response: { output: 'Tool executed successfully' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -98,23 +99,19 @@ describe('executeToolCall', () => {
|
|||
abortController.signal,
|
||||
);
|
||||
|
||||
expect(response.callId).toBe('call2');
|
||||
expect(response.error).toBeInstanceOf(Error);
|
||||
expect(response.error?.message).toBe(
|
||||
'Tool "nonexistentTool" not found in registry.',
|
||||
);
|
||||
expect(response.resultDisplay).toBe(
|
||||
'Tool "nonexistentTool" not found in registry.',
|
||||
);
|
||||
expect(response.responseParts).toEqual([
|
||||
{
|
||||
expect(response).toStrictEqual({
|
||||
callId: 'call2',
|
||||
error: new Error('Tool "nonexistentTool" not found in registry.'),
|
||||
errorType: ToolErrorType.TOOL_NOT_REGISTERED,
|
||||
resultDisplay: 'Tool "nonexistentTool" not found in registry.',
|
||||
responseParts: {
|
||||
functionResponse: {
|
||||
name: 'nonexistentTool',
|
||||
id: 'call2',
|
||||
response: { error: 'Tool "nonexistentTool" not found in registry.' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error if tool validation fails', async () => {
|
||||
|
@ -125,24 +122,17 @@ describe('executeToolCall', () => {
|
|||
isClientInitiated: false,
|
||||
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.spyOn(mockTool, 'validateBuildAndExecute').mockResolvedValue(
|
||||
validationErrorResult,
|
||||
);
|
||||
vi.spyOn(mockTool, 'build').mockImplementation(() => {
|
||||
throw new Error('Invalid parameters');
|
||||
});
|
||||
|
||||
const response = await executeToolCall(
|
||||
mockConfig,
|
||||
request,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
expect(response).toStrictEqual({
|
||||
callId: 'call3',
|
||||
error: new Error('Invalid parameters'),
|
||||
|
@ -152,7 +142,7 @@ describe('executeToolCall', () => {
|
|||
id: 'call3',
|
||||
name: 'testTool',
|
||||
response: {
|
||||
output: 'Error: Invalid parameters',
|
||||
error: 'Invalid parameters',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -177,9 +167,7 @@ describe('executeToolCall', () => {
|
|||
},
|
||||
};
|
||||
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
|
||||
vi.spyOn(mockTool, 'validateBuildAndExecute').mockResolvedValue(
|
||||
executionErrorResult,
|
||||
);
|
||||
mockTool.executeFn.mockReturnValue(executionErrorResult);
|
||||
|
||||
const response = await executeToolCall(
|
||||
mockConfig,
|
||||
|
@ -195,7 +183,7 @@ describe('executeToolCall', () => {
|
|||
id: 'call4',
|
||||
name: 'testTool',
|
||||
response: {
|
||||
output: 'Error: Execution failed',
|
||||
error: 'Execution failed',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -211,11 +199,10 @@ describe('executeToolCall', () => {
|
|||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-5',
|
||||
};
|
||||
const executionError = new Error('Something went very wrong');
|
||||
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
|
||||
vi.spyOn(mockTool, 'validateBuildAndExecute').mockRejectedValue(
|
||||
executionError,
|
||||
);
|
||||
mockTool.executeFn.mockImplementation(() => {
|
||||
throw new Error('Something went very wrong');
|
||||
});
|
||||
|
||||
const response = await executeToolCall(
|
||||
mockConfig,
|
||||
|
@ -223,19 +210,19 @@ describe('executeToolCall', () => {
|
|||
abortController.signal,
|
||||
);
|
||||
|
||||
expect(response.callId).toBe('call5');
|
||||
expect(response.error).toBe(executionError);
|
||||
expect(response.errorType).toBe(ToolErrorType.UNHANDLED_EXCEPTION);
|
||||
expect(response.resultDisplay).toBe('Something went very wrong');
|
||||
expect(response.responseParts).toEqual([
|
||||
{
|
||||
expect(response).toStrictEqual({
|
||||
callId: 'call5',
|
||||
error: new Error('Something went very wrong'),
|
||||
errorType: ToolErrorType.UNHANDLED_EXCEPTION,
|
||||
resultDisplay: 'Something went very wrong',
|
||||
responseParts: {
|
||||
functionResponse: {
|
||||
name: 'testTool',
|
||||
id: 'call5',
|
||||
response: { error: 'Something went very wrong' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly format llmContent with inlineData', async () => {
|
||||
|
@ -254,7 +241,7 @@ describe('executeToolCall', () => {
|
|||
returnDisplay: 'Image processed',
|
||||
};
|
||||
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
|
||||
vi.spyOn(mockTool, 'validateBuildAndExecute').mockResolvedValue(toolResult);
|
||||
mockTool.executeFn.mockReturnValue(toolResult);
|
||||
|
||||
const response = await executeToolCall(
|
||||
mockConfig,
|
||||
|
@ -262,8 +249,12 @@ describe('executeToolCall', () => {
|
|||
abortController.signal,
|
||||
);
|
||||
|
||||
expect(response.resultDisplay).toBe('Image processed');
|
||||
expect(response.responseParts).toEqual([
|
||||
expect(response).toStrictEqual({
|
||||
callId: 'call6',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
resultDisplay: 'Image processed',
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'testTool',
|
||||
|
@ -274,6 +265,7 @@ describe('executeToolCall', () => {
|
|||
},
|
||||
},
|
||||
imageDataPart,
|
||||
]);
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,166 +4,27 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
FileDiff,
|
||||
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';
|
||||
import { ToolCallRequestInfo, ToolCallResponseInfo, Config } from '../index.js';
|
||||
import { CoreToolScheduler } from './coreToolScheduler.js';
|
||||
|
||||
/**
|
||||
* Executes a single tool call non-interactively.
|
||||
* It does not handle confirmations, multiple calls, or live updates.
|
||||
* Executes a single tool call non-interactively by leveraging the CoreToolScheduler.
|
||||
*/
|
||||
export async function executeToolCall(
|
||||
config: Config,
|
||||
toolCallRequest: ToolCallRequestInfo,
|
||||
abortSignal?: AbortSignal,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<ToolCallResponseInfo> {
|
||||
const tool = config.getToolRegistry().getTool(toolCallRequest.name);
|
||||
|
||||
const startTime = Date.now();
|
||||
if (!tool) {
|
||||
const error = new Error(
|
||||
`Tool "${toolCallRequest.name}" not found in registry.`,
|
||||
);
|
||||
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,
|
||||
prompt_id: toolCallRequest.prompt_id,
|
||||
tool_type: 'native',
|
||||
return new Promise<ToolCallResponseInfo>((resolve, reject) => {
|
||||
new CoreToolScheduler({
|
||||
config,
|
||||
getPreferredEditor: () => undefined,
|
||||
onEditorClose: () => {},
|
||||
onAllToolCallsComplete: async (completedToolCalls) => {
|
||||
resolve(completedToolCalls[0].response);
|
||||
},
|
||||
})
|
||||
.schedule(toolCallRequest, abortSignal)
|
||||
.catch(reject);
|
||||
});
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,75 +4,224 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as path from 'path';
|
||||
import { IdeClient } from './ide-client.js';
|
||||
import {
|
||||
describe,
|
||||
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', () => {
|
||||
it('should return valid if cwd is a subpath of the IDE workspace path', () => {
|
||||
const result = IdeClient.validateWorkspacePath(
|
||||
'/Users/person/gemini-cli',
|
||||
'VS Code',
|
||||
'/Users/person/gemini-cli/sub-dir',
|
||||
);
|
||||
expect(result.isValid).toBe(true);
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as object),
|
||||
promises: {
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
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', () => {
|
||||
const result = IdeClient.validateWorkspacePath(
|
||||
undefined,
|
||||
'VS Code',
|
||||
'/Users/person/gemini-cli/sub-dir',
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('Failed to connect');
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return invalid if GEMINI_CLI_IDE_WORKSPACE_PATH is empty', () => {
|
||||
const result = IdeClient.validateWorkspacePath(
|
||||
'',
|
||||
'VS Code',
|
||||
'/Users/person/gemini-cli/sub-dir',
|
||||
describe('connect', () => {
|
||||
it('should connect using HTTP when port is provided in config file', async () => {
|
||||
const config = { port: '8080' };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
|
||||
const ideClient = IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp', 'gemini-ide-server-12345.json'),
|
||||
'utf8',
|
||||
);
|
||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||
new URL('http://localhost:8080/mcp'),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
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 result = IdeClient.validateWorkspacePath(
|
||||
'/some/other/path',
|
||||
'VS Code',
|
||||
'/Users/person/gemini-cli/sub-dir',
|
||||
it('should connect using stdio when stdio config is provided in file', async () => {
|
||||
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
|
||||
const ideClient = IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(StdioClientTransport).toHaveBeenCalledWith({
|
||||
command: 'test-cmd',
|
||||
args: ['--foo'],
|
||||
});
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport);
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('Directory mismatch');
|
||||
});
|
||||
|
||||
it('should handle multiple workspace paths and return valid', () => {
|
||||
const result = IdeClient.validateWorkspacePath(
|
||||
['/some/other/path', '/Users/person/gemini-cli'].join(path.delimiter),
|
||||
'VS Code',
|
||||
'/Users/person/gemini-cli/sub-dir',
|
||||
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,
|
||||
);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should return invalid if cwd is not in any of the multiple workspace paths', () => {
|
||||
const result = IdeClient.validateWorkspacePath(
|
||||
['/some/other/path', '/another/path'].join(path.delimiter),
|
||||
'VS Code',
|
||||
'/Users/person/gemini-cli/sub-dir',
|
||||
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,
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('Directory mismatch');
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('should handle windows paths', () => {
|
||||
const result = IdeClient.validateWorkspacePath(
|
||||
'c:/some/other/path;d:/Users/person/gemini-cli',
|
||||
'VS Code',
|
||||
'd:/Users/person/gemini-cli/sub-dir',
|
||||
it('should connect using stdio when stdio config is in environment variables', async () => {
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValue(
|
||||
new Error('File not found'),
|
||||
);
|
||||
expect(result.isValid).toBe(true);
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
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 * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EnvHttpProxyAgent } from 'undici';
|
||||
|
@ -40,6 +41,16 @@ export enum IDEConnectionStatus {
|
|||
Connecting = 'connecting',
|
||||
}
|
||||
|
||||
type StdioConfig = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
type ConnectionConfig = {
|
||||
port?: string;
|
||||
stdio?: StdioConfig;
|
||||
};
|
||||
|
||||
function getRealPath(path: string): string {
|
||||
try {
|
||||
return fs.realpathSync(path);
|
||||
|
@ -104,9 +115,9 @@ export class IdeClient {
|
|||
|
||||
this.setState(IDEConnectionStatus.Connecting);
|
||||
|
||||
const ideInfoFromFile = await this.getIdeInfoFromFile();
|
||||
const configFromFile = await this.getConnectionConfigFromFile();
|
||||
const workspacePath =
|
||||
ideInfoFromFile.workspacePath ??
|
||||
configFromFile?.workspacePath ??
|
||||
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
|
||||
|
||||
const { isValid, error } = IdeClient.validateWorkspacePath(
|
||||
|
@ -120,17 +131,36 @@ export class IdeClient {
|
|||
return;
|
||||
}
|
||||
|
||||
const portFromFile = ideInfoFromFile.port;
|
||||
if (portFromFile) {
|
||||
const connected = await this.establishConnection(portFromFile);
|
||||
if (configFromFile) {
|
||||
if (configFromFile.port) {
|
||||
const connected = await this.establishHttpConnection(
|
||||
configFromFile.port,
|
||||
);
|
||||
if (connected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (configFromFile.stdio) {
|
||||
const connected = await this.establishStdioConnection(
|
||||
configFromFile.stdio,
|
||||
);
|
||||
if (connected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const portFromEnv = this.getPortFromEnv();
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
@ -316,10 +346,35 @@ export class IdeClient {
|
|||
return port;
|
||||
}
|
||||
|
||||
private async getIdeInfoFromFile(): Promise<{
|
||||
port?: string;
|
||||
workspacePath?: string;
|
||||
}> {
|
||||
private getStdioConfigFromEnv(): StdioConfig | undefined {
|
||||
const command = process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'];
|
||||
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 {
|
||||
const ideProcessId = await getIdeProcessId();
|
||||
const portFile = path.join(
|
||||
|
@ -327,13 +382,9 @@ export class IdeClient {
|
|||
`gemini-ide-server-${ideProcessId}.json`,
|
||||
);
|
||||
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
|
||||
const ideInfo = JSON.parse(portFileContents);
|
||||
return {
|
||||
port: ideInfo?.port?.toString(),
|
||||
workspacePath: ideInfo?.workspacePath,
|
||||
};
|
||||
return JSON.parse(portFileContents);
|
||||
} 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;
|
||||
try {
|
||||
logger.debug('Attempting to connect to IDE via HTTP SSE');
|
||||
this.client = new Client({
|
||||
name: 'streamable-http-client',
|
||||
// TODO(#3487): use the CLI version here.
|
||||
|
@ -443,6 +495,39 @@ export class IdeClient {
|
|||
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() {
|
||||
|
|
|
@ -9,10 +9,14 @@ import { partListUnionToString } from '../core/geminiRequest.js';
|
|||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
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 { Config } from '../config/config.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', () => {
|
||||
let tempRootDir: string; // This will be the rootDirectory for the GlobTool instance
|
||||
|
@ -203,6 +207,29 @@ describe('GlobTool', () => {
|
|||
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', () => {
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from './tools.js';
|
||||
import { shortenPath, makeRelative } from '../utils/paths.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
|
||||
export interface GlobPath {
|
||||
|
@ -115,9 +116,14 @@ class GlobToolInvocation extends BaseToolInvocation<
|
|||
this.params.path,
|
||||
);
|
||||
if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) {
|
||||
const rawError = `Error: Path "${this.params.path}" is not within any workspace directory`;
|
||||
return {
|
||||
llmContent: `Error: Path "${this.params.path}" is not within any workspace directory`,
|
||||
llmContent: rawError,
|
||||
returnDisplay: `Path is not within workspace`,
|
||||
error: {
|
||||
message: rawError,
|
||||
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
|
||||
},
|
||||
};
|
||||
}
|
||||
searchDirectories = [searchDirAbsolute];
|
||||
|
@ -234,9 +240,14 @@ class GlobToolInvocation extends BaseToolInvocation<
|
|||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error(`GlobLogic execute Error: ${errorMessage}`, error);
|
||||
const rawError = `Error during glob search operation: ${errorMessage}`;
|
||||
return {
|
||||
llmContent: `Error during glob search operation: ${errorMessage}`,
|
||||
llmContent: rawError,
|
||||
returnDisplay: `Error: An unexpected error occurred.`,
|
||||
error: {
|
||||
message: rawError,
|
||||
type: ToolErrorType.GLOB_EXECUTION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,10 @@ import fs from 'fs/promises';
|
|||
import os from 'os';
|
||||
import { Config } from '../config/config.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
|
||||
vi.mock('child_process', () => ({
|
||||
|
@ -223,6 +227,15 @@ describe('GrepTool', () => {
|
|||
/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', () => {
|
||||
|
|
|
@ -21,6 +21,7 @@ import { makeRelative, shortenPath } from '../utils/paths.js';
|
|||
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||
import { isGitRepository } from '../utils/gitUtils.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
|
@ -198,6 +199,10 @@ class GrepToolInvocation extends BaseToolInvocation<
|
|||
return {
|
||||
llmContent: `Error during grep search operation: ${errorMessage}`,
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.GREP_EXECUTION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ import { LSTool } from './ls.js';
|
|||
import { Config } from '../config/config.js';
|
||||
import { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
describe('LSTool', () => {
|
||||
let lsTool: LSTool;
|
||||
|
@ -288,6 +289,7 @@ describe('LSTool', () => {
|
|||
|
||||
expect(result.llmContent).toContain('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 () => {
|
||||
|
@ -302,6 +304,7 @@ describe('LSTool', () => {
|
|||
|
||||
expect(result.llmContent).toContain('Error listing 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 () => {
|
||||
|
@ -357,6 +360,7 @@ describe('LSTool', () => {
|
|||
expect(result.llmContent).toContain('Error listing directory');
|
||||
expect(result.llmContent).toContain('permission denied');
|
||||
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 () => {
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from './tools.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
/**
|
||||
* Parameters for the LS tool
|
||||
|
@ -114,11 +115,19 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
|
|||
}
|
||||
|
||||
// Helper for consistent error formatting
|
||||
private errorResult(llmContent: string, returnDisplay: string): ToolResult {
|
||||
private errorResult(
|
||||
llmContent: string,
|
||||
returnDisplay: string,
|
||||
type: ToolErrorType,
|
||||
): ToolResult {
|
||||
return {
|
||||
llmContent,
|
||||
// Keep returnDisplay simpler in core logic
|
||||
returnDisplay: `Error: ${returnDisplay}`,
|
||||
error: {
|
||||
message: llmContent,
|
||||
type,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -135,12 +144,14 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
|
|||
return this.errorResult(
|
||||
`Error: Directory not found or inaccessible: ${this.params.path}`,
|
||||
`Directory not found or inaccessible.`,
|
||||
ToolErrorType.FILE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (!stats.isDirectory()) {
|
||||
return this.errorResult(
|
||||
`Error: Path is not a directory: ${this.params.path}`,
|
||||
`Path is not a directory.`,
|
||||
ToolErrorType.PATH_IS_NOT_A_DIRECTORY,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -253,7 +264,11 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
|
|||
};
|
||||
} catch (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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { DiscoveredMCPTool, generateValidName } from './mcp-tool.js'; // Added getStringifiedResultForDisplay
|
||||
import { ToolResult, ToolConfirmationOutcome } from './tools.js'; // Added ToolConfirmationOutcome
|
||||
import { CallableTool, Part } from '@google/genai';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
// Mock @google/genai mcpToTool and CallableTool
|
||||
// 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" (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 }) => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
mockCallableToolInstance,
|
||||
|
@ -210,16 +211,18 @@ describe('DiscoveredMCPTool', () => {
|
|||
},
|
||||
];
|
||||
mockCallTool.mockResolvedValue(mockMcpToolResponseParts);
|
||||
const expectedError = new Error(
|
||||
`MCP tool '${serverToolName}' reported tool error with response: ${JSON.stringify(
|
||||
const expectedErrorMessage = `MCP tool '${serverToolName}' reported tool error with response: ${JSON.stringify(
|
||||
mockMcpToolResponseParts,
|
||||
)}`,
|
||||
);
|
||||
)}`;
|
||||
|
||||
const invocation = tool.build(params);
|
||||
await expect(
|
||||
invocation.execute(new AbortController().signal),
|
||||
).rejects.toThrow(expectedError);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
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.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
ToolResult,
|
||||
} from './tools.js';
|
||||
import { CallableTool, FunctionCall, Part } from '@google/genai';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
type ToolParams = Record<string, unknown>;
|
||||
|
||||
|
@ -139,9 +140,19 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
|
|||
|
||||
// Ensure the response is not an error
|
||||
if (this.isMCPToolError(rawResponseParts)) {
|
||||
throw new Error(
|
||||
`MCP tool '${this.serverToolName}' reported tool error with response: ${JSON.stringify(rawResponseParts)}`,
|
||||
);
|
||||
const errorMessage = `MCP tool '${
|
||||
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);
|
||||
|
|
|
@ -16,6 +16,7 @@ import * as fs from 'fs/promises';
|
|||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { ToolConfirmationOutcome } from './tools.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock(import('fs/promises'), async (importOriginal) => {
|
||||
|
@ -287,6 +288,9 @@ describe('MemoryTool', () => {
|
|||
expect(result.returnDisplay).toBe(
|
||||
`Error saving memory: ${underlyingError.message}`,
|
||||
);
|
||||
expect(result.error?.type).toBe(
|
||||
ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ import * as Diff from 'diff';
|
|||
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
|
||||
import { tildeifyPath } from '../utils/paths.js';
|
||||
import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
const memoryToolSchemaData: FunctionDeclaration = {
|
||||
name: 'save_memory',
|
||||
|
@ -273,6 +274,10 @@ class MemoryToolInvocation extends BaseToolInvocation<
|
|||
error: `Failed to save memory. Detail: ${errorMessage}`,
|
||||
}),
|
||||
returnDisplay: `Error saving memory: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,10 @@ import os from 'os';
|
|||
import { Config } from '../config/config.js';
|
||||
import { WorkspaceContext } from '../utils/workspaceContext.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', () => {
|
||||
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', () => {
|
||||
const createMultipleFiles = (count: number, contentPrefix = 'Content') => {
|
||||
const files: string[] = [];
|
||||
|
|
|
@ -29,6 +29,7 @@ import {
|
|||
recordFileOperationMetric,
|
||||
FileOperation,
|
||||
} from '../telemetry/metrics.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
/**
|
||||
* Parameters for the ReadManyFilesTool.
|
||||
|
@ -232,13 +233,6 @@ ${finalExclusionPatternsForDescription
|
|||
: [...exclude];
|
||||
|
||||
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 {
|
||||
const allEntries = new Set<string>();
|
||||
const workspaceDirs = this.config.getWorkspaceContext().getDirectories();
|
||||
|
@ -352,9 +346,14 @@ ${finalExclusionPatternsForDescription
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = `Error during file search: ${getErrorMessage(error)}`;
|
||||
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\`\`\``,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.READ_MANY_FILES_SEARCH_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -24,10 +24,43 @@ export enum ToolErrorType {
|
|||
PERMISSION_DENIED = 'permission_denied',
|
||||
NO_SPACE_LEFT = 'no_space_left',
|
||||
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_PREPARATION_FAILURE = 'edit_preparation_failure',
|
||||
EDIT_NO_OCCURRENCE_FOUND = 'edit_no_occurrence_found',
|
||||
EDIT_EXPECTED_OCCURRENCE_MISMATCH = 'edit_expected_occurrence_mismatch',
|
||||
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',
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import fs from 'node:fs';
|
|||
import { MockTool } from '../test-utils/tools.js';
|
||||
|
||||
import { McpClientManager } from './mcp-client-manager.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
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 () => {
|
||||
const discoverSpy = vi.spyOn(
|
||||
McpClientManager.prototype,
|
||||
|
|
|
@ -20,6 +20,7 @@ import { connectAndDiscover } from './mcp-client.js';
|
|||
import { McpClientManager } from './mcp-client-manager.js';
|
||||
import { DiscoveredMCPTool } from './mcp-tool.js';
|
||||
import { parse } from 'shell-quote';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
|
||||
|
||||
type ToolParams = Record<string, unknown>;
|
||||
|
@ -106,6 +107,10 @@ class DiscoveredToolInvocation extends BaseToolInvocation<
|
|||
return {
|
||||
llmContent,
|
||||
returnDisplay: llmContent,
|
||||
error: {
|
||||
message: llmContent,
|
||||
type: ToolErrorType.DISCOVERED_TOOL_EXECUTION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -4,17 +4,72 @@
|
|||
* 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 { Config, ApprovalMode } from '../config/config.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', () => {
|
||||
const mockConfig = {
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
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', () => {
|
||||
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 () => {
|
||||
const tool = new WebFetchTool({
|
||||
...mockConfig,
|
||||
getApprovalMode: () => ApprovalMode.AUTO_EDIT,
|
||||
} as unknown as Config);
|
||||
vi.spyOn(mockConfig, 'getApprovalMode').mockReturnValue(
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
const tool = new WebFetchTool(mockConfig);
|
||||
const params = { prompt: 'fetch https://example.com' };
|
||||
const invocation = tool.build(params);
|
||||
const confirmationDetails = await invocation.shouldConfirmExecute(
|
||||
|
@ -72,11 +127,7 @@ describe('WebFetchTool', () => {
|
|||
});
|
||||
|
||||
it('should call setApprovalMode when onConfirm is called with ProceedAlways', async () => {
|
||||
const setApprovalMode = vi.fn();
|
||||
const tool = new WebFetchTool({
|
||||
...mockConfig,
|
||||
setApprovalMode,
|
||||
} as unknown as Config);
|
||||
const tool = new WebFetchTool(mockConfig);
|
||||
const params = { prompt: 'fetch https://example.com' };
|
||||
const invocation = tool.build(params);
|
||||
const confirmationDetails = await invocation.shouldConfirmExecute(
|
||||
|
@ -93,7 +144,9 @@ describe('WebFetchTool', () => {
|
|||
);
|
||||
}
|
||||
|
||||
expect(setApprovalMode).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
ToolInvocation,
|
||||
ToolResult,
|
||||
} from './tools.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { ApprovalMode, Config } from '../config/config.js';
|
||||
import { getResponseText } from '../utils/generateContentResponseUtilities.js';
|
||||
|
@ -73,12 +74,6 @@ class WebFetchToolInvocation extends BaseToolInvocation<
|
|||
|
||||
private async executeFallback(signal: AbortSignal): Promise<ToolResult> {
|
||||
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
|
||||
let url = urls[0];
|
||||
|
||||
|
@ -130,6 +125,10 @@ ${textContent}
|
|||
return {
|
||||
llmContent: `Error: ${errorMessage}`,
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.WEB_FETCH_FALLBACK_FAILED,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -300,6 +299,10 @@ ${sourceListFormatted.join('\n')}`;
|
|||
return {
|
||||
llmContent: `Error: ${errorMessage}`,
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.WEB_FETCH_PROCESSING_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
|||
import { WebSearchTool, WebSearchToolParams } from './web-search.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
// Mock GeminiClient and Config constructor
|
||||
vi.mock('../core/client.js');
|
||||
|
@ -112,7 +113,7 @@ describe('WebSearchTool', () => {
|
|||
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 testError = new Error('API Failure');
|
||||
(mockGeminiClient.generateContent as Mock).mockRejectedValue(testError);
|
||||
|
@ -120,6 +121,7 @@ describe('WebSearchTool', () => {
|
|||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.error?.type).toBe(ToolErrorType.WEB_SEARCH_FAILED);
|
||||
expect(result.llmContent).toContain('Error:');
|
||||
expect(result.llmContent).toContain('API Failure');
|
||||
expect(result.returnDisplay).toBe('Error performing web search.');
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
ToolInvocation,
|
||||
ToolResult,
|
||||
} from './tools.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { Config } from '../config/config.js';
|
||||
|
@ -153,6 +154,10 @@ class WebSearchToolInvocation extends BaseToolInvocation<
|
|||
return {
|
||||
llmContent: `Error: ${errorMessage}`,
|
||||
returnDisplay: `Error performing web search.`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.WEB_SEARCH_FAILED,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -121,6 +121,7 @@ export class DiffManager {
|
|||
diffTitle,
|
||||
{
|
||||
preview: false,
|
||||
preserveFocus: true,
|
||||
},
|
||||
);
|
||||
await vscode.commands.executeCommand(
|
||||
|
|
Loading…
Reference in New Issue