From 2fb14ead1f073fe8f77a08597ab785c0b93b36d5 Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Thu, 4 Sep 2025 12:46:02 -0700 Subject: [PATCH 01/10] Hotfix for issue #7730 (#7739) Co-authored-by: Taylor Mullen Co-authored-by: Arya Gummadi --- packages/cli/src/config/config.test.ts | 2 +- packages/core/src/config/config.test.ts | 10 ++ packages/core/src/ide/process-utils.test.ts | 114 +++++++++++++++++++- packages/core/src/ide/process-utils.ts | 27 +++-- 4 files changed, 139 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 86570e0a..9a596299 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -83,7 +83,7 @@ vi.mock('@google/gemini-cli-core', async () => { return { ...actualServer, IdeClient: { - getInstance: vi.fn().mockReturnValue({ + getInstance: vi.fn().mockResolvedValue({ getConnectionStatus: vi.fn(), initialize: vi.fn(), shutdown: vi.fn(), diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index d5647bee..0b1c2023 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -100,6 +100,16 @@ vi.mock('../services/gitService.js', () => { return { GitService: GitServiceMock }; }); +vi.mock('../ide/ide-client.js', () => ({ + IdeClient: { + getInstance: vi.fn().mockResolvedValue({ + getConnectionStatus: vi.fn(), + initialize: vi.fn(), + shutdown: vi.fn(), + }), + }, +})); + describe('Server Config (config.ts)', () => { const MODEL = 'gemini-pro'; const SANDBOX: SandboxConfig = { diff --git a/packages/core/src/ide/process-utils.test.ts b/packages/core/src/ide/process-utils.test.ts index 9ac56424..e6c68f14 100644 --- a/packages/core/src/ide/process-utils.test.ts +++ b/packages/core/src/ide/process-utils.test.ts @@ -66,13 +66,34 @@ describe('getIdeProcessInfo', () => { it('should traverse up and find the great-grandchild of the root process', async () => { (os.platform as Mock).mockReturnValue('win32'); const processInfoMap = new Map([ - [1000, { stdout: 'ParentProcessId=900\r\nCommandLine=node.exe\r\n' }], + [ + 1000, + { + stdout: + '{"Name":"node.exe","ParentProcessId":900,"CommandLine":"node.exe"}', + }, + ], [ 900, - { stdout: 'ParentProcessId=800\r\nCommandLine=powershell.exe\r\n' }, + { + stdout: + '{"Name":"powershell.exe","ParentProcessId":800,"CommandLine":"powershell.exe"}', + }, + ], + [ + 800, + { + stdout: + '{"Name":"code.exe","ParentProcessId":700,"CommandLine":"code.exe"}', + }, + ], + [ + 700, + { + stdout: + '{"Name":"wininit.exe","ParentProcessId":0,"CommandLine":"wininit.exe"}', + }, ], - [800, { stdout: 'ParentProcessId=700\r\nCommandLine=code.exe\r\n' }], - [700, { stdout: 'ParentProcessId=0\r\nCommandLine=wininit.exe\r\n' }], ]); mockedExec.mockImplementation((command: string) => { const pidMatch = command.match(/ProcessId=(\d+)/); @@ -86,5 +107,90 @@ describe('getIdeProcessInfo', () => { const result = await getIdeProcessInfo(); expect(result).toEqual({ pid: 900, command: 'powershell.exe' }); }); + + it('should handle non-existent process gracefully', async () => { + (os.platform as Mock).mockReturnValue('win32'); + mockedExec + .mockResolvedValueOnce({ stdout: '' }) // Non-existent PID returns empty due to -ErrorAction SilentlyContinue + .mockResolvedValueOnce({ + stdout: + '{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}', + }); // Fallback call + + const result = await getIdeProcessInfo(); + expect(result).toEqual({ pid: 1000, command: 'fallback.exe' }); + }); + + it('should handle malformed JSON output gracefully', async () => { + (os.platform as Mock).mockReturnValue('win32'); + mockedExec + .mockResolvedValueOnce({ stdout: '{"invalid":json}' }) // Malformed JSON + .mockResolvedValueOnce({ + stdout: + '{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}', + }); // Fallback call + + const result = await getIdeProcessInfo(); + expect(result).toEqual({ pid: 1000, command: 'fallback.exe' }); + }); + + it('should handle PowerShell errors without crashing the process chain', async () => { + (os.platform as Mock).mockReturnValue('win32'); + const processInfoMap = new Map([ + [1000, { stdout: '' }], // First process doesn't exist (empty due to -ErrorAction) + [ + 1001, + { + stdout: + '{"Name":"parent.exe","ParentProcessId":800,"CommandLine":"parent.exe"}', + }, + ], + [ + 800, + { + stdout: + '{"Name":"ide.exe","ParentProcessId":0,"CommandLine":"ide.exe"}', + }, + ], + ]); + + // Mock the process.pid to test traversal with missing processes + Object.defineProperty(process, 'pid', { + value: 1001, + configurable: true, + }); + + mockedExec.mockImplementation((command: string) => { + const pidMatch = command.match(/ProcessId=(\d+)/); + if (pidMatch) { + const pid = parseInt(pidMatch[1], 10); + return Promise.resolve(processInfoMap.get(pid) || { stdout: '' }); + } + return Promise.reject(new Error('Invalid command for mock')); + }); + + const result = await getIdeProcessInfo(); + // Should return the current process command since traversal continues despite missing processes + expect(result).toEqual({ pid: 1001, command: 'parent.exe' }); + + // Reset process.pid + Object.defineProperty(process, 'pid', { + value: 1000, + configurable: true, + }); + }); + + it('should handle partial JSON data with defaults', async () => { + (os.platform as Mock).mockReturnValue('win32'); + mockedExec + .mockResolvedValueOnce({ stdout: '{"Name":"partial.exe"}' }) // Missing ParentProcessId, defaults to 0 + .mockResolvedValueOnce({ + stdout: + '{"Name":"root.exe","ParentProcessId":0,"CommandLine":"root.exe"}', + }); // Get grandparent info + + const result = await getIdeProcessInfo(); + expect(result).toEqual({ pid: 1000, command: 'root.exe' }); + }); }); }); diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index 17d1d520..451a3af3 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -26,15 +26,24 @@ async function getProcessInfo(pid: number): Promise<{ }> { const platform = os.platform(); if (platform === 'win32') { - const command = `wmic process where "ProcessId=${pid}" get Name,ParentProcessId,CommandLine /value`; - const { stdout } = await execAsync(command); - const nameMatch = stdout.match(/Name=([^\n]*)/); - const processName = nameMatch ? nameMatch[1].trim() : ''; - const ppidMatch = stdout.match(/ParentProcessId=(\d+)/); - const parentPid = ppidMatch ? parseInt(ppidMatch[1], 10) : 0; - const commandLineMatch = stdout.match(/CommandLine=([^\n]*)/); - const commandLine = commandLineMatch ? commandLineMatch[1].trim() : ''; - return { parentPid, name: processName, command: commandLine }; + const powershellCommand = [ + '$p = Get-CimInstance Win32_Process', + `-Filter 'ProcessId=${pid}'`, + '-ErrorAction SilentlyContinue;', + 'if ($p) {', + '@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}', + '| ConvertTo-Json', + '}', + ].join(' '); + const { stdout } = await execAsync(`powershell "${powershellCommand}"`); + const output = stdout.trim(); + if (!output) return { parentPid: 0, name: '', command: '' }; + const { + Name = '', + ParentProcessId = 0, + CommandLine = '', + } = JSON.parse(output); + return { parentPid: ParentProcessId, name: Name, command: CommandLine }; } else { const command = `ps -o ppid=,command= -p ${pid}`; const { stdout } = await execAsync(command); From 9e574773c914ccb277ad4b8cd976e50226010320 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Thu, 4 Sep 2025 19:50:25 +0000 Subject: [PATCH 02/10] chore(release): v0.3.1 --- package-lock.json | 14 +++++++------- package.json | 4 ++-- packages/a2a-server/package.json | 2 +- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7caed83d..5d7faece 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.3.0", + "version": "0.3.1", "workspaces": [ "packages/*" ], @@ -14235,7 +14235,7 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "@a2a-js/sdk": "^0.3.2", "@google-cloud/storage": "^7.16.0", @@ -14506,7 +14506,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "@google/gemini-cli-core": "file:../core", "@google/genai": "1.13.0", @@ -14690,7 +14690,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "@google/genai": "1.13.0", "@lvce-editor/ripgrep": "^1.6.0", @@ -14812,7 +14812,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "devDependencies": { "typescript": "^5.3.3" @@ -14823,7 +14823,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.3.0", + "version": "0.3.1", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index 4b556297..09450c32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.3.0", + "version": "0.3.1", "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.3.0" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.1" }, "scripts": { "start": "node scripts/start.js", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index dce8e6bd..b82c8993 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.3.0", + "version": "0.3.1", "private": true, "description": "Gemini CLI A2A Server", "repository": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 9d8a45dc..6a59afb8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.3.0", + "version": "0.3.1", "description": "Gemini CLI", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.0" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.1" }, "dependencies": { "@google/gemini-cli-core": "file:../core", diff --git a/packages/core/package.json b/packages/core/package.json index 53419ded..dd9b4c31 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-core", - "version": "0.3.0", + "version": "0.3.1", "description": "Gemini CLI Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 032d47f2..70186c84 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-test-utils", - "version": "0.3.0", + "version": "0.3.1", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 63e37fb6..fb1e9d6f 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -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.3.0", + "version": "0.3.1", "publisher": "google", "icon": "assets/icon.png", "repository": { From ad3bc17e457ff86315a07dbd6bffeeab6703398f Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Thu, 4 Sep 2025 16:10:33 -0700 Subject: [PATCH 03/10] fix(process-utils): fix bug that prevented start-up when running process walking command fails (#7757) --- packages/core/src/ide/process-utils.ts | 72 ++++++++++++++------------ 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index 451a3af3..ecb93781 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -24,39 +24,44 @@ async function getProcessInfo(pid: number): Promise<{ name: string; command: string; }> { - const platform = os.platform(); - if (platform === 'win32') { - const powershellCommand = [ - '$p = Get-CimInstance Win32_Process', - `-Filter 'ProcessId=${pid}'`, - '-ErrorAction SilentlyContinue;', - 'if ($p) {', - '@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}', - '| ConvertTo-Json', - '}', - ].join(' '); - const { stdout } = await execAsync(`powershell "${powershellCommand}"`); - const output = stdout.trim(); - if (!output) return { parentPid: 0, name: '', command: '' }; - const { - Name = '', - ParentProcessId = 0, - CommandLine = '', - } = JSON.parse(output); - return { parentPid: ParentProcessId, name: Name, command: CommandLine }; - } else { - const command = `ps -o ppid=,command= -p ${pid}`; - const { stdout } = await execAsync(command); - const trimmedStdout = stdout.trim(); - const ppidString = trimmedStdout.split(/\s+/)[0]; - const parentPid = parseInt(ppidString, 10); - const fullCommand = trimmedStdout.substring(ppidString.length).trim(); - const processName = path.basename(fullCommand.split(' ')[0]); - return { - parentPid: isNaN(parentPid) ? 1 : parentPid, - name: processName, - command: fullCommand, - }; + try { + const platform = os.platform(); + if (platform === 'win32') { + const powershellCommand = [ + '$p = Get-CimInstance Win32_Process', + `-Filter 'ProcessId=${pid}'`, + '-ErrorAction SilentlyContinue;', + 'if ($p) {', + '@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}', + '| ConvertTo-Json', + '}', + ].join(' '); + const { stdout } = await execAsync(`powershell "${powershellCommand}"`); + const output = stdout.trim(); + if (!output) return { parentPid: 0, name: '', command: '' }; + const { + Name = '', + ParentProcessId = 0, + CommandLine = '', + } = JSON.parse(output); + return { parentPid: ParentProcessId, name: Name, command: CommandLine }; + } else { + const command = `ps -o ppid=,command= -p ${pid}`; + const { stdout } = await execAsync(command); + const trimmedStdout = stdout.trim(); + const ppidString = trimmedStdout.split(/\s+/)[0]; + const parentPid = parseInt(ppidString, 10); + const fullCommand = trimmedStdout.substring(ppidString.length).trim(); + const processName = path.basename(fullCommand.split(' ')[0]); + return { + parentPid: isNaN(parentPid) ? 1 : parentPid, + name: processName, + command: fullCommand, + }; + } + } catch (_e) { + console.debug(`Failed to get process info for pid ${pid}:`, _e); + return { parentPid: 0, name: '', command: '' }; } } @@ -169,7 +174,6 @@ async function getIdeProcessInfoForWindows(): Promise<{ * top-level ancestor process ID and command as a fallback. * * @returns A promise that resolves to the PID and command of the IDE process. - * @throws Will throw an error if the underlying shell commands fail. */ export async function getIdeProcessInfo(): Promise<{ pid: number; From 4c9746561c13e9d3183b5c01c1bc4b7ad13fd6d0 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Thu, 4 Sep 2025 23:55:40 +0000 Subject: [PATCH 04/10] chore(release): v0.3.2 --- package-lock.json | 14 +++++++------- package.json | 4 ++-- packages/a2a-server/package.json | 2 +- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5d7faece..e1980184 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.3.1", + "version": "0.3.2", "workspaces": [ "packages/*" ], @@ -14235,7 +14235,7 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.3.1", + "version": "0.3.2", "dependencies": { "@a2a-js/sdk": "^0.3.2", "@google-cloud/storage": "^7.16.0", @@ -14506,7 +14506,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.3.1", + "version": "0.3.2", "dependencies": { "@google/gemini-cli-core": "file:../core", "@google/genai": "1.13.0", @@ -14690,7 +14690,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.3.1", + "version": "0.3.2", "dependencies": { "@google/genai": "1.13.0", "@lvce-editor/ripgrep": "^1.6.0", @@ -14812,7 +14812,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.3.1", + "version": "0.3.2", "license": "Apache-2.0", "devDependencies": { "typescript": "^5.3.3" @@ -14823,7 +14823,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.3.1", + "version": "0.3.2", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index 09450c32..2ba5eda3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.3.1", + "version": "0.3.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.3.1" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.2" }, "scripts": { "start": "node scripts/start.js", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index b82c8993..f54b1eb5 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.3.1", + "version": "0.3.2", "private": true, "description": "Gemini CLI A2A Server", "repository": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 6a59afb8..f19449c6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.3.1", + "version": "0.3.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.3.1" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.2" }, "dependencies": { "@google/gemini-cli-core": "file:../core", diff --git a/packages/core/package.json b/packages/core/package.json index dd9b4c31..8e009fea 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-core", - "version": "0.3.1", + "version": "0.3.2", "description": "Gemini CLI Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 70186c84..516a7e29 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-test-utils", - "version": "0.3.1", + "version": "0.3.2", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index fb1e9d6f..b98b9002 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -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.3.1", + "version": "0.3.2", "publisher": "google", "icon": "assets/icon.png", "repository": { From dfa9dda1ddd9eeaa9c63f9ff71ab7166eef109d0 Mon Sep 17 00:00:00 2001 From: Victor May Date: Thu, 28 Aug 2025 13:21:27 -0400 Subject: [PATCH 05/10] Refine stream validation to prevent unnecessary retries (#7278) --- packages/core/src/core/geminiChat.test.ts | 190 ++++++++++++++++++++++ packages/core/src/core/geminiChat.ts | 47 +++++- 2 files changed, 232 insertions(+), 5 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index deacea37..161f2900 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -73,6 +73,92 @@ describe('GeminiChat', () => { }); describe('sendMessage', () => { + it('should retain the initial user message when an automatic function call occurs', async () => { + // 1. Define the user's initial text message. This is the turn that gets dropped by the buggy logic. + const userInitialMessage: Content = { + role: 'user', + parts: [{ text: 'How is the weather in Boston?' }], + }; + + // 2. Mock the full API response, including the automaticFunctionCallingHistory. + // This history represents the full turn: user asks, model calls tool, tool responds, model answers. + const mockAfcResponse = { + candidates: [ + { + content: { + role: 'model', + parts: [ + { text: 'The weather in Boston is 72 degrees and sunny.' }, + ], + }, + }, + ], + automaticFunctionCallingHistory: [ + userInitialMessage, // The user's turn + { + // The model's first response: a tool call + role: 'model', + parts: [ + { + functionCall: { + name: 'get_weather', + args: { location: 'Boston' }, + }, + }, + ], + }, + { + // The tool's response, which has a 'user' role + role: 'user', + parts: [ + { + functionResponse: { + name: 'get_weather', + response: { temperature: 72, condition: 'sunny' }, + }, + }, + ], + }, + ], + } as unknown as GenerateContentResponse; + + vi.mocked(mockModelsModule.generateContent).mockResolvedValue( + mockAfcResponse, + ); + + // 3. Action: Send the initial message. + await chat.sendMessage( + { message: 'How is the weather in Boston?' }, + 'prompt-id-afc-bug', + ); + + // 4. Assert: Check the final state of the history. + const history = chat.getHistory(); + + // With the bug, history.length will be 3, because the first user message is dropped. + // The correct behavior is for the history to contain all 4 turns. + expect(history.length).toBe(4); + + // Crucially, assert that the very first turn in the history matches the user's initial message. + // This is the assertion that will fail. + const firstTurn = history[0]!; + expect(firstTurn.role).toBe('user'); + expect(firstTurn?.parts![0]!.text).toBe('How is the weather in Boston?'); + + // Verify the rest of the history is also correct. + const secondTurn = history[1]!; + expect(secondTurn.role).toBe('model'); + expect(secondTurn?.parts![0]!.functionCall).toBeDefined(); + + const thirdTurn = history[2]!; + expect(thirdTurn.role).toBe('user'); + expect(thirdTurn?.parts![0]!.functionResponse).toBeDefined(); + + const fourthTurn = history[3]!; + expect(fourthTurn.role).toBe('model'); + expect(fourthTurn?.parts![0]!.text).toContain('72 degrees and sunny'); + }); + it('should throw an error when attempting to add a user turn after another user turn', async () => { // 1. Setup: Create a history that already ends with a user turn (a functionResponse). const initialHistory: Content[] = [ @@ -240,6 +326,110 @@ describe('GeminiChat', () => { }); describe('sendMessageStream', () => { + it('should succeed if a tool call is followed by an empty part', async () => { + // 1. Mock a stream that contains a tool call, then an invalid (empty) part. + const streamWithToolCall = (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { name: 'test_tool', args: {} } }], + }, + }, + ], + } as unknown as GenerateContentResponse; + // This second chunk is invalid according to isValidResponse + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: '' }], + }, + }, + ], + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockModelsModule.generateContentStream).mockResolvedValue( + streamWithToolCall, + ); + + // 2. Action & Assert: The stream processing should complete without throwing an error + // because the presence of a tool call makes the empty final chunk acceptable. + const stream = await chat.sendMessageStream( + { message: 'test message' }, + 'prompt-id-tool-call-empty-end', + ); + await expect( + (async () => { + for await (const _ of stream) { + /* consume stream */ + } + })(), + ).resolves.not.toThrow(); + + // 3. Verify history was recorded correctly + const history = chat.getHistory(); + expect(history.length).toBe(2); // user turn + model turn + const modelTurn = history[1]!; + expect(modelTurn?.parts?.length).toBe(1); // The empty part is discarded + expect(modelTurn?.parts![0]!.functionCall).toBeDefined(); + }); + + it('should succeed if the stream ends with an empty part but has a valid finishReason', async () => { + // 1. Mock a stream that ends with an invalid part but has a 'STOP' finish reason. + const streamWithValidFinish = (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: 'Initial content...' }], + }, + }, + ], + } as unknown as GenerateContentResponse; + // This second chunk is invalid, but the finishReason should save it from retrying. + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: '' }], + }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockModelsModule.generateContentStream).mockResolvedValue( + streamWithValidFinish, + ); + + // 2. Action & Assert: The stream should complete successfully because the valid + // finishReason overrides the invalid final chunk. + const stream = await chat.sendMessageStream( + { message: 'test message' }, + 'prompt-id-valid-finish-empty-end', + ); + await expect( + (async () => { + for await (const _ of stream) { + /* consume stream */ + } + })(), + ).resolves.not.toThrow(); + + // 3. Verify history was recorded correctly + const history = chat.getHistory(); + expect(history.length).toBe(2); + const modelTurn = history[1]!; + expect(modelTurn?.parts?.length).toBe(1); // The empty part is discarded + expect(modelTurn?.parts![0]!.text).toBe('Initial content...'); + }); it('should not consolidate text into a part that also contains a functionCall', async () => { // 1. Mock the API to stream a malformed part followed by a valid text part. const multiChunkStream = (async function* () { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index f9d8fd1d..6eea6f22 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -562,15 +562,30 @@ export class GeminiChat { userInput: Content, ): AsyncGenerator { const modelResponseParts: Part[] = []; - let isStreamInvalid = false; let hasReceivedAnyChunk = false; + let hasToolCall = false; + let lastChunk: GenerateContentResponse | null = null; + + let isStreamInvalid = false; + let firstInvalidChunkEncountered = false; + let validChunkAfterInvalidEncountered = false; for await (const chunk of streamResponse) { hasReceivedAnyChunk = true; + lastChunk = chunk; + if (isValidResponse(chunk)) { + if (firstInvalidChunkEncountered) { + // A valid chunk appeared *after* an invalid one. + validChunkAfterInvalidEncountered = true; + } + const content = chunk.candidates?.[0]?.content; if (content?.parts) { modelResponseParts.push(...content.parts); + if (content.parts.some((part) => part.functionCall)) { + hasToolCall = true; + } } } else { logInvalidChunk( @@ -578,14 +593,36 @@ export class GeminiChat { new InvalidChunkEvent('Invalid chunk received from stream.'), ); isStreamInvalid = true; + firstInvalidChunkEncountered = true; } yield chunk; } - if (isStreamInvalid || !hasReceivedAnyChunk) { - throw new EmptyStreamError( - 'Model stream was invalid or completed without valid content.', - ); + if (!hasReceivedAnyChunk) { + throw new EmptyStreamError('Model stream completed without any chunks.'); + } + + // --- FIX: The entire validation block was restructured for clarity and correctness --- + // Only apply complex validation if an invalid chunk was actually found. + if (isStreamInvalid) { + // Fail immediately if an invalid chunk was not the absolute last chunk. + if (validChunkAfterInvalidEncountered) { + throw new EmptyStreamError( + 'Model stream had invalid intermediate chunks without a tool call.', + ); + } + + if (!hasToolCall) { + // If the *only* invalid part was the last chunk, we still check its finish reason. + const finishReason = lastChunk?.candidates?.[0]?.finishReason; + const isSuccessfulFinish = + finishReason === 'STOP' || finishReason === 'MAX_TOKENS'; + if (!isSuccessfulFinish) { + throw new EmptyStreamError( + 'Model stream ended with an invalid chunk and a failed finish reason.', + ); + } + } } // Bundle all streamed parts into a single Content object From a2a3c66e28798d45f1e58547e177594f46ae7261 Mon Sep 17 00:00:00 2001 From: Victor May Date: Wed, 3 Sep 2025 22:00:16 -0400 Subject: [PATCH 06/10] Handle cleaning up the response text in the UI when a response stream retry occurs (#7416) --- packages/cli/src/ui/hooks/useGeminiStream.ts | 3 + .../cli/src/zed-integration/zedIntegration.ts | 13 +- packages/core/src/core/geminiChat.test.ts | 125 +++++++- packages/core/src/core/geminiChat.ts | 20 +- packages/core/src/core/subagent.test.ts | 32 +- packages/core/src/core/subagent.ts | 11 +- packages/core/src/core/turn.test.ts | 281 +++++++++++------- packages/core/src/core/turn.ts | 26 +- 8 files changed, 371 insertions(+), 140 deletions(-) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index b8004edb..618fdb76 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -616,6 +616,9 @@ export const useGeminiStream = ( // before we add loop detected message to history loopDetectedRef.current = true; break; + case ServerGeminiEventType.Retry: + // Will add the missing logic later + break; default: { // enforces exhaustive switch-case const unreachable: never = event; diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 77622255..a6a502e4 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -24,6 +24,7 @@ import { getErrorStatus, MCPServerConfig, DiscoveredMCPTool, + StreamEventType, } from '@google/gemini-cli-core'; import * as acp from './acp.js'; import { AcpFileSystemService } from './fileSystemService.js'; @@ -269,8 +270,12 @@ class Session { return { stopReason: 'cancelled' }; } - if (resp.candidates && resp.candidates.length > 0) { - const candidate = resp.candidates[0]; + if ( + resp.type === StreamEventType.CHUNK && + resp.value.candidates && + resp.value.candidates.length > 0 + ) { + const candidate = resp.value.candidates[0]; for (const part of candidate.content?.parts ?? []) { if (!part.text) { continue; @@ -290,8 +295,8 @@ class Session { } } - if (resp.functionCalls) { - functionCalls.push(...resp.functionCalls); + if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) { + functionCalls.push(...resp.value.functionCalls); } } } catch (error) { diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 161f2900..0c8ae913 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -12,7 +12,12 @@ import type { Part, GenerateContentResponse, } from '@google/genai'; -import { GeminiChat, EmptyStreamError } from './geminiChat.js'; +import { + GeminiChat, + EmptyStreamError, + StreamEventType, + type StreamEvent, +} from './geminiChat.js'; import type { Config } from '../config/config.js'; import { setSimulate429 } from '../utils/testUtils.js'; @@ -922,6 +927,42 @@ describe('GeminiChat', () => { }); describe('sendMessageStream with retries', () => { + it('should yield a RETRY event when an invalid stream is encountered', async () => { + // ARRANGE: Mock the stream to fail once, then succeed. + vi.mocked(mockModelsModule.generateContentStream) + .mockImplementationOnce(async () => + // First attempt: An invalid stream with an empty text part. + (async function* () { + yield { + candidates: [{ content: { parts: [{ text: '' }] } }], + } as unknown as GenerateContentResponse; + })(), + ) + .mockImplementationOnce(async () => + // Second attempt (the retry): A minimal valid stream. + (async function* () { + yield { + candidates: [{ content: { parts: [{ text: 'Success' }] } }], + } as unknown as GenerateContentResponse; + })(), + ); + + // ACT: Send a message and collect all events from the stream. + const stream = await chat.sendMessageStream( + { message: 'test' }, + 'prompt-id-yield-retry', + ); + const events: StreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + // ASSERT: Check that a RETRY event was present in the stream's output. + const retryEvent = events.find((e) => e.type === StreamEventType.RETRY); + + expect(retryEvent).toBeDefined(); + expect(retryEvent?.type).toBe(StreamEventType.RETRY); + }); it('should retry on invalid content, succeed, and report metrics', async () => { // Use mockImplementationOnce to provide a fresh, promise-wrapped generator for each attempt. vi.mocked(mockModelsModule.generateContentStream) @@ -948,7 +989,7 @@ describe('GeminiChat', () => { { message: 'test' }, 'prompt-id-retry-success', ); - const chunks = []; + const chunks: StreamEvent[] = []; for await (const chunk of stream) { chunks.push(chunk); } @@ -958,11 +999,17 @@ describe('GeminiChat', () => { expect(mockLogContentRetry).toHaveBeenCalledTimes(1); expect(mockLogContentRetryFailure).not.toHaveBeenCalled(); expect(mockModelsModule.generateContentStream).toHaveBeenCalledTimes(2); + + // Check for a retry event + expect(chunks.some((c) => c.type === StreamEventType.RETRY)).toBe(true); + + // Check for the successful content chunk expect( chunks.some( (c) => - c.candidates?.[0]?.content?.parts?.[0]?.text === - 'Successful response', + c.type === StreamEventType.CHUNK && + c.value.candidates?.[0]?.content?.parts?.[0]?.text === + 'Successful response', ), ).toBe(true); @@ -1203,7 +1250,7 @@ describe('GeminiChat', () => { { message: 'test empty stream' }, 'prompt-id-empty-stream', ); - const chunks = []; + const chunks: StreamEvent[] = []; for await (const chunk of stream) { chunks.push(chunk); } @@ -1213,8 +1260,9 @@ describe('GeminiChat', () => { expect( chunks.some( (c) => - c.candidates?.[0]?.content?.parts?.[0]?.text === - 'Successful response after empty', + c.type === StreamEventType.CHUNK && + c.value.candidates?.[0]?.content?.parts?.[0]?.text === + 'Successful response after empty', ), ).toBe(true); @@ -1313,4 +1361,67 @@ describe('GeminiChat', () => { } expect(turn4.parts[0].text).toBe('second response'); }); + + it('should discard valid partial content from a failed attempt upon retry', async () => { + // ARRANGE: Mock the stream to fail on the first attempt after yielding some valid content. + vi.mocked(mockModelsModule.generateContentStream) + .mockImplementationOnce(async () => + // First attempt: yields one valid chunk, then one invalid chunk + (async function* () { + yield { + candidates: [ + { + content: { + parts: [{ text: 'This valid part should be discarded' }], + }, + }, + ], + } as unknown as GenerateContentResponse; + yield { + candidates: [{ content: { parts: [{ text: '' }] } }], // Invalid chunk triggers retry + } as unknown as GenerateContentResponse; + })(), + ) + .mockImplementationOnce(async () => + // Second attempt (the retry): succeeds + (async function* () { + yield { + candidates: [ + { + content: { + parts: [{ text: 'Successful final response' }], + }, + }, + ], + } as unknown as GenerateContentResponse; + })(), + ); + + // ACT: Send a message and consume the stream + const stream = await chat.sendMessageStream( + { message: 'test' }, + 'prompt-id-discard-test', + ); + const events: StreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + // ASSERT + // Check that a retry happened + expect(mockModelsModule.generateContentStream).toHaveBeenCalledTimes(2); + expect(events.some((e) => e.type === StreamEventType.RETRY)).toBe(true); + + // Check the final recorded history + const history = chat.getHistory(); + expect(history.length).toBe(2); // user turn + final model turn + + const modelTurn = history[1]!; + // The model turn should only contain the text from the successful attempt + expect(modelTurn!.parts![0]!.text).toBe('Successful final response'); + // It should NOT contain any text from the failed attempt + expect(modelTurn!.parts![0]!.text).not.toContain( + 'This valid part should be discarded', + ); + }); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 6eea6f22..fabe9f70 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -34,6 +34,18 @@ import { InvalidChunkEvent, } from '../telemetry/types.js'; +export enum StreamEventType { + /** A regular content chunk from the API. */ + CHUNK = 'chunk', + /** A signal that a retry is about to happen. The UI should discard any partial + * content from the attempt that just failed. */ + RETRY = 'retry', +} + +export type StreamEvent = + | { type: StreamEventType.CHUNK; value: GenerateContentResponse } + | { type: StreamEventType.RETRY }; + /** * Options for retrying due to invalid content from the model. */ @@ -340,7 +352,7 @@ export class GeminiChat { async sendMessageStream( params: SendMessageParameters, prompt_id: string, - ): Promise> { + ): Promise> { await this.sendPromise; let streamDoneResolver: () => void; @@ -367,6 +379,10 @@ export class GeminiChat { attempt++ ) { try { + if (attempt > 0) { + yield { type: StreamEventType.RETRY }; + } + const stream = await self.makeApiCallAndProcessStream( requestContents, params, @@ -375,7 +391,7 @@ export class GeminiChat { ); for await (const chunk of stream) { - yield chunk; + yield { type: StreamEventType.CHUNK, value: chunk }; } lastError = null; diff --git a/packages/core/src/core/subagent.test.ts b/packages/core/src/core/subagent.test.ts index f2073306..cc54037b 100644 --- a/packages/core/src/core/subagent.test.ts +++ b/packages/core/src/core/subagent.test.ts @@ -21,7 +21,7 @@ import type { } from './subagent.js'; import { Config } from '../config/config.js'; import type { ConfigParameters } from '../config/config.js'; -import { GeminiChat } from './geminiChat.js'; +import { GeminiChat, StreamEventType } from './geminiChat.js'; import { createContentGenerator } from './contentGenerator.js'; import { getEnvironmentContext } from '../utils/environmentContext.js'; import { executeToolCall } from './nonInteractiveToolExecutor.js'; @@ -33,6 +33,7 @@ import type { FunctionCall, FunctionDeclaration, GenerateContentConfig, + GenerateContentResponse, } from '@google/genai'; import { ToolErrorType } from '../tools/tool-error.js'; @@ -73,18 +74,33 @@ const createMockStream = ( functionCallsList: Array, ) => { let index = 0; - return vi.fn().mockImplementation(() => { + // This mock now returns a Promise that resolves to the async generator, + // matching the new signature for sendMessageStream. + return vi.fn().mockImplementation(async () => { const response = functionCallsList[index] || 'stop'; index++; + return (async function* () { - if (response === 'stop') { - // When stopping, the model might return text, but the subagent logic primarily cares about the absence of functionCalls. - yield { text: 'Done.' }; - } else if (response.length > 0) { - yield { functionCalls: response }; + let mockResponseValue: Partial; + + if (response === 'stop' || response.length === 0) { + // Simulate a text response for stop/empty conditions. + mockResponseValue = { + candidates: [{ content: { parts: [{ text: 'Done.' }] } }], + }; } else { - yield { text: 'Done.' }; // Handle empty array also as stop + // Simulate a tool call response. + mockResponseValue = { + candidates: [], // Good practice to include for safety. + functionCalls: response, + }; } + + // The stream must now yield a StreamEvent object of type CHUNK. + yield { + type: StreamEventType.CHUNK, + value: mockResponseValue as GenerateContentResponse, + }; })(); }); }; diff --git a/packages/core/src/core/subagent.ts b/packages/core/src/core/subagent.ts index 75f77c57..41de5978 100644 --- a/packages/core/src/core/subagent.ts +++ b/packages/core/src/core/subagent.ts @@ -20,7 +20,7 @@ import type { FunctionDeclaration, } from '@google/genai'; import { Type } from '@google/genai'; -import { GeminiChat } from './geminiChat.js'; +import { GeminiChat, StreamEventType } from './geminiChat.js'; /** * @fileoverview Defines the configuration interfaces for a subagent. @@ -439,12 +439,11 @@ export class SubAgentScope { let textResponse = ''; for await (const resp of responseStream) { if (abortController.signal.aborted) return; - if (resp.functionCalls) { - functionCalls.push(...resp.functionCalls); + if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) { + functionCalls.push(...resp.value.functionCalls); } - const text = resp.text; - if (text) { - textResponse += text; + if (resp.type === StreamEventType.CHUNK && resp.value.text) { + textResponse += resp.value.text; } } diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index c749baba..87be432b 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -13,6 +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'; const mockSendMessageStream = vi.fn(); const mockGetHistory = vi.fn(); @@ -35,6 +36,7 @@ vi.mock('../utils/errorReporting', () => ({ reportError: vi.fn(), })); +// Use the actual implementation from partUtils now that it's provided. vi.mock('../utils/generateContentResponseUtilities', () => ({ getResponseText: (resp: GenerateContentResponse) => resp.candidates?.[0]?.content?.parts?.map((part) => part.text).join('') || @@ -78,11 +80,17 @@ describe('Turn', () => { it('should yield content events for text parts', async () => { const mockResponseStream = (async function* () { yield { - candidates: [{ content: { parts: [{ text: 'Hello' }] } }], - } as unknown as GenerateContentResponse; + type: StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'Hello' }] } }], + } as GenerateContentResponse, + }; yield { - candidates: [{ content: { parts: [{ text: ' world' }] } }], - } as unknown as GenerateContentResponse; + type: StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: ' world' }] } }], + } as GenerateContentResponse, + }; })(); mockSendMessageStream.mockResolvedValue(mockResponseStream); @@ -113,16 +121,23 @@ describe('Turn', () => { it('should yield tool_call_request events for function calls', async () => { const mockResponseStream = (async function* () { yield { - functionCalls: [ - { - id: 'fc1', - name: 'tool1', - args: { arg1: 'val1' }, - isClientInitiated: false, - }, - { name: 'tool2', args: { arg2: 'val2' }, isClientInitiated: false }, // No ID - ], - } as unknown as GenerateContentResponse; + type: StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'fc1', + name: 'tool1', + args: { arg1: 'val1' }, + isClientInitiated: false, + }, + { + name: 'tool2', + args: { arg2: 'val2' }, + isClientInitiated: false, + }, // No ID + ], + } as unknown as GenerateContentResponse, + }; })(); mockSendMessageStream.mockResolvedValue(mockResponseStream); @@ -168,18 +183,24 @@ describe('Turn', () => { const abortController = new AbortController(); const mockResponseStream = (async function* () { yield { - candidates: [{ content: { parts: [{ text: 'First part' }] } }], - } as unknown as GenerateContentResponse; + type: StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'First part' }] } }], + } as GenerateContentResponse, + }; abortController.abort(); yield { - candidates: [ - { - content: { - parts: [{ text: 'Second part - should not be processed' }], + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + parts: [{ text: 'Second part - should not be processed' }], + }, }, - }, - ], - } as unknown as GenerateContentResponse; + ], + } as GenerateContentResponse, + }; })(); mockSendMessageStream.mockResolvedValue(mockResponseStream); @@ -230,79 +251,79 @@ describe('Turn', () => { it('should handle function calls with undefined name or args', async () => { const mockResponseStream = (async function* () { yield { - functionCalls: [ - { id: 'fc1', name: undefined, args: { arg1: 'val1' } }, - { id: 'fc2', name: 'tool2', args: undefined }, - { id: 'fc3', name: undefined, args: undefined }, - ], - } as unknown as GenerateContentResponse; + type: StreamEventType.CHUNK, + value: { + candidates: [], + functionCalls: [ + // Add `id` back to the mock to match what the code expects + { id: 'fc1', name: undefined, args: { arg1: 'val1' } }, + { id: 'fc2', name: 'tool2', args: undefined }, + { id: 'fc3', name: undefined, args: undefined }, + ], + }, + }; })(); mockSendMessageStream.mockResolvedValue(mockResponseStream); + const events = []; - const reqParts: Part[] = [{ text: 'Test undefined tool parts' }]; for await (const event of turn.run( - reqParts, + [{ text: 'Test undefined tool parts' }], new AbortController().signal, )) { events.push(event); } expect(events.length).toBe(3); + + // Assertions for each specific tool call event const event1 = events[0] as ServerGeminiToolCallRequestEvent; - expect(event1.type).toBe(GeminiEventType.ToolCallRequest); - expect(event1.value).toEqual( - expect.objectContaining({ - callId: 'fc1', - name: 'undefined_tool_name', - args: { arg1: 'val1' }, - isClientInitiated: false, - }), - ); - expect(turn.pendingToolCalls[0]).toEqual(event1.value); + expect(event1.value).toMatchObject({ + callId: 'fc1', + name: 'undefined_tool_name', + args: { arg1: 'val1' }, + }); const event2 = events[1] as ServerGeminiToolCallRequestEvent; - expect(event2.type).toBe(GeminiEventType.ToolCallRequest); - expect(event2.value).toEqual( - expect.objectContaining({ - callId: 'fc2', - name: 'tool2', - args: {}, - isClientInitiated: false, - }), - ); - expect(turn.pendingToolCalls[1]).toEqual(event2.value); + expect(event2.value).toMatchObject({ + callId: 'fc2', + name: 'tool2', + args: {}, + }); const event3 = events[2] as ServerGeminiToolCallRequestEvent; - expect(event3.type).toBe(GeminiEventType.ToolCallRequest); - expect(event3.value).toEqual( - expect.objectContaining({ - callId: 'fc3', - name: 'undefined_tool_name', - args: {}, - isClientInitiated: false, - }), - ); - expect(turn.pendingToolCalls[2]).toEqual(event3.value); - expect(turn.getDebugResponses().length).toBe(1); + expect(event3.value).toMatchObject({ + callId: 'fc3', + name: 'undefined_tool_name', + args: {}, + }); }); it('should yield finished event when response has finish reason', async () => { const mockResponseStream = (async function* () { yield { - candidates: [ - { - content: { parts: [{ text: 'Partial response' }] }, - finishReason: 'STOP', + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { parts: [{ text: 'Partial response' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 17, + candidatesTokenCount: 50, + cachedContentTokenCount: 10, + thoughtsTokenCount: 5, + toolUsePromptTokenCount: 2, }, - ], - } as unknown as GenerateContentResponse; + } as GenerateContentResponse, + }; })(); mockSendMessageStream.mockResolvedValue(mockResponseStream); const events = []; - const reqParts: Part[] = [{ text: 'Test finish reason' }]; for await (const event of turn.run( - reqParts, + [{ text: 'Test finish reason' }], new AbortController().signal, )) { events.push(event); @@ -317,17 +338,20 @@ describe('Turn', () => { it('should yield finished event for MAX_TOKENS finish reason', async () => { const mockResponseStream = (async function* () { yield { - candidates: [ - { - content: { - parts: [ - { text: 'This is a long response that was cut off...' }, - ], + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + parts: [ + { text: 'This is a long response that was cut off...' }, + ], + }, + finishReason: 'MAX_TOKENS', }, - finishReason: 'MAX_TOKENS', - }, - ], - } as unknown as GenerateContentResponse; + ], + }, + }; })(); mockSendMessageStream.mockResolvedValue(mockResponseStream); @@ -352,13 +376,16 @@ describe('Turn', () => { it('should yield finished event for SAFETY finish reason', async () => { const mockResponseStream = (async function* () { yield { - candidates: [ - { - content: { parts: [{ text: 'Content blocked' }] }, - finishReason: 'SAFETY', - }, - ], - } as unknown as GenerateContentResponse; + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { parts: [{ text: 'Content blocked' }] }, + finishReason: 'SAFETY', + }, + ], + }, + }; })(); mockSendMessageStream.mockResolvedValue(mockResponseStream); @@ -380,13 +407,18 @@ describe('Turn', () => { it('should not yield finished event when there is no finish reason', async () => { const mockResponseStream = (async function* () { yield { - candidates: [ - { - content: { parts: [{ text: 'Response without finish reason' }] }, - // No finishReason property - }, - ], - } as unknown as GenerateContentResponse; + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + parts: [{ text: 'Response without finish reason' }], + }, + // No finishReason property + }, + ], + }, + }; })(); mockSendMessageStream.mockResolvedValue(mockResponseStream); @@ -411,21 +443,27 @@ describe('Turn', () => { it('should handle multiple responses with different finish reasons', async () => { const mockResponseStream = (async function* () { yield { - candidates: [ - { - content: { parts: [{ text: 'First part' }] }, - // No finish reason on first response - }, - ], - } as unknown as GenerateContentResponse; + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { parts: [{ text: 'First part' }] }, + // No finish reason on first response + }, + ], + }, + }; yield { - candidates: [ - { - content: { parts: [{ text: 'Second part' }] }, - finishReason: 'OTHER', - }, - ], - } as unknown as GenerateContentResponse; + value: { + type: StreamEventType.CHUNK, + candidates: [ + { + content: { parts: [{ text: 'Second part' }] }, + finishReason: 'OTHER', + }, + ], + }, + }; })(); mockSendMessageStream.mockResolvedValue(mockResponseStream); @@ -470,6 +508,29 @@ describe('Turn', () => { expect(reportError).not.toHaveBeenCalled(); }); + + it('should yield a Retry event when it receives one from the chat stream', async () => { + const mockResponseStream = (async function* () { + yield { type: StreamEventType.RETRY }; + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'Success' }] } }], + }, + }; + })(); + mockSendMessageStream.mockResolvedValue(mockResponseStream); + + const events = []; + for await (const event of turn.run([], new AbortController().signal)) { + events.push(event); + } + + expect(events).toEqual([ + { type: GeminiEventType.Retry }, + { type: GeminiEventType.Content, value: 'Success' }, + ]); + }); }); describe('getDebugResponses', () => { @@ -481,8 +542,8 @@ describe('Turn', () => { functionCalls: [{ name: 'debugTool' }], } as unknown as GenerateContentResponse; const mockResponseStream = (async function* () { - yield resp1; - yield resp2; + yield { type: StreamEventType.CHUNK, value: resp1 }; + yield { type: StreamEventType.CHUNK, value: resp2 }; })(); mockSendMessageStream.mockResolvedValue(mockResponseStream); const reqParts: Part[] = [{ text: 'Hi' }]; diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index ab46ee00..abd74e44 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -54,8 +54,14 @@ export enum GeminiEventType { MaxSessionTurns = 'max_session_turns', Finished = 'finished', LoopDetected = 'loop_detected', + Citation = 'citation', + Retry = 'retry', } +export type ServerGeminiRetryEvent = { + type: GeminiEventType.Retry; +}; + export interface StructuredError { message: string; status?: number; @@ -175,7 +181,8 @@ export type ServerGeminiStreamEvent = | ServerGeminiThoughtEvent | ServerGeminiMaxSessionTurnsEvent | ServerGeminiFinishedEvent - | ServerGeminiLoopDetectedEvent; + | ServerGeminiLoopDetectedEvent + | ServerGeminiRetryEvent; // A turn manages the agentic loop turn within the server context. export class Turn { @@ -197,6 +204,8 @@ export class Turn { signal: AbortSignal, ): AsyncGenerator { try { + // Note: This assumes `sendMessageStream` yields events like + // { type: StreamEventType.RETRY } or { type: StreamEventType.CHUNK, value: GenerateContentResponse } const responseStream = await this.chat.sendMessageStream( { message: req, @@ -207,12 +216,22 @@ export class Turn { this.prompt_id, ); - for await (const resp of responseStream) { + for await (const streamEvent of responseStream) { if (signal?.aborted) { yield { type: GeminiEventType.UserCancelled }; - // Do not add resp to debugResponses if aborted before processing return; } + + // Handle the new RETRY event + if (streamEvent.type === 'retry') { + yield { type: GeminiEventType.Retry }; + continue; // Skip to the next event in the stream + } + + // Assuming other events are chunks with a `value` property + const resp = streamEvent.value as GenerateContentResponse; + if (!resp) continue; // Skip if there's no response body + this.debugResponses.push(resp); const thoughtPart = resp.candidates?.[0]?.content?.parts?.[0]; @@ -254,6 +273,7 @@ export class Turn { // Check if response was truncated or stopped for various reasons const finishReason = resp.candidates?.[0]?.finishReason; + // This is the key change: Only yield 'Finished' if there is a finishReason. if (finishReason) { this.finishReason = finishReason; yield { From ddd4659d10d8cd2ae19ce9f2fe32e28e1cd41584 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Fri, 5 Sep 2025 16:22:54 -0700 Subject: [PATCH 07/10] Fix(core): Fix stream validation logic (#7832) --- packages/core/src/core/geminiChat.test.ts | 71 +++++++++++++++-------- packages/core/src/core/geminiChat.ts | 49 ++++++---------- 2 files changed, 65 insertions(+), 55 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 0c8ae913..e2ccc305 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -383,9 +383,9 @@ describe('GeminiChat', () => { expect(modelTurn?.parts![0]!.functionCall).toBeDefined(); }); - it('should succeed if the stream ends with an empty part but has a valid finishReason', async () => { - // 1. Mock a stream that ends with an invalid part but has a 'STOP' finish reason. - const streamWithValidFinish = (async function* () { + it('should fail if the stream ends with an empty part and has no finishReason', async () => { + // 1. Mock a stream that ends with an invalid part and has no finish reason. + const streamWithNoFinish = (async function* () { yield { candidates: [ { @@ -396,7 +396,7 @@ describe('GeminiChat', () => { }, ], } as unknown as GenerateContentResponse; - // This second chunk is invalid, but the finishReason should save it from retrying. + // This second chunk is invalid and has no finishReason, so it should fail. yield { candidates: [ { @@ -404,21 +404,19 @@ describe('GeminiChat', () => { role: 'model', parts: [{ text: '' }], }, - finishReason: 'STOP', }, ], } as unknown as GenerateContentResponse; })(); vi.mocked(mockModelsModule.generateContentStream).mockResolvedValue( - streamWithValidFinish, + streamWithNoFinish, ); - // 2. Action & Assert: The stream should complete successfully because the valid - // finishReason overrides the invalid final chunk. + // 2. Action & Assert: The stream should fail because there's no finish reason. const stream = await chat.sendMessageStream( { message: 'test message' }, - 'prompt-id-valid-finish-empty-end', + 'prompt-id-no-finish-empty-end', ); await expect( (async () => { @@ -426,14 +424,7 @@ describe('GeminiChat', () => { /* consume stream */ } })(), - ).resolves.not.toThrow(); - - // 3. Verify history was recorded correctly - const history = chat.getHistory(); - expect(history.length).toBe(2); - const modelTurn = history[1]!; - expect(modelTurn?.parts?.length).toBe(1); // The empty part is discarded - expect(modelTurn?.parts![0]!.text).toBe('Initial content...'); + ).rejects.toThrow(EmptyStreamError); }); it('should not consolidate text into a part that also contains a functionCall', async () => { // 1. Mock the API to stream a malformed part followed by a valid text part. @@ -509,7 +500,10 @@ describe('GeminiChat', () => { // as the important part is consolidating what comes after. yield { candidates: [ - { content: { role: 'model', parts: [{ text: ' World!' }] } }, + { + content: { role: 'model', parts: [{ text: ' World!' }] }, + finishReason: 'STOP', + }, ], } as unknown as GenerateContentResponse; })(); @@ -612,6 +606,7 @@ describe('GeminiChat', () => { { text: 'This is the visible text that should not be lost.' }, ], }, + finishReason: 'STOP', }, ], } as unknown as GenerateContentResponse; @@ -672,7 +667,10 @@ describe('GeminiChat', () => { const emptyStreamResponse = (async function* () { yield { candidates: [ - { content: { role: 'model', parts: [{ thought: true }] } }, + { + content: { role: 'model', parts: [{ thought: true }] }, + finishReason: 'STOP', + }, ], } as unknown as GenerateContentResponse; })(); @@ -942,7 +940,12 @@ describe('GeminiChat', () => { // Second attempt (the retry): A minimal valid stream. (async function* () { yield { - candidates: [{ content: { parts: [{ text: 'Success' }] } }], + candidates: [ + { + content: { parts: [{ text: 'Success' }] }, + finishReason: 'STOP', + }, + ], } as unknown as GenerateContentResponse; })(), ); @@ -979,7 +982,10 @@ describe('GeminiChat', () => { (async function* () { yield { candidates: [ - { content: { parts: [{ text: 'Successful response' }] } }, + { + content: { parts: [{ text: 'Successful response' }] }, + finishReason: 'STOP', + }, ], } as unknown as GenerateContentResponse; })(), @@ -1090,7 +1096,12 @@ describe('GeminiChat', () => { // Second attempt succeeds (async function* () { yield { - candidates: [{ content: { parts: [{ text: 'Second answer' }] } }], + candidates: [ + { + content: { parts: [{ text: 'Second answer' }] }, + finishReason: 'STOP', + }, + ], } as unknown as GenerateContentResponse; })(), ); @@ -1239,6 +1250,7 @@ describe('GeminiChat', () => { content: { parts: [{ text: 'Successful response after empty' }], }, + finishReason: 'STOP', }, ], } as unknown as GenerateContentResponse; @@ -1300,13 +1312,23 @@ describe('GeminiChat', () => { } as unknown as GenerateContentResponse; await firstStreamContinuePromise; // Pause the stream yield { - candidates: [{ content: { parts: [{ text: ' part 2' }] } }], + candidates: [ + { + content: { parts: [{ text: ' part 2' }] }, + finishReason: 'STOP', + }, + ], } as unknown as GenerateContentResponse; })(); const secondStreamGenerator = (async function* () { yield { - candidates: [{ content: { parts: [{ text: 'second response' }] } }], + candidates: [ + { + content: { parts: [{ text: 'second response' }] }, + finishReason: 'STOP', + }, + ], } as unknown as GenerateContentResponse; })(); @@ -1391,6 +1413,7 @@ describe('GeminiChat', () => { content: { parts: [{ text: 'Successful final response' }], }, + finishReason: 'STOP', }, ], } as unknown as GenerateContentResponse; diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index fabe9f70..4c62887e 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -581,21 +581,14 @@ export class GeminiChat { let hasReceivedAnyChunk = false; let hasToolCall = false; let lastChunk: GenerateContentResponse | null = null; - - let isStreamInvalid = false; - let firstInvalidChunkEncountered = false; - let validChunkAfterInvalidEncountered = false; + let lastChunkIsInvalid = false; for await (const chunk of streamResponse) { hasReceivedAnyChunk = true; lastChunk = chunk; if (isValidResponse(chunk)) { - if (firstInvalidChunkEncountered) { - // A valid chunk appeared *after* an invalid one. - validChunkAfterInvalidEncountered = true; - } - + lastChunkIsInvalid = false; const content = chunk.candidates?.[0]?.content; if (content?.parts) { modelResponseParts.push(...content.parts); @@ -608,8 +601,7 @@ export class GeminiChat { this.config, new InvalidChunkEvent('Invalid chunk received from stream.'), ); - isStreamInvalid = true; - firstInvalidChunkEncountered = true; + lastChunkIsInvalid = true; } yield chunk; } @@ -618,27 +610,22 @@ export class GeminiChat { throw new EmptyStreamError('Model stream completed without any chunks.'); } - // --- FIX: The entire validation block was restructured for clarity and correctness --- - // Only apply complex validation if an invalid chunk was actually found. - if (isStreamInvalid) { - // Fail immediately if an invalid chunk was not the absolute last chunk. - if (validChunkAfterInvalidEncountered) { - throw new EmptyStreamError( - 'Model stream had invalid intermediate chunks without a tool call.', - ); - } + const hasFinishReason = lastChunk?.candidates?.some( + (candidate) => candidate.finishReason, + ); - if (!hasToolCall) { - // If the *only* invalid part was the last chunk, we still check its finish reason. - const finishReason = lastChunk?.candidates?.[0]?.finishReason; - const isSuccessfulFinish = - finishReason === 'STOP' || finishReason === 'MAX_TOKENS'; - if (!isSuccessfulFinish) { - throw new EmptyStreamError( - 'Model stream ended with an invalid chunk and a failed finish reason.', - ); - } - } + // --- FIX: The entire validation block was restructured for clarity and correctness --- + // Stream validation logic: A stream is considered successful if: + // 1. There's a tool call (tool calls can end without explicit finish reasons), OR + // 2. Both conditions are met: last chunk is valid AND any candidate has a finish reason + // + // We throw an error only when there's no tool call AND either: + // - The last chunk is invalid, OR + // - No candidate in the last chunk has a finish reason + if (!hasToolCall && (lastChunkIsInvalid || !hasFinishReason)) { + throw new EmptyStreamError( + 'Model stream ended with an invalid chunk or missing finish reason.', + ); } // Bundle all streamed parts into a single Content object From 443148ea1e3854706e2e087e5db5c46a249cfcf9 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Sat, 6 Sep 2025 01:12:31 +0000 Subject: [PATCH 08/10] chore(release): v0.3.3 --- package-lock.json | 14 +++++++------- package.json | 4 ++-- packages/a2a-server/package.json | 2 +- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index e1980184..5dc72d47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.3.2", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.3.2", + "version": "0.3.3", "workspaces": [ "packages/*" ], @@ -14235,7 +14235,7 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.3.2", + "version": "0.3.3", "dependencies": { "@a2a-js/sdk": "^0.3.2", "@google-cloud/storage": "^7.16.0", @@ -14506,7 +14506,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.3.2", + "version": "0.3.3", "dependencies": { "@google/gemini-cli-core": "file:../core", "@google/genai": "1.13.0", @@ -14690,7 +14690,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.3.2", + "version": "0.3.3", "dependencies": { "@google/genai": "1.13.0", "@lvce-editor/ripgrep": "^1.6.0", @@ -14812,7 +14812,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.3.2", + "version": "0.3.3", "license": "Apache-2.0", "devDependencies": { "typescript": "^5.3.3" @@ -14823,7 +14823,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.3.2", + "version": "0.3.3", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index 2ba5eda3..5ef898fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.3.2", + "version": "0.3.3", "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.3.2" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.3" }, "scripts": { "start": "node scripts/start.js", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index f54b1eb5..795d2d3b 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.3.2", + "version": "0.3.3", "private": true, "description": "Gemini CLI A2A Server", "repository": { diff --git a/packages/cli/package.json b/packages/cli/package.json index f19449c6..e7cb25f9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.3.2", + "version": "0.3.3", "description": "Gemini CLI", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.2" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.3" }, "dependencies": { "@google/gemini-cli-core": "file:../core", diff --git a/packages/core/package.json b/packages/core/package.json index 8e009fea..cd50fdda 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-core", - "version": "0.3.2", + "version": "0.3.3", "description": "Gemini CLI Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 516a7e29..83538425 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-test-utils", - "version": "0.3.2", + "version": "0.3.3", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index b98b9002..13928d62 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -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.3.2", + "version": "0.3.3", "publisher": "google", "icon": "assets/icon.png", "repository": { From 2acadecaa1407f8b9b6281cc2fdcaec92d34d2ce Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Sat, 6 Sep 2025 00:01:21 -0700 Subject: [PATCH 09/10] Fix(core): Do not retry if last chunk is empty with finishReason previous chunks are good (#7859) --- packages/core/src/core/geminiChat.test.ts | 52 +++++++++++++++++++++++ packages/core/src/core/geminiChat.ts | 16 ++++--- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index e2ccc305..a69292fb 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -426,6 +426,58 @@ describe('GeminiChat', () => { })(), ).rejects.toThrow(EmptyStreamError); }); + + it('should succeed if the stream ends with an invalid part but has a finishReason and contained a valid part', async () => { + // 1. Mock a stream that sends a valid chunk, then an invalid one, but has a finish reason. + const streamWithInvalidEnd = (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: 'Initial valid content...' }], + }, + }, + ], + } as unknown as GenerateContentResponse; + // This second chunk is invalid, but the response has a finishReason. + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: '' }], // Invalid part + }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockModelsModule.generateContentStream).mockResolvedValue( + streamWithInvalidEnd, + ); + + // 2. Action & Assert: The stream should complete without throwing an error. + const stream = await chat.sendMessageStream( + { message: 'test message' }, + 'prompt-id-valid-then-invalid-end', + ); + await expect( + (async () => { + for await (const _ of stream) { + /* consume stream */ + } + })(), + ).resolves.not.toThrow(); + + // 3. Verify history was recorded correctly with only the valid part. + const history = chat.getHistory(); + expect(history.length).toBe(2); // user turn + model turn + const modelTurn = history[1]!; + expect(modelTurn?.parts?.length).toBe(1); + expect(modelTurn?.parts![0]!.text).toBe('Initial valid content...'); + }); it('should not consolidate text into a part that also contains a functionCall', async () => { // 1. Mock the API to stream a malformed part followed by a valid text part. const multiChunkStream = (async function* () { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 4c62887e..5a4f578e 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -579,6 +579,7 @@ export class GeminiChat { ): AsyncGenerator { const modelResponseParts: Part[] = []; let hasReceivedAnyChunk = false; + let hasReceivedValidChunk = false; let hasToolCall = false; let lastChunk: GenerateContentResponse | null = null; let lastChunkIsInvalid = false; @@ -588,6 +589,7 @@ export class GeminiChat { lastChunk = chunk; if (isValidResponse(chunk)) { + hasReceivedValidChunk = true; lastChunkIsInvalid = false; const content = chunk.candidates?.[0]?.content; if (content?.parts) { @@ -614,15 +616,17 @@ export class GeminiChat { (candidate) => candidate.finishReason, ); - // --- FIX: The entire validation block was restructured for clarity and correctness --- // Stream validation logic: A stream is considered successful if: // 1. There's a tool call (tool calls can end without explicit finish reasons), OR - // 2. Both conditions are met: last chunk is valid AND any candidate has a finish reason + // 2. There's a finish reason AND the last chunk is valid (or we haven't received any valid chunks) // - // We throw an error only when there's no tool call AND either: - // - The last chunk is invalid, OR - // - No candidate in the last chunk has a finish reason - if (!hasToolCall && (lastChunkIsInvalid || !hasFinishReason)) { + // We throw an error only when there's no tool call AND: + // - No finish reason, OR + // - Last chunk is invalid after receiving valid content + if ( + !hasToolCall && + (!hasFinishReason || (lastChunkIsInvalid && !hasReceivedValidChunk)) + ) { throw new EmptyStreamError( 'Model stream ended with an invalid chunk or missing finish reason.', ); From 20b6246dded354791e9634cb03c9c16e3a8f1889 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Sat, 6 Sep 2025 18:56:20 +0000 Subject: [PATCH 10/10] chore(release): v0.3.4 --- package-lock.json | 14 +++++++------- package.json | 4 ++-- packages/a2a-server/package.json | 2 +- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5dc72d47..7448c7e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.3.3", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.3.3", + "version": "0.3.4", "workspaces": [ "packages/*" ], @@ -14235,7 +14235,7 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.3.3", + "version": "0.3.4", "dependencies": { "@a2a-js/sdk": "^0.3.2", "@google-cloud/storage": "^7.16.0", @@ -14506,7 +14506,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.3.3", + "version": "0.3.4", "dependencies": { "@google/gemini-cli-core": "file:../core", "@google/genai": "1.13.0", @@ -14690,7 +14690,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.3.3", + "version": "0.3.4", "dependencies": { "@google/genai": "1.13.0", "@lvce-editor/ripgrep": "^1.6.0", @@ -14812,7 +14812,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.3.3", + "version": "0.3.4", "license": "Apache-2.0", "devDependencies": { "typescript": "^5.3.3" @@ -14823,7 +14823,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.3.3", + "version": "0.3.4", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index 5ef898fd..232ee82c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.3.3", + "version": "0.3.4", "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.3.3" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.4" }, "scripts": { "start": "node scripts/start.js", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index 795d2d3b..aa0a4f23 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.3.3", + "version": "0.3.4", "private": true, "description": "Gemini CLI A2A Server", "repository": { diff --git a/packages/cli/package.json b/packages/cli/package.json index e7cb25f9..9b26585a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.3.3", + "version": "0.3.4", "description": "Gemini CLI", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.3" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.4" }, "dependencies": { "@google/gemini-cli-core": "file:../core", diff --git a/packages/core/package.json b/packages/core/package.json index cd50fdda..a8ef7a22 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-core", - "version": "0.3.3", + "version": "0.3.4", "description": "Gemini CLI Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 83538425..b89f3611 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-test-utils", - "version": "0.3.3", + "version": "0.3.4", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 13928d62..1dcdfdfb 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -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.3.3", + "version": "0.3.4", "publisher": "google", "icon": "assets/icon.png", "repository": {