Compare commits

...

13 Commits

Author SHA1 Message Date
gemini-cli-robot c9dbf70043 chore(release): v0.25.0 2026-01-21 05:09:32 +00:00
gemini-cli-robot eb88343419 chore(release): v0.25.0-preview.4 2026-01-20 18:09:08 +00:00
Adam Weidman 217f277580 fix: update currentSequenceModel when modelChanged (#17051) 2026-01-20 09:40:43 -08:00
Pyry Takala 02e68e4554 Fix: Process all parts in response chunks when thought is first (#13539) 2026-01-20 09:40:41 -08:00
gemini-cli-robot 982fd1fc29 chore(release): v0.25.0-preview.3 2026-01-20 17:21:59 +00:00
gemini-cli-robot 6289c3ee3f fix(patch): cherry-pick 3b55581 to release/v0.25.0-preview.2-pr-16506 to patch version v0.25.0-preview.2 and create version 0.25.0-preview.3 (#17098)
Co-authored-by: christine betts <chrstn@uw.edu>
2026-01-20 08:56:21 -08:00
gemini-cli-robot b1f7a7e6f7 chore(release): v0.25.0-preview.2 2026-01-20 05:59:38 +00:00
N. Taylor Mullen ea0e3de430 fix(core): deduplicate ModelInfo emission in GeminiClient (#17075) 2026-01-19 21:26:09 -08:00
N. Taylor Mullen f6a5fa0e03 fix(ui): ensure rationale renders before tool calls (#17043) 2026-01-19 21:25:49 -08:00
gemini-cli-robot de86bccd0d chore(release): v0.25.0-preview.1 2026-01-19 01:50:22 +00:00
christine betts 46079d9daa Patch #16730 into v0.25.0 preview (#16882)
Co-authored-by: Sehoon Shon <sshon@google.com>
2026-01-16 20:36:17 +00:00
gemini-cli-robot 1d5e792a41 fix(patch): cherry-pick cfdc4cf to release/v0.25.0-preview.0-pr-16759 to patch version v0.25.0-preview.0 and create version 0.25.0-preview.1 (#16866)
Co-authored-by: christine betts <chrstn@uw.edu>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-16 11:20:33 -08:00
gemini-cli-robot cd7a5c9604 chore(release): v0.25.0-preview.0 2026-01-14 13:13:18 +00:00
30 changed files with 488 additions and 144 deletions
+5
View File
@@ -830,6 +830,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.extensionConfig`** (boolean):
- **Description:** Enable requesting and fetching of extension settings.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.extensionReloading`** (boolean):
- **Description:** Enables extension loading/unloading within the CLI session.
- **Default:** `false`
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.25.0-nightly.20260107.59a18e710",
"version": "0.25.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.25.0-nightly.20260107.59a18e710",
"version": "0.25.0",
"workspaces": [
"packages/*"
],
@@ -18382,7 +18382,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.25.0-nightly.20260107.59a18e710",
"version": "0.25.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.7",
"@google-cloud/storage": "^7.16.0",
@@ -18692,7 +18692,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.25.0-nightly.20260107.59a18e710",
"version": "0.25.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.11.0",
@@ -18795,7 +18795,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.25.0-nightly.20260107.59a18e710",
"version": "0.25.0",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.7",
@@ -18954,7 +18954,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.25.0-nightly.20260107.59a18e710",
"version": "0.25.0",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18971,7 +18971,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.25.0-nightly.20260107.59a18e710",
"version": "0.25.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.25.0-nightly.20260107.59a18e710",
"version": "0.25.0",
"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.25.0-nightly.20260107.59a18e710"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.25.0"
},
"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.25.0-nightly.20260107.59a18e710",
"version": "0.25.0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.25.0-nightly.20260107.59a18e710",
"version": "0.25.0",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.25.0-nightly.20260107.59a18e710"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.25.0"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.11.0",
@@ -12,7 +12,8 @@ import {
getScopedEnvContents,
} from '../../config/extensions/extensionSettings.js';
import { getExtensionAndManager, getExtensionManager } from './utils.js';
import { debugLogger } from '@google/gemini-cli-core';
import { loadSettings } from '../../config/settings.js';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
import prompts from 'prompts';
import type { ExtensionConfig } from '../../config/extension.js';
@@ -43,6 +44,16 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
}),
handler: async (args) => {
const { name, setting, scope } = args;
const settings = loadSettings(process.cwd()).merged;
if (!(settings.experimental?.extensionConfig ?? true)) {
coreEvents.emitFeedback(
'error',
'Extension configuration is currently disabled. Enable it by setting "experimental.extensionConfig" to true.',
);
await exitCli();
return;
}
if (name) {
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
@@ -109,6 +109,9 @@ describe('ExtensionManager Settings Scope', () => {
telemetry: {
enabled: false,
},
experimental: {
extensionConfig: true,
},
} as Settings,
});
@@ -148,6 +151,9 @@ describe('ExtensionManager Settings Scope', () => {
telemetry: {
enabled: false,
},
experimental: {
extensionConfig: true,
},
} as Settings,
});
@@ -185,6 +191,9 @@ describe('ExtensionManager Settings Scope', () => {
telemetry: {
enabled: false,
},
experimental: {
extensionConfig: true,
},
} as Settings,
});
+32 -18
View File
@@ -287,7 +287,10 @@ Would you like to attempt to install via "git clone" instead?`,
}
await fs.promises.mkdir(destinationPath, { recursive: true });
if (this.requestSetting) {
if (
this.requestSetting &&
(this.settings.experimental?.extensionConfig ?? false)
) {
if (isUpdate) {
await maybePromptForSettings(
newExtensionConfig,
@@ -305,11 +308,14 @@ Would you like to attempt to install via "git clone" instead?`,
}
}
const missingSettings = await getMissingSettings(
newExtensionConfig,
extensionId,
this.workspaceDir,
);
const missingSettings =
(this.settings.experimental?.extensionConfig ?? false)
? await getMissingSettings(
newExtensionConfig,
extensionId,
this.workspaceDir,
)
: [];
if (missingSettings.length > 0) {
const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings
.map((s) => s.name)
@@ -526,23 +532,31 @@ Would you like to attempt to install via "git clone" instead?`,
const extensionId = getExtensionId(config, installMetadata);
const userSettings = await getScopedEnvContents(
config,
extensionId,
ExtensionSettingScope.USER,
);
const workspaceSettings = await getScopedEnvContents(
config,
extensionId,
ExtensionSettingScope.WORKSPACE,
this.workspaceDir,
);
let userSettings: Record<string, string> = {};
let workspaceSettings: Record<string, string> = {};
if (this.settings.experimental?.extensionConfig ?? false) {
userSettings = await getScopedEnvContents(
config,
extensionId,
ExtensionSettingScope.USER,
);
workspaceSettings = await getScopedEnvContents(
config,
extensionId,
ExtensionSettingScope.WORKSPACE,
this.workspaceDir,
);
}
const customEnv = { ...userSettings, ...workspaceSettings };
config = resolveEnvVarsInObject(config, customEnv);
const resolvedSettings: ResolvedExtensionSetting[] = [];
if (config.settings) {
if (
config.settings &&
(this.settings.experimental?.extensionConfig ?? false)
) {
for (const setting of config.settings) {
const value = customEnv[setting.envVar];
let scope: 'user' | 'workspace' | undefined;
+3 -1
View File
@@ -200,11 +200,13 @@ describe('extension tests', () => {
source: undefined,
});
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
const settings = loadSettings(tempWorkspaceDir).merged;
(settings.experimental ??= {}).extensionConfig = true;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: loadSettings(tempWorkspaceDir).merged,
settings,
});
resetTrustedFoldersForTesting();
});
@@ -11,6 +11,7 @@ import * as fs from 'node:fs';
import { getMissingSettings } from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from './storage.js';
import type { Settings } from '../settings.js';
import {
KeychainTokenStorage,
debugLogger,
@@ -245,8 +246,13 @@ describe('extensionUpdates', () => {
const manager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
settings: { telemetry: {} } as any,
settings: {
telemetry: {
enabled: false,
},
experimental: { extensionConfig: true },
} as unknown as Settings,
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null, // Simulate non-interactive
});
@@ -1413,6 +1413,15 @@ const SETTINGS_SCHEMA = {
description: 'Enable extension management features.',
showInDialog: false,
},
extensionConfig: {
type: 'boolean',
label: 'Extension Configuration',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable requesting and fetching of extension settings.',
showInDialog: false,
},
extensionReloading: {
type: 'boolean',
label: 'Extension Reloading',
@@ -2205,6 +2205,98 @@ describe('useGeminiStream', () => {
);
});
it('should flush pending text rationale before scheduling tool calls to ensure correct history order', async () => {
const addItemOrder: string[] = [];
let capturedOnComplete: any;
const mockScheduleToolCalls = vi.fn(async (requests) => {
addItemOrder.push('scheduleToolCalls_START');
// Simulate tools completing and triggering onComplete immediately.
// This mimics the behavior that caused the regression where tool results
// were added to history during the await scheduleToolCalls(...) block.
const tools = requests.map((r: any) => ({
request: r,
status: 'success',
tool: { displayName: r.name, name: r.name },
invocation: { getDescription: () => 'desc' },
response: { responseParts: [], resultDisplay: 'done' },
startTime: Date.now(),
endTime: Date.now(),
}));
await capturedOnComplete(tools);
addItemOrder.push('scheduleToolCalls_END');
});
mockAddItem.mockImplementation((item: any) => {
addItemOrder.push(`addItem:${item.type}`);
});
// We need to capture the onComplete callback from useReactToolScheduler
const mockUseReactToolScheduler = useReactToolScheduler as Mock;
mockUseReactToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[], // toolCalls
mockScheduleToolCalls,
vi.fn(), // markToolsAsSubmitted
vi.fn(), // setToolCallsForDisplay
vi.fn(), // cancelAllToolCalls
0, // lastToolOutputTime
];
});
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
vi.fn(),
vi.fn(),
false,
() => 'vscode' as EditorType,
vi.fn(),
vi.fn(),
false,
vi.fn(),
vi.fn(),
vi.fn(),
80,
24,
),
);
const mockStream = (async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Rationale rationale.',
};
yield {
type: ServerGeminiEventType.ToolCallRequest,
value: { callId: '1', name: 'test_tool', args: {} },
};
})();
mockSendMessageStream.mockReturnValue(mockStream);
await act(async () => {
await result.current.submitQuery('test input');
});
// Expectation: addItem:gemini (rationale) MUST happen before scheduleToolCalls_START
const rationaleIndex = addItemOrder.indexOf('addItem:gemini');
const scheduleIndex = addItemOrder.indexOf('scheduleToolCalls_START');
const toolGroupIndex = addItemOrder.indexOf('addItem:tool_group');
expect(rationaleIndex).toBeGreaterThan(-1);
expect(scheduleIndex).toBeGreaterThan(-1);
expect(toolGroupIndex).toBeGreaterThan(-1);
// This is the core fix validation: Rationale comes before tools are even scheduled (awaited)
expect(rationaleIndex).toBeLessThan(scheduleIndex);
expect(rationaleIndex).toBeLessThan(toolGroupIndex);
});
it('should process @include commands, adding user turn after processing to prevent race conditions', async () => {
const rawQuery = '@include file.txt Summarize this.';
const processedQueryParts = [
+9 -2
View File
@@ -458,7 +458,7 @@ export const useGeminiStream = (
isClientInitiated: true,
prompt_id,
};
scheduleToolCalls([toolCallRequest], abortSignal);
await scheduleToolCalls([toolCallRequest], abortSignal);
return { queryToSend: null, shouldProceed: false };
}
case 'submit_prompt': {
@@ -921,7 +921,11 @@ export const useGeminiStream = (
}
}
if (toolCallRequests.length > 0) {
scheduleToolCalls(toolCallRequests, signal);
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
await scheduleToolCalls(toolCallRequests, signal);
}
return StreamProcessingStatus.Completed;
},
@@ -938,6 +942,9 @@ export const useGeminiStream = (
handleChatModelEvent,
handleAgentExecutionStoppedEvent,
handleAgentExecutionBlockedEvent,
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
],
);
const submitQuery = useCallback(
@@ -166,11 +166,6 @@ describe('useQuotaAndFallback', () => {
const intent = await promise!;
expect(intent).toBe('retry_always');
// Verify activateFallbackMode was called
expect(mockConfig.activateFallbackMode).toHaveBeenCalledWith(
'gemini-flash',
);
// The pending request should be cleared from the state
expect(result.current.proQuotaRequest).toBeNull();
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
@@ -282,11 +277,6 @@ describe('useQuotaAndFallback', () => {
const intent = await promise!;
expect(intent).toBe('retry_always');
// Verify activateFallbackMode was called
expect(mockConfig.activateFallbackMode).toHaveBeenCalledWith(
'model-B',
);
// The pending request should be cleared from the state
expect(result.current.proQuotaRequest).toBeNull();
expect(mockConfig.setQuotaErrorOccurred).toHaveBeenCalledWith(true);
@@ -342,11 +332,6 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
const intent = await promise!;
expect(intent).toBe('retry_always');
// Verify activateFallbackMode was called
expect(mockConfig.activateFallbackMode).toHaveBeenCalledWith(
'gemini-2.5-pro',
);
expect(result.current.proQuotaRequest).toBeNull();
});
});
@@ -430,11 +415,6 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
expect(intent).toBe('retry_always');
expect(result.current.proQuotaRequest).toBeNull();
// Verify activateFallbackMode was called
expect(mockConfig.activateFallbackMode).toHaveBeenCalledWith(
'gemini-flash',
);
// Verify quota error flags are reset
expect(mockSetModelSwitchedFromQuotaError).toHaveBeenCalledWith(false);
expect(mockConfig.setQuotaErrorOccurred).toHaveBeenCalledWith(false);
@@ -135,10 +135,6 @@ export function useQuotaAndFallback({
config.setQuotaErrorOccurred(false);
if (choice === 'retry_always') {
// Set the model to the fallback model for the current session.
// This ensures the Footer updates and future turns use this model.
// The change is not persisted, so the original model is restored on restart.
config.activateFallbackMode(proQuotaRequest.fallbackModel);
historyManager.addItem(
{
type: MessageType.INFO,
@@ -31,7 +31,7 @@ import { ToolCallStatus } from '../types.js';
export type ScheduleFn = (
request: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal,
) => void;
) => Promise<void>;
export type MarkToolsAsSubmittedFn = (callIds: string[]) => void;
export type TrackedScheduledToolCall = ScheduledToolCall & {
@@ -180,7 +180,7 @@ export function useReactToolScheduler(
signal: AbortSignal,
) => {
setToolCallsForDisplay([]);
void scheduler.schedule(request, signal);
return scheduler.schedule(request, signal);
},
[scheduler, setToolCallsForDisplay],
);
@@ -172,8 +172,8 @@ describe('useReactToolScheduler in YOLO Mode', () => {
args: { data: 'any data' },
} as any;
act(() => {
schedule(request, new AbortController().signal);
await act(async () => {
await schedule(request, new AbortController().signal);
});
await act(async () => {
@@ -229,11 +229,11 @@ describe('useReactToolScheduler', () => {
schedule: (
req: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal,
) => void,
) => Promise<void>,
request: ToolCallRequestInfo | ToolCallRequestInfo[],
) => {
act(() => {
schedule(request, new AbortController().signal);
await act(async () => {
await schedule(request, new AbortController().signal);
});
await advanceAndSettle();
@@ -322,10 +322,13 @@ describe('useReactToolScheduler', () => {
it('should clear previous tool calls when scheduling new ones', async () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
(mockTool.execute as Mock).mockResolvedValue({
llmContent: 'Tool output',
returnDisplay: 'Formatted tool output',
} as ToolResult);
(mockTool.execute as Mock).mockImplementation(async () => {
await new Promise((r) => setTimeout(r, 10));
return {
llmContent: 'Tool output',
returnDisplay: 'Formatted tool output',
};
});
const { result } = renderScheduler();
const schedule = result.current[1];
@@ -346,10 +349,13 @@ describe('useReactToolScheduler', () => {
name: 'mockTool',
args: {},
} as any;
act(() => {
schedule(newRequest, new AbortController().signal);
let schedulePromise: Promise<void>;
await act(async () => {
schedulePromise = schedule(newRequest, new AbortController().signal);
});
await advanceAndSettle();
// After scheduling, the old call should be gone,
// and the new one should be in the display in its initial state.
expect(result.current[0].length).toBe(1);
@@ -358,14 +364,13 @@ describe('useReactToolScheduler', () => {
// Let the new call finish.
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(20);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
await schedulePromise;
});
expect(onComplete).toHaveBeenCalled();
});
@@ -388,16 +393,14 @@ describe('useReactToolScheduler', () => {
args: {},
} as any;
act(() => {
schedule(request, new AbortController().signal);
});
let schedulePromise: Promise<void>;
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
}); // validation
await act(async () => {
await vi.advanceTimersByTimeAsync(0); // Process scheduling
schedulePromise = schedule(request, new AbortController().signal);
});
await advanceAndSettle(); // validation
await advanceAndSettle(); // Process scheduling
// At this point, the tool is 'executing' and waiting on the promise.
expect(result.current[0][0].status).toBe('executing');
@@ -406,9 +409,7 @@ describe('useReactToolScheduler', () => {
cancelAllToolCalls(cancelController.signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await advanceAndSettle();
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
@@ -421,6 +422,11 @@ describe('useReactToolScheduler', () => {
await act(async () => {
resolveExecute({ llmContent: 'output', returnDisplay: 'display' });
});
// Now await the schedule promise
await act(async () => {
await schedulePromise;
});
});
it.each([
@@ -520,8 +526,9 @@ describe('useReactToolScheduler', () => {
args: { data: 'sensitive' },
} as any;
act(() => {
schedule(request, new AbortController().signal);
let schedulePromise: Promise<void>;
await act(async () => {
schedulePromise = schedule(request, new AbortController().signal);
});
await advanceAndSettle();
@@ -535,8 +542,11 @@ describe('useReactToolScheduler', () => {
});
await advanceAndSettle();
await advanceAndSettle();
await advanceAndSettle();
// Now await the schedule promise as it should complete
await act(async () => {
await schedulePromise;
});
expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
@@ -567,8 +577,9 @@ describe('useReactToolScheduler', () => {
args: {},
} as any;
act(() => {
schedule(request, new AbortController().signal);
let schedulePromise: Promise<void>;
await act(async () => {
schedulePromise = schedule(request, new AbortController().signal);
});
await advanceAndSettle();
@@ -580,8 +591,13 @@ describe('useReactToolScheduler', () => {
await act(async () => {
await capturedOnConfirmForTest?.(ToolConfirmationOutcome.Cancel);
});
await advanceAndSettle();
await advanceAndSettle();
// Now await the schedule promise
await act(async () => {
await schedulePromise;
});
expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith(
ToolConfirmationOutcome.Cancel,
@@ -628,8 +644,12 @@ describe('useReactToolScheduler', () => {
args: {},
} as any;
act(() => {
result.current[1](request, new AbortController().signal);
let schedulePromise: Promise<void>;
await act(async () => {
schedulePromise = result.current[1](
request,
new AbortController().signal,
);
});
await advanceAndSettle();
@@ -653,7 +673,11 @@ describe('useReactToolScheduler', () => {
} as ToolResult);
});
await advanceAndSettle();
await advanceAndSettle();
// Now await schedule
await act(async () => {
await schedulePromise;
});
const completedCalls = onComplete.mock.calls[0][0] as ToolCall[];
expect(completedCalls[0].status).toBe('success');
@@ -699,8 +723,8 @@ describe('useReactToolScheduler', () => {
{ callId: 'multi2', name: 'tool2', args: { p: 2 } } as any,
];
act(() => {
schedule(requests, new AbortController().signal);
await act(async () => {
await schedule(requests, new AbortController().signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
@@ -791,24 +815,30 @@ describe('useReactToolScheduler', () => {
args: {},
} as any;
act(() => {
schedule(request1, new AbortController().signal);
let schedulePromise1: Promise<void>;
let schedulePromise2: Promise<void>;
await act(async () => {
schedulePromise1 = schedule(request1, new AbortController().signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
act(() => {
schedule(request2, new AbortController().signal);
await act(async () => {
schedulePromise2 = schedule(request2, new AbortController().signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(0);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
});
// Wait for first to complete
await act(async () => {
await schedulePromise1;
});
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'success',
@@ -816,13 +846,17 @@ describe('useReactToolScheduler', () => {
response: expect.objectContaining({ resultDisplay: 'done display' }),
}),
]);
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(0);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
});
// Wait for second to complete
await act(async () => {
await schedulePromise2;
});
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'success',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.25.0-nightly.20260107.59a18e710",
"version": "0.25.0",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+2 -3
View File
@@ -1955,9 +1955,8 @@ export class Config {
*/
async dispose(): Promise<void> {
coreEvents.off(CoreEvent.AgentsRefreshed, this.onAgentsRefreshed);
if (this.agentRegistry) {
this.agentRegistry.dispose();
}
this.agentRegistry?.dispose();
this.geminiClient?.dispose();
if (this.mcpClientManager) {
await this.mcpClientManager.stop();
}
+56 -6
View File
@@ -48,6 +48,7 @@ import type {
import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js';
import * as policyCatalog from '../availability/policyCatalog.js';
import { partToString } from '../utils/partUtils.js';
import { coreEvents } from '../utils/events.js';
vi.mock('../services/chatCompressionService.js');
@@ -290,6 +291,7 @@ describe('Gemini Client (client.ts)', () => {
});
afterEach(() => {
client.dispose();
vi.restoreAllMocks();
});
@@ -1579,6 +1581,55 @@ ${JSON.stringify(
expect.any(AbortSignal),
);
});
it('should re-route within the same prompt when the configured model changes', async () => {
mockTurnRunFn.mockClear();
mockTurnRunFn.mockImplementation(async function* () {
yield { type: 'content', value: 'Hello' };
});
mockRouterService.route.mockResolvedValueOnce({
model: 'original-model',
reason: 'test',
});
let stream = client.sendMessageStream(
[{ text: 'Hi' }],
new AbortController().signal,
'prompt-1',
);
await fromAsync(stream);
expect(mockRouterService.route).toHaveBeenCalledTimes(1);
expect(mockTurnRunFn).toHaveBeenNthCalledWith(
1,
{ model: 'original-model' },
[{ text: 'Hi' }],
expect.any(AbortSignal),
);
mockRouterService.route.mockResolvedValue({
model: 'fallback-model',
reason: 'test',
});
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-flash');
coreEvents.emitModelChanged('gemini-2.5-flash');
stream = client.sendMessageStream(
[{ text: 'Continue' }],
new AbortController().signal,
'prompt-1',
);
await fromAsync(stream);
expect(mockRouterService.route).toHaveBeenCalledTimes(2);
expect(mockTurnRunFn).toHaveBeenNthCalledWith(
2,
{ model: 'fallback-model' },
[{ text: 'Continue' }],
expect.any(AbortSignal),
);
});
});
it('should use getGlobalMemory for system instruction when JIT is enabled', async () => {
@@ -1649,7 +1700,6 @@ ${JSON.stringify(
expect(events).toEqual([
{ type: GeminiEventType.ModelInfo, value: 'default-routed-model' },
{ type: GeminiEventType.InvalidStream },
{ type: GeminiEventType.ModelInfo, value: 'default-routed-model' },
{ type: GeminiEventType.Content, value: 'Continued content' },
]);
@@ -1737,13 +1787,13 @@ ${JSON.stringify(
const events = await fromAsync(stream);
// Assert
// We expect 4 events (model_info + original + model_info + 1 retry)
expect(events.length).toBe(4);
// We expect 3 events (model_info + original + 1 retry)
expect(events.length).toBe(3);
expect(
events
.filter((e) => e.type !== GeminiEventType.ModelInfo)
.every((e) => e.type === GeminiEventType.InvalidStream),
).toBe(true);
.filter((e) => e.type === GeminiEventType.ModelInfo)
.map((e) => e.value),
).toEqual(['default-routed-model']);
// Verify that turn.run was called twice
expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
+14 -2
View File
@@ -58,6 +58,7 @@ import {
import { resolveModel } from '../config/models.js';
import type { RetryAvailabilityContext } from '../utils/retry.js';
import { partToString } from '../utils/partUtils.js';
import { coreEvents, CoreEvent } from '../utils/events.js';
const MAX_TURNS = 100;
@@ -94,8 +95,14 @@ export class GeminiClient {
this.loopDetector = new LoopDetectionService(config);
this.compressionService = new ChatCompressionService();
this.lastPromptId = this.config.getSessionId();
coreEvents.on(CoreEvent.ModelChanged, this.handleModelChanged);
}
private handleModelChanged = () => {
this.currentSequenceModel = null;
};
// Hook state to deduplicate BeforeAgent calls and track response for
// AfterAgent
private hookStateMap = new Map<
@@ -253,6 +260,10 @@ export class GeminiClient {
this.updateTelemetryTokenCount();
}
dispose() {
coreEvents.off(CoreEvent.ModelChanged, this.handleModelChanged);
}
async resumeChat(
history: Content[],
resumedSessionData?: ResumedSessionData,
@@ -626,9 +637,10 @@ export class GeminiClient {
);
modelToUse = finalModel;
if (!signal.aborted && !this.currentSequenceModel) {
yield { type: GeminiEventType.ModelInfo, value: modelToUse };
}
this.currentSequenceModel = modelToUse;
yield { type: GeminiEventType.ModelInfo, value: modelToUse };
const resultStream = turn.run(modelConfigKey, request, linkedSignal);
let isError = false;
let isInvalidStream = false;
+102
View File
@@ -754,6 +754,108 @@ describe('Turn', () => {
expect(events).toEqual([expectedEvent]);
});
it('should process all parts when thought is first part in chunk', async () => {
const mockResponseStream = (async function* () {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
parts: [
{ text: '**Planning** the solution', thought: 'planning' },
{ text: 'I will help you with that.' },
],
},
citationMetadata: {
citations: [{ uri: 'https://example.com', title: 'Source' }],
},
finishReason: 'STOP',
},
],
functionCalls: [
{
id: 'fc1',
name: 'ReadFile',
args: { path: 'file.txt' },
},
],
responseId: 'trace-789',
} as unknown as GenerateContentResponse,
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
const events = [];
for await (const event of turn.run(
{ model: 'gemini' },
[{ text: 'Test mixed content' }],
new AbortController().signal,
)) {
events.push(event);
}
// Should yield:
// 1. Thought event (from first part)
// 2. Content event (from second part)
// 3. ToolCallRequest event (from functionCalls)
// 4. Citation event (from citationMetadata, emitted with finishReason)
// 5. Finished event (from finishReason)
expect(events.length).toBe(5);
const thoughtEvent = events.find(
(e) => e.type === GeminiEventType.Thought,
);
expect(thoughtEvent).toBeDefined();
expect(thoughtEvent).toMatchObject({
type: GeminiEventType.Thought,
value: { subject: 'Planning', description: 'the solution' },
traceId: 'trace-789',
});
const contentEvent = events.find(
(e) => e.type === GeminiEventType.Content,
);
expect(contentEvent).toBeDefined();
expect(contentEvent).toMatchObject({
type: GeminiEventType.Content,
value: 'I will help you with that.',
traceId: 'trace-789',
});
const toolCallEvent = events.find(
(e) => e.type === GeminiEventType.ToolCallRequest,
);
expect(toolCallEvent).toBeDefined();
expect(toolCallEvent).toMatchObject({
type: GeminiEventType.ToolCallRequest,
value: expect.objectContaining({
callId: 'fc1',
name: 'ReadFile',
args: { path: 'file.txt' },
}),
});
const citationEvent = events.find(
(e) => e.type === GeminiEventType.Citation,
);
expect(citationEvent).toBeDefined();
expect(citationEvent).toMatchObject({
type: GeminiEventType.Citation,
value: expect.stringContaining('https://example.com'),
});
const finishedEvent = events.find(
(e) => e.type === GeminiEventType.Finished,
);
expect(finishedEvent).toBeDefined();
expect(finishedEvent).toMatchObject({
type: GeminiEventType.Finished,
value: { reason: 'STOP' },
});
});
});
describe('getDebugResponses', () => {
+10 -9
View File
@@ -290,15 +290,16 @@ export class Turn {
const traceId = resp.responseId;
const thoughtPart = resp.candidates?.[0]?.content?.parts?.[0];
if (thoughtPart?.thought) {
const thought = parseThought(thoughtPart.text ?? '');
yield {
type: GeminiEventType.Thought,
value: thought,
traceId,
};
continue;
const parts = resp.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.thought) {
const thought = parseThought(part.text ?? '');
yield {
type: GeminiEventType.Thought,
value: thought,
traceId,
};
}
}
const text = getResponseText(resp);
+12 -8
View File
@@ -65,6 +65,8 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
fallbackHandler: undefined,
getFallbackModelHandler: vi.fn(),
setActiveModel: vi.fn(),
setModel: vi.fn(),
activateFallbackMode: vi.fn(),
getModelAvailabilityService: vi.fn(() =>
createAvailabilityServiceMock({
selectedModel: FALLBACK_MODEL,
@@ -198,7 +200,7 @@ describe('handleFallback', () => {
expect(result).toBe(true);
expect(policyConfig.getFallbackModelHandler).not.toHaveBeenCalled();
expect(policyConfig.setActiveModel).toHaveBeenCalledWith(
expect(policyConfig.activateFallbackMode).toHaveBeenCalledWith(
DEFAULT_GEMINI_FLASH_MODEL,
);
} finally {
@@ -273,7 +275,7 @@ describe('handleFallback', () => {
expect(openBrowserSecurely).toHaveBeenCalledWith(
'https://goo.gle/set-up-gemini-code-assist',
);
expect(policyConfig.setActiveModel).not.toHaveBeenCalled();
expect(policyConfig.activateFallbackMode).not.toHaveBeenCalled();
});
it('should catch errors from the handler, log an error, and return null', async () => {
@@ -378,7 +380,7 @@ describe('handleFallback', () => {
);
});
it('calls setActiveModel and logs telemetry when handler returns "retry_always"', async () => {
it('calls activateFallbackMode when handler returns "retry_always"', async () => {
policyHandler.mockResolvedValue('retry_always');
vi.mocked(policyConfig.getModel).mockReturnValue(
DEFAULT_GEMINI_MODEL_AUTO,
@@ -391,11 +393,13 @@ describe('handleFallback', () => {
);
expect(result).toBe(true);
expect(policyConfig.setActiveModel).toHaveBeenCalledWith(FALLBACK_MODEL);
expect(policyConfig.activateFallbackMode).toHaveBeenCalledWith(
FALLBACK_MODEL,
);
// TODO: add logging expect statement
});
it('does NOT call setActiveModel when handler returns "stop"', async () => {
it('does NOT call activateFallbackMode when handler returns "stop"', async () => {
policyHandler.mockResolvedValue('stop');
const result = await handleFallback(
@@ -405,11 +409,11 @@ describe('handleFallback', () => {
);
expect(result).toBe(false);
expect(policyConfig.setActiveModel).not.toHaveBeenCalled();
expect(policyConfig.activateFallbackMode).not.toHaveBeenCalled();
// TODO: add logging expect statement
});
it('does NOT call setActiveModel when handler returns "retry_once"', async () => {
it('does NOT call activateFallbackMode when handler returns "retry_once"', async () => {
policyHandler.mockResolvedValue('retry_once');
const result = await handleFallback(
@@ -419,7 +423,7 @@ describe('handleFallback', () => {
);
expect(result).toBe(true);
expect(policyConfig.setActiveModel).not.toHaveBeenCalled();
expect(policyConfig.activateFallbackMode).not.toHaveBeenCalled();
});
});
});
+1 -1
View File
@@ -131,7 +131,7 @@ async function processIntent(
case 'retry_always':
// TODO(telemetry): Implement generic fallback event logging. Existing
// logFlashFallback is specific to a single Model.
config.setActiveModel(fallbackModel);
config.activateFallbackMode(fallbackModel);
return true;
case 'retry_once':
+1 -1
View File
@@ -81,7 +81,7 @@ export function getResponseText(
candidate.content.parts.length > 0
) {
return candidate.content.parts
.filter((part) => part.text)
.filter((part) => part.text && !part.thought)
.map((part) => part.text)
.join('');
}
+5 -1
View File
@@ -4,7 +4,11 @@
"outDir": "dist",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"composite": true,
"types": ["node", "vitest/globals"]
"types": ["node", "vitest/globals"],
"baseUrl": ".",
"paths": {
"@google/gemini-cli-core": ["./index.ts"]
}
},
"include": ["index.ts", "src/**/*.ts", "src/**/*.json"],
"exclude": ["node_modules", "dist"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.25.0-nightly.20260107.59a18e710",
"version": "0.25.0",
"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.25.0-nightly.20260107.59a18e710",
"version": "0.25.0",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+7
View File
@@ -1393,6 +1393,13 @@
"default": true,
"type": "boolean"
},
"extensionConfig": {
"title": "Extension Configuration",
"description": "Enable requesting and fetching of extension settings.",
"markdownDescription": "Enable requesting and fetching of extension settings.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"extensionReloading": {
"title": "Extension Reloading",
"description": "Enables extension loading/unloading within the CLI session.",