Compare commits

...

5 Commits

Author SHA1 Message Date
gemini-cli-robot 7cb7a2e01d chore(release): v0.9.0-preview.2 2025-10-11 00:07:48 +00:00
gemini-cli-robot a07fbbebae fix(patch): cherry-pick 0b6c020 to release/v0.9.0-preview.1-pr-10828 [CONFLICTS] (#10920)
Co-authored-by: Victor May <mayvic@google.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-10-10 16:43:40 -07:00
gemini-cli-robot 0a30c20dad chore(release): v0.9.0-preview.1 2025-10-09 18:39:21 +00:00
gemini-cli-robot c5d5603edd fix(patch): cherry-pick 467a305 to release/v0.9.0-preview.0-pr-10661 to patch version v0.9.0-preview.0 and create version 0.9.0-preview.1 (#10817)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2025-10-09 18:22:34 +00:00
gemini-cli-robot cf1c8b2440 chore(release): v0.9.0-preview.0 2025-10-07 21:48:12 +00:00
31 changed files with 338 additions and 111 deletions
+2 -2
View File
@@ -206,8 +206,8 @@ Settings are organized into categories. All settings should be placed within the
- **Default:** `undefined`
- **`tools.shell.enableInteractiveShell`** (boolean):
Use `node-pty` for an interactive shell experience. Fallback to `child_process` still applies. Defaults to `false`.
- **Description:** Enables interactive terminal for running shell commands. If an interactive session cannot be started, it will fall back to a standard shell.
- **Default:** `true`
- **`tools.core`** (array of strings):
- **Description:** This can be used to restrict the set of built-in tools [with an allowlist](../cli/enterprise.md#restricting-tool-access). See [Built-in Tools](../core/tools-api.md#built-in-tools) for a list of core tools. The match semantics are the same as `tools.allowed`.
@@ -18,93 +18,87 @@ describe('Interactive Mode', () => {
await rig.cleanup();
});
it.skipIf(process.platform === 'win32')(
'should trigger chat compression with /compress command',
async () => {
await rig.setup('interactive-compress-test');
//TODO - https://github.com/google-gemini/gemini-cli/issues/10770
it.skip('should trigger chat compression with /compress command', async () => {
await rig.setup('interactive-compress-test');
const { ptyProcess } = rig.runInteractive();
const { ptyProcess } = rig.runInteractive();
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000);
expect(
isReady,
'CLI did not start up in interactive mode correctly',
).toBe(true);
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000);
expect(isReady, 'CLI did not start up in interactive mode correctly').toBe(
true,
);
const longPrompt =
'Dont do anything except returning a 1000 token long paragragh with the <name of the scientist who discovered theory of relativity> at the end to indicate end of response. This is a moderately long sentence.';
const longPrompt =
'Dont do anything except returning a 1000 token long paragragh with the <name of the scientist who discovered theory of relativity> at the end to indicate end of response. This is a moderately long sentence.';
await type(ptyProcess, longPrompt);
await type(ptyProcess, '\r');
await type(ptyProcess, longPrompt);
await type(ptyProcess, '\r');
await rig.waitForText('einstein', 25000);
await rig.waitForText('einstein', 25000);
await type(ptyProcess, '/compress');
// A small delay to allow React to re-render the command list.
await new Promise((resolve) => setTimeout(resolve, 100));
await type(ptyProcess, '\r');
await type(ptyProcess, '/compress');
// A small delay to allow React to re-render the command list.
await new Promise((resolve) => setTimeout(resolve, 100));
await type(ptyProcess, '\r');
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
90000,
);
expect(foundEvent, 'chat_compression telemetry event was not found').toBe(
true,
);
},
);
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
90000,
);
expect(foundEvent, 'chat_compression telemetry event was not found').toBe(
true,
);
});
it.skipIf(process.platform === 'win32')(
'should handle compression failure on token inflation',
async () => {
await rig.setup('interactive-compress-test');
//TODO - https://github.com/google-gemini/gemini-cli/issues/10769
it.skip('should handle compression failure on token inflation', async () => {
await rig.setup('interactive-compress-test');
const { ptyProcess } = rig.runInteractive();
const { ptyProcess } = rig.runInteractive();
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 25000);
expect(
isReady,
'CLI did not start up in interactive mode correctly',
).toBe(true);
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 25000);
expect(isReady, 'CLI did not start up in interactive mode correctly').toBe(
true,
);
await type(ptyProcess, '/compress');
await new Promise((resolve) => setTimeout(resolve, 100));
await type(ptyProcess, '\r');
await type(ptyProcess, '/compress');
await new Promise((resolve) => setTimeout(resolve, 100));
await type(ptyProcess, '\r');
const compressionFailed = await rig.waitForText(
'compression was not beneficial',
25000,
);
const compressionFailed = await rig.waitForText(
'compression was not beneficial',
25000,
);
expect(compressionFailed).toBe(true);
},
);
expect(compressionFailed).toBe(true);
});
});
@@ -192,7 +192,8 @@ describe('mcp server with cyclic tool schema is detected', () => {
}
});
it('mcp tool list should include tool with cyclic tool schema', async () => {
//TODO - https://github.com/google-gemini/gemini-cli/issues/10735
it.skip('mcp tool list should include tool with cyclic tool schema', async () => {
const tool_list_output = await rig.run('/mcp list');
expect(tool_list_output).toContain('tool_with_cyclic_schema');
});
+4 -2
View File
@@ -197,7 +197,8 @@ describe('run_shell_command', () => {
).toBeTruthy();
});
it('should combine multiple --allowed-tools flags', async () => {
//TODO - https://github.com/google-gemini/gemini-cli/issues/10737
it.skip('should combine multiple --allowed-tools flags', async () => {
const rig = new TestRig();
await rig.setup('should combine multiple --allowed-tools flags');
@@ -226,7 +227,8 @@ describe('run_shell_command', () => {
).toBeTruthy();
});
it('should allow all with "ShellTool" and other specifics', async () => {
//TODO - https://github.com/google-gemini/gemini-cli/issues/10768
it.skip('should allow all with "ShellTool" and other specifics', async () => {
const rig = new TestRig();
await rig.setup('should allow all with "ShellTool" and other specifics');
+2 -1
View File
@@ -210,7 +210,8 @@ describe('simple-mcp-server', () => {
}
});
it('should add two numbers', async () => {
//TODO -https://github.com/google-gemini/gemini-cli/issues/10738
it.skip('should add two numbers', async () => {
// Test directory is already set up in before hook
// Just run the command - MCP server config is in settings.json
const output = await rig.run('add 5 and 10');
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.9.0-nightly.20251001.163dba7e",
"version": "0.9.0-preview.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.9.0-nightly.20251001.163dba7e",
"version": "0.9.0-preview.2",
"workspaces": [
"packages/*"
],
@@ -17601,7 +17601,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.9.0-nightly.20251001.163dba7e",
"version": "0.9.0-preview.2",
"dependencies": {
"@a2a-js/sdk": "^0.3.2",
"@google-cloud/storage": "^7.16.0",
@@ -17872,7 +17872,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.9.0-nightly.20251001.163dba7e",
"version": "0.9.0-preview.2",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.16.0",
@@ -17985,7 +17985,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.9.0-nightly.20251001.163dba7e",
"version": "0.9.0-preview.2",
"dependencies": {
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
@@ -18124,7 +18124,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.9.0-nightly.20251001.163dba7e",
"version": "0.9.0-preview.2",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -18135,7 +18135,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.9.0-nightly.20251001.163dba7e",
"version": "0.9.0-preview.2",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.9.0-nightly.20251001.163dba7e",
"version": "0.9.0-preview.2",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.9.0-nightly.20251001.163dba7e"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.9.0-preview.2"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.9.0-nightly.20251001.163dba7e",
"version": "0.9.0-preview.2",
"private": true,
"description": "Gemini CLI A2A Server",
"repository": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.9.0-nightly.20251001.163dba7e",
"version": "0.9.0-preview.2",
"description": "Gemini CLI",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.9.0-nightly.20251001.163dba7e"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.9.0-preview.2"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
+2 -1
View File
@@ -737,7 +737,8 @@ export async function loadCliConfig(
interactive,
trustedFolder,
useRipgrep: settings.tools?.useRipgrep,
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
enableInteractiveShell:
settings.tools?.shell?.enableInteractiveShell ?? true,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
+2 -1
View File
@@ -869,7 +869,8 @@ describe('extension tests', () => {
expect(mockLogExtensionInstallEvent).toHaveBeenCalled();
});
it('should show users information on their mcp server when installing', async () => {
//TODO - https://github.com/google-gemini/gemini-cli/issues/10739
it.skip('should show users information on their mcp server when installing', async () => {
const consoleInfoSpy = vi.spyOn(console, 'info');
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
+1 -1
View File
@@ -110,7 +110,7 @@ const MIGRATION_MAP: Record<string, string> = {
preferredEditor: 'general.preferredEditor',
sandbox: 'tools.sandbox',
selectedAuthType: 'security.auth.selectedType',
shouldUseNodePtyShell: 'tools.shell.enableInteractiveShell',
enableInteractiveShell: 'tools.shell.enableInteractiveShell',
shellPager: 'tools.shell.pager',
shellShowColor: 'tools.shell.showColor',
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
+1 -1
View File
@@ -713,7 +713,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Interactive Shell',
category: 'Tools',
requiresRestart: true,
default: false,
default: true,
description:
'Use node-pty for an interactive shell experience. Fallback to child_process still applies.',
showInDialog: true,
@@ -70,7 +70,7 @@ describe('ShellProcessor', () => {
mockConfig = {
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
};
@@ -171,7 +171,7 @@ export class ShellProcessor implements IPromptProcessor {
config.getTargetDir(),
() => {},
new AbortController().signal,
config.getShouldUseNodePtyShell(),
config.getEnableInteractiveShell(),
shellExecutionConfig,
);
@@ -49,7 +49,8 @@ describe('setupGithubCommand', async () => {
if (scratchDir) await fs.rm(scratchDir, { recursive: true });
});
it('returns a tool action to download github workflows and handles paths', async () => {
//TODO - https://github.com/google-gemini/gemini-cli/issues/10740
it.skip('returns a tool action to download github workflows and handles paths', async () => {
const fakeRepoOwner = 'fake';
const fakeRepoName = 'repo';
const fakeRepoRoot = scratchDir;
@@ -93,7 +93,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
const isThisShellFocusable =
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
status === ToolCallStatus.Executing &&
config?.getShouldUseNodePtyShell();
config?.getEnableInteractiveShell();
const shouldShowFocusHint =
isThisShellFocusable && (showFocusHint || userHasFocused);
@@ -65,7 +65,7 @@ describe('useShellCommandProcessor', () => {
setShellInputFocusedMock = vi.fn();
mockConfig = {
getTargetDir: () => '/test/dir',
getShouldUseNodePtyShell: () => false,
getEnableInteractiveShell: () => false,
getShellExecutionConfig: () => ({
terminalHeight: 20,
terminalWidth: 80,
@@ -160,7 +160,7 @@ export const useShellCommandProcessor = (
if (isBinaryStream) break;
// PTY provides the full screen state, so we just replace.
// Child process provides chunks, so we append.
if (config.getShouldUseNodePtyShell()) {
if (config.getEnableInteractiveShell()) {
cumulativeStdout = event.chunk;
shouldUpdate = true;
} else if (
@@ -221,7 +221,7 @@ export const useShellCommandProcessor = (
}
},
abortSignal,
config.getShouldUseNodePtyShell(),
config.getEnableInteractiveShell(),
shellExecutionConfig,
);
@@ -724,6 +724,7 @@ export const useGeminiStream = (
loopDetectedRef.current = true;
break;
case ServerGeminiEventType.Retry:
case ServerGeminiEventType.InvalidStream:
// Will add the missing logic later
break;
default: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.9.0-nightly.20251001.163dba7e",
"version": "0.9.0-preview.2",
"description": "Gemini CLI Core",
"repository": {
"type": "git",
+25
View File
@@ -619,6 +619,31 @@ describe('Server Config (config.ts)', () => {
});
});
describe('ContinueOnFailedApiCall Configuration', () => {
it('should default continueOnFailedApiCall to true when not provided', () => {
const config = new Config(baseParams);
expect(config.getContinueOnFailedApiCall()).toBe(true);
});
it('should set continueOnFailedApiCall to true when provided as true', () => {
const paramsWithContinueOnFailedApiCall: ConfigParameters = {
...baseParams,
continueOnFailedApiCall: true,
};
const config = new Config(paramsWithContinueOnFailedApiCall);
expect(config.getContinueOnFailedApiCall()).toBe(true);
});
it('should set continueOnFailedApiCall to false when explicitly provided as false', () => {
const paramsWithContinueOnFailedApiCall: ConfigParameters = {
...baseParams,
continueOnFailedApiCall: false,
};
const config = new Config(paramsWithContinueOnFailedApiCall);
expect(config.getContinueOnFailedApiCall()).toBe(false);
});
});
describe('createToolRegistry', () => {
it('should register a tool if coreTools contains an argument-specific pattern', async () => {
const params: ConfigParameters = {
+12 -5
View File
@@ -244,7 +244,7 @@ export interface ConfigParameters {
interactive?: boolean;
trustedFolder?: boolean;
useRipgrep?: boolean;
shouldUseNodePtyShell?: boolean;
enableInteractiveShell?: boolean;
skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig;
extensionManagement?: boolean;
@@ -260,6 +260,7 @@ export interface ConfigParameters {
useModelRouter?: boolean;
enableMessageBusIntegration?: boolean;
enableSubagents?: boolean;
continueOnFailedApiCall?: boolean;
}
export class Config {
@@ -333,7 +334,7 @@ export class Config {
private readonly interactive: boolean;
private readonly trustedFolder: boolean | undefined;
private readonly useRipgrep: boolean;
private readonly shouldUseNodePtyShell: boolean;
private readonly enableInteractiveShell: boolean;
private readonly skipNextSpeakerCheck: boolean;
private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true;
@@ -353,6 +354,7 @@ export class Config {
private readonly useModelRouter: boolean;
private readonly enableMessageBusIntegration: boolean;
private readonly enableSubagents: boolean;
private readonly continueOnFailedApiCall: boolean;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
@@ -423,7 +425,7 @@ export class Config {
this.interactive = params.interactive ?? false;
this.trustedFolder = params.trustedFolder;
this.useRipgrep = params.useRipgrep ?? true;
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
this.shellExecutionConfig = {
terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80,
@@ -443,6 +445,7 @@ export class Config {
this.enableMessageBusIntegration =
params.enableMessageBusIntegration ?? false;
this.enableSubagents = params.enableSubagents ?? false;
this.continueOnFailedApiCall = params.continueOnFailedApiCall ?? true;
this.extensionManagement = params.extensionManagement ?? true;
this.storage = new Storage(this.targetDir);
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
@@ -933,14 +936,18 @@ export class Config {
return this.useRipgrep;
}
getShouldUseNodePtyShell(): boolean {
return this.shouldUseNodePtyShell;
getEnableInteractiveShell(): boolean {
return this.enableInteractiveShell;
}
getSkipNextSpeakerCheck(): boolean {
return this.skipNextSpeakerCheck;
}
getContinueOnFailedApiCall(): boolean {
return this.continueOnFailedApiCall;
}
getShellExecutionConfig(): ShellExecutionConfig {
return this.shellExecutionConfig;
}
+129
View File
@@ -315,6 +315,7 @@ describe('Gemini Client (client.ts)', () => {
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
getUseSmartEdit: vi.fn().mockReturnValue(false),
getUseModelRouter: vi.fn().mockReturnValue(false),
getContinueOnFailedApiCall: vi.fn(),
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/test/temp'),
@@ -1288,6 +1289,9 @@ ${JSON.stringify(
});
it('should stop infinite loop after MAX_TURNS when nextSpeaker always returns model', async () => {
vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue(
true,
);
// Get the mocked checkNextSpeaker function and configure it to trigger infinite loop
const { checkNextSpeaker } = await import(
'../utils/nextSpeakerChecker.js'
@@ -1677,6 +1681,131 @@ ${JSON.stringify(
});
});
it('should recursively call sendMessageStream with "Please continue." when InvalidStream event is received', async () => {
vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue(
true,
);
// Arrange
const mockStream1 = (async function* () {
yield { type: GeminiEventType.InvalidStream };
})();
const mockStream2 = (async function* () {
yield { type: GeminiEventType.Content, value: 'Continued content' };
})();
mockTurnRunFn
.mockReturnValueOnce(mockStream1)
.mockReturnValueOnce(mockStream2);
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
const initialRequest = [{ text: 'Hi' }];
const promptId = 'prompt-id-invalid-stream';
const signal = new AbortController().signal;
// Act
const stream = client.sendMessageStream(initialRequest, signal, promptId);
const events = await fromAsync(stream);
// Assert
expect(events).toEqual([
{ type: GeminiEventType.InvalidStream },
{ type: GeminiEventType.Content, value: 'Continued content' },
]);
// Verify that turn.run was called twice
expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
// First call with original request
expect(mockTurnRunFn).toHaveBeenNthCalledWith(
1,
expect.any(String),
initialRequest,
expect.any(Object),
);
// Second call with "Please continue."
expect(mockTurnRunFn).toHaveBeenNthCalledWith(
2,
expect.any(String),
[{ text: 'System: Please continue.' }],
expect.any(Object),
);
});
it('should not recursively call sendMessageStream with "Please continue." when InvalidStream event is received and flag is false', async () => {
vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue(
false,
);
// Arrange
const mockStream1 = (async function* () {
yield { type: GeminiEventType.InvalidStream };
})();
mockTurnRunFn.mockReturnValueOnce(mockStream1);
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
const initialRequest = [{ text: 'Hi' }];
const promptId = 'prompt-id-invalid-stream';
const signal = new AbortController().signal;
// Act
const stream = client.sendMessageStream(initialRequest, signal, promptId);
const events = await fromAsync(stream);
// Assert
expect(events).toEqual([{ type: GeminiEventType.InvalidStream }]);
// Verify that turn.run was called only once
expect(mockTurnRunFn).toHaveBeenCalledTimes(1);
});
it('should stop recursing after one retry when InvalidStream events are repeatedly received', async () => {
vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue(
true,
);
// Arrange
// Always return a new invalid stream
mockTurnRunFn.mockImplementation(() =>
(async function* () {
yield { type: GeminiEventType.InvalidStream };
})(),
);
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
const initialRequest = [{ text: 'Hi' }];
const promptId = 'prompt-id-infinite-invalid-stream';
const signal = new AbortController().signal;
// Act
const stream = client.sendMessageStream(initialRequest, signal, promptId);
const events = await fromAsync(stream);
// Assert
// We expect 2 InvalidStream events (original + 1 retry)
expect(events.length).toBe(2);
expect(
events.every((e) => e.type === GeminiEventType.InvalidStream),
).toBe(true);
// Verify that turn.run was called twice
expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
});
describe('Editor context delta', () => {
const mockStream = (async function* () {
yield { type: 'content', value: 'Hello' };
+29
View File
@@ -40,9 +40,11 @@ import { LoopDetectionService } from '../services/loopDetectionService.js';
import { ideContextStore } from '../ide/ideContext.js';
import {
logChatCompression,
logContentRetryFailure,
logNextSpeakerCheck,
} from '../telemetry/loggers.js';
import {
ContentRetryFailureEvent,
makeChatCompressionEvent,
NextSpeakerCheckEvent,
} from '../telemetry/types.js';
@@ -463,6 +465,7 @@ My setup is complete. I will provide my first command in the next turn.
signal: AbortSignal,
prompt_id: string,
turns: number = MAX_TURNS,
isInvalidStreamRetry: boolean = false,
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
if (this.lastPromptId !== prompt_id) {
this.loopDetector.reset(prompt_id);
@@ -554,6 +557,31 @@ My setup is complete. I will provide my first command in the next turn.
return turn;
}
yield event;
if (event.type === GeminiEventType.InvalidStream) {
if (this.config.getContinueOnFailedApiCall()) {
if (isInvalidStreamRetry) {
// We already retried once, so stop here.
logContentRetryFailure(
this.config,
new ContentRetryFailureEvent(
4, // 2 initial + 2 after injections
'FAILED_AFTER_PROMPT_INJECTION',
modelToUse,
),
);
return turn;
}
const nextRequest = [{ text: 'System: Please continue.' }];
yield* this.sendMessageStream(
nextRequest,
signal,
prompt_id,
boundedTurns - 1,
true, // Set isInvalidStreamRetry to true
);
return turn;
}
}
if (event.type === GeminiEventType.Error) {
return turn;
}
@@ -591,6 +619,7 @@ My setup is complete. I will provide my first command in the next turn.
signal,
prompt_id,
boundedTurns - 1,
// isInvalidStreamRetry is false here, as this is a next speaker check
);
}
}
+23 -1
View File
@@ -13,7 +13,7 @@ import { Turn, GeminiEventType } from './turn.js';
import type { GenerateContentResponse, Part, Content } from '@google/genai';
import { reportError } from '../utils/errorReporting.js';
import type { GeminiChat } from './geminiChat.js';
import { StreamEventType } from './geminiChat.js';
import { InvalidStreamError, StreamEventType } from './geminiChat.js';
const mockSendMessageStream = vi.fn();
const mockGetHistory = vi.fn();
@@ -223,6 +223,28 @@ describe('Turn', () => {
expect(turn.getDebugResponses().length).toBe(1);
});
it('should yield InvalidStream event if sendMessageStream throws InvalidStreamError', async () => {
const error = new InvalidStreamError(
'Test invalid stream',
'NO_FINISH_REASON',
);
mockSendMessageStream.mockRejectedValue(error);
const reqParts: Part[] = [{ text: 'Trigger invalid stream' }];
const events = [];
for await (const event of turn.run(
'test-model',
reqParts,
new AbortController().signal,
)) {
events.push(event);
}
expect(events).toEqual([{ type: GeminiEventType.InvalidStream }]);
expect(turn.getDebugResponses().length).toBe(0);
expect(reportError).not.toHaveBeenCalled(); // Should not report as error
});
it('should yield Error event and report if sendMessageStream throws', async () => {
const error = new Error('API Error');
mockSendMessageStream.mockRejectedValue(error);
+14 -1
View File
@@ -27,6 +27,7 @@ import {
toFriendlyError,
} from '../utils/errors.js';
import type { GeminiChat } from './geminiChat.js';
import { InvalidStreamError } from './geminiChat.js';
import { parseThought, type ThoughtSummary } from '../utils/thoughtUtils.js';
import { createUserContent } from '@google/genai';
@@ -59,12 +60,18 @@ export enum GeminiEventType {
LoopDetected = 'loop_detected',
Citation = 'citation',
Retry = 'retry',
ContextWindowWillOverflow = 'context_window_will_overflow',
InvalidStream = 'invalid_stream',
}
export type ServerGeminiRetryEvent = {
type: GeminiEventType.Retry;
};
export type ServerGeminiInvalidStreamEvent = {
type: GeminiEventType.InvalidStream;
};
export interface StructuredError {
message: string;
status?: number;
@@ -193,7 +200,8 @@ export type ServerGeminiStreamEvent =
| ServerGeminiToolCallRequestEvent
| ServerGeminiToolCallResponseEvent
| ServerGeminiUserCancelledEvent
| ServerGeminiRetryEvent;
| ServerGeminiRetryEvent
| ServerGeminiInvalidStreamEvent;
// A turn manages the agentic loop turn within the server context.
export class Turn {
@@ -302,6 +310,11 @@ export class Turn {
return;
}
if (e instanceof InvalidStreamError) {
yield { type: GeminiEventType.InvalidStream };
return;
}
const error = toFriendlyError(e);
if (error instanceof UnauthorizedError) {
throw error;
+1 -1
View File
@@ -60,7 +60,7 @@ describe('ShellTool', () => {
.fn()
.mockReturnValue(createMockWorkspaceContext('/test/dir')),
getGeminiClient: vi.fn(),
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
+1 -1
View File
@@ -251,7 +251,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
},
signal,
this.config.getShouldUseNodePtyShell(),
this.config.getEnableInteractiveShell(),
shellExecutionConfig ?? {},
);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.9.0-nightly.20251001.163dba7e",
"version": "0.9.0-preview.2",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.9.0-nightly.20251001.163dba7e",
"version": "0.9.0-preview.2",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {