From 5bd73a33655eb627587b6d71fb88772e10f82e43 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Mon, 3 Nov 2025 14:16:19 -0800 Subject: [PATCH] fix(patch): cherry-pick 265f24e to release/v0.12.0-preview.9-pr-12412 [CONFLICTS] (#12498) Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com> Co-authored-by: Abhi --- packages/cli/src/ui/AppContainer.test.tsx | 38 ++++++++++++++++++++++- packages/cli/src/ui/AppContainer.tsx | 9 +++++- packages/core/src/config/config.test.ts | 15 +++++++++ packages/core/src/config/config.ts | 6 +++- packages/core/src/utils/events.test.ts | 13 ++++++++ packages/core/src/utils/events.ts | 31 ++++++++++++++++++ 6 files changed, 109 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0337a6bc1a..c7a2c6d8d9 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -47,7 +47,7 @@ import { UIActionsContext, type UIActions, } from './contexts/UIActionsContext.js'; -import { useContext } from 'react'; +import { useContext, act } from 'react'; // Mock useStdout to capture terminal title writes let mockStdout: { write: ReturnType }; @@ -1395,5 +1395,41 @@ describe('AppContainer State Management', () => { expect.any(Number), ); }); + + it('updates currentModel when ModelChanged event is received', async () => { + // Arrange: Mock initial model + vi.spyOn(mockConfig, 'getModel').mockReturnValue('initial-model'); + + const { unmount } = render( + , + ); + + // Verify initial model + await act(async () => { + await vi.waitFor(() => { + expect(capturedUIState?.currentModel).toBe('initial-model'); + }); + }); + + // Get the registered handler for ModelChanged + const handler = mockCoreEvents.on.mock.calls.find( + (call: unknown[]) => call[0] === CoreEvent.ModelChanged, + )?.[1]; + expect(handler).toBeDefined(); + + // Act: Simulate ModelChanged event + act(() => { + handler({ model: 'new-model' }); + }); + + // Assert: Verify model is updated + expect(capturedUIState.currentModel).toBe('new-model'); + unmount(); + }); }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index eef68e4e03..73fc416fb3 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -47,6 +47,7 @@ import { debugLogger, coreEvents, CoreEvent, + type ModelChangedPayload, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; @@ -253,16 +254,22 @@ export const AppContainer = (props: AppContainerProps) => { [historyManager.addItem], ); - // Subscribe to fallback mode changes from core + // Subscribe to fallback mode and model changes from core useEffect(() => { const handleFallbackModeChanged = () => { const effectiveModel = getEffectiveModel(); setCurrentModel(effectiveModel); }; + const handleModelChanged = (payload: ModelChangedPayload) => { + setCurrentModel(payload.model); + }; + coreEvents.on(CoreEvent.FallbackModeChanged, handleFallbackModeChanged); + coreEvents.on(CoreEvent.ModelChanged, handleModelChanged); return () => { coreEvents.off(CoreEvent.FallbackModeChanged, handleFallbackModeChanged); + coreEvents.off(CoreEvent.ModelChanged, handleModelChanged); }; }, [getEffectiveModel]); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 334ff865a7..23c5dfd1d6 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -139,6 +139,21 @@ vi.mock('../agents/subagent-tool-wrapper.js', () => ({ SubagentToolWrapper: vi.fn(), })); +const mockCoreEvents = vi.hoisted(() => ({ + emitFeedback: vi.fn(), + emitModelChanged: vi.fn(), +})); + +const mockSetGlobalProxy = vi.hoisted(() => vi.fn()); + +vi.mock('../utils/events.js', () => ({ + coreEvents: mockCoreEvents, +})); + +vi.mock('../utils/fetch.js', () => ({ + setGlobalProxy: mockSetGlobalProxy, +})); + import { BaseLlmClient } from '../core/baseLlmClient.js'; import { tokenLimit } from '../core/tokenLimits.js'; import { uiTelemetryService } from '../telemetry/index.js'; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 61aa05f5f8..d2f5a06256 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -41,6 +41,7 @@ import { DEFAULT_OTLP_ENDPOINT, uiTelemetryService, } from '../telemetry/index.js'; +import { coreEvents } from '../utils/events.js'; import { tokenLimit } from '../core/tokenLimits.js'; import { DEFAULT_GEMINI_EMBEDDING_MODEL, @@ -638,7 +639,10 @@ export class Config { return; } - this.model = newModel; + if (this.model !== newModel) { + this.model = newModel; + coreEvents.emitModelChanged(newModel); + } } isInFallbackMode(): boolean { diff --git a/packages/core/src/utils/events.test.ts b/packages/core/src/utils/events.test.ts index 4a11263014..9ba660bf26 100644 --- a/packages/core/src/utils/events.test.ts +++ b/packages/core/src/utils/events.test.ts @@ -156,4 +156,17 @@ describe('CoreEventEmitter', () => { }); expect(listener.mock.calls[2][0]).toMatchObject({ message: 'Buffered 2' }); }); + + describe('ModelChanged Event', () => { + it('should emit ModelChanged event with correct payload', () => { + const listener = vi.fn(); + events.on(CoreEvent.ModelChanged, listener); + + const newModel = 'gemini-2.5-pro'; + events.emitModelChanged(newModel); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ model: newModel }); + }); + }); }); diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index 9b34d27883..386200fad7 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -43,9 +43,20 @@ export interface FallbackModeChangedPayload { isInFallbackMode: boolean; } +/** + * Payload for the 'model-changed' event. + */ +export interface ModelChangedPayload { + /** + * The new model that was set. + */ + model: string; +} + export enum CoreEvent { UserFeedback = 'user-feedback', FallbackModeChanged = 'fallback-mode-changed', + ModelChanged = 'model-changed', } export class CoreEventEmitter extends EventEmitter { @@ -86,6 +97,14 @@ export class CoreEventEmitter extends EventEmitter { this.emit(CoreEvent.FallbackModeChanged, payload); } + /** + * Notifies subscribers that the model has changed. + */ + emitModelChanged(model: string): void { + const payload: ModelChangedPayload = { model }; + this.emit(CoreEvent.ModelChanged, payload); + } + /** * Flushes buffered messages. Call this immediately after primary UI listener * subscribes. @@ -106,6 +125,10 @@ export class CoreEventEmitter extends EventEmitter { event: CoreEvent.FallbackModeChanged, listener: (payload: FallbackModeChangedPayload) => void, ): this; + override on( + event: CoreEvent.ModelChanged, + listener: (payload: ModelChangedPayload) => void, + ): this; override on( event: string | symbol, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -122,6 +145,10 @@ export class CoreEventEmitter extends EventEmitter { event: CoreEvent.FallbackModeChanged, listener: (payload: FallbackModeChangedPayload) => void, ): this; + override off( + event: CoreEvent.ModelChanged, + listener: (payload: ModelChangedPayload) => void, + ): this; override off( event: string | symbol, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -138,6 +165,10 @@ export class CoreEventEmitter extends EventEmitter { event: CoreEvent.FallbackModeChanged, payload: FallbackModeChangedPayload, ): boolean; + override emit( + event: CoreEvent.ModelChanged, + payload: ModelChangedPayload, + ): boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any override emit(event: string | symbol, ...args: any[]): boolean { return super.emit(event, ...args);