FEAT: Add availabilityService (#81)

* auto and fallback work

* test fixes

* fixes

* Show model dialog even if there's no more fallback option

* fix tests

* fix failing test

* disable showInDialog for model in settings

* revert package-lock.json

* remove dup line

---------

Co-authored-by: Sehoon Shon <sshon@google.com>
This commit is contained in:
Adam Weidman
2025-12-11 21:51:16 -05:00
committed by Tommaso Sciortino
parent ad994cfe8b
commit 48ad6983a3
37 changed files with 876 additions and 1511 deletions
+184 -57
View File
@@ -17,7 +17,7 @@ import {
import { render } from '../test-utils/render.js';
import { waitFor } from '../test-utils/async.js';
import { cleanup } from 'ink-testing-library';
import { act, useContext } from 'react';
import { act, useContext, type ReactElement } from 'react';
import { AppContainer } from './AppContainer.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import {
@@ -71,6 +71,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
})),
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
FileDiscoveryService: vi.fn().mockImplementation(() => ({
initialize: vi.fn(),
})),
startupProfiler: {
flush: vi.fn(),
start: vi.fn(),
end: vi.fn(),
},
};
});
import ansiEscapes from 'ansi-escapes';
@@ -344,7 +352,7 @@ describe('AppContainer State Management', () => {
// Add other properties if AppContainer uses them
});
mockedUseLogger.mockReturnValue({
getPreviousUserMessages: vi.fn().mockReturnValue(new Promise(() => {})),
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
});
mockedUseInputHistoryStore.mockReturnValue({
inputHistory: [],
@@ -361,6 +369,8 @@ describe('AppContainer State Management', () => {
// Mock config's getTargetDir to return consistent workspace directory
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
vi.spyOn(mockConfig, 'getDebugMode').mockReturnValue(false);
mockExtensionManager = vi.mockObject({
getExtensions: vi.fn().mockReturnValue([]),
@@ -403,17 +413,25 @@ describe('AppContainer State Management', () => {
describe('Basic Rendering', () => {
it('renders without crashing with minimal props', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
it('renders with startup warnings', async () => {
const startupWarnings = ['Warning 1', 'Warning 2'];
const { unmount } = renderAppContainer({ startupWarnings });
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ startupWarnings });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
});
@@ -424,11 +442,15 @@ describe('AppContainer State Management', () => {
themeError: 'Failed to load theme',
};
const { unmount } = renderAppContainer({
initResult: initResultWithError,
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
initResult: initResultWithError,
});
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
it('handles debug mode state', () => {
@@ -443,29 +465,45 @@ describe('AppContainer State Management', () => {
describe('Context Providers', () => {
it('provides AppContext with correct values', async () => {
const { unmount } = renderAppContainer({ version: '2.0.0' });
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ version: '2.0.0' });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Should render and unmount cleanly
expect(() => unmount()).not.toThrow();
expect(() => unmount!()).not.toThrow();
});
it('provides UIStateContext with state management', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
it('provides UIActionsContext with action handlers', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
it('provides ConfigContext with config object', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
});
@@ -480,9 +518,13 @@ describe('AppContainer State Management', () => {
},
} as unknown as LoadedSettings;
const { unmount } = renderAppContainer({ settings: settingsAllHidden });
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ settings: settingsAllHidden });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
it('handles settings with memory usage enabled', async () => {
@@ -495,9 +537,13 @@ describe('AppContainer State Management', () => {
},
} as unknown as LoadedSettings;
const { unmount } = renderAppContainer({ settings: settingsWithMemory });
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ settings: settingsWithMemory });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
});
@@ -505,9 +551,13 @@ describe('AppContainer State Management', () => {
it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])(
'handles version format: %s',
async (version) => {
const { unmount } = renderAppContainer({ version });
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ version });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
},
);
});
@@ -529,9 +579,13 @@ describe('AppContainer State Management', () => {
merged: {},
} as LoadedSettings;
const { unmount } = renderAppContainer({ settings: undefinedSettings });
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ settings: undefinedSettings });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
});
});
@@ -860,12 +914,16 @@ describe('AppContainer State Management', () => {
describe('Quota and Fallback Integration', () => {
it('passes a null proQuotaRequest to UIStateContext by default', async () => {
// The default mock from beforeEach already sets proQuotaRequest to null
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
// Assert that the context value is as expected
expect(capturedUIState.proQuotaRequest).toBeNull();
});
unmount();
unmount!();
});
it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => {
@@ -881,12 +939,16 @@ describe('AppContainer State Management', () => {
});
// Act: Render the container
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
// Assert: The mock request is correctly passed through the context
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
});
unmount();
unmount!();
});
it('passes the handleProQuotaChoice function to UIActionsContext', async () => {
@@ -898,7 +960,11 @@ describe('AppContainer State Management', () => {
});
// Act: Render the container
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
// Assert: The action in the context is the mock handler we provided
expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler);
@@ -909,7 +975,7 @@ describe('AppContainer State Management', () => {
capturedUIActions.handleProQuotaChoice('retry_later');
});
expect(mockHandler).toHaveBeenCalledWith('retry_later');
unmount();
unmount!();
});
});
@@ -1327,13 +1393,17 @@ describe('AppContainer State Management', () => {
activePtyId: 'some-id',
});
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
const lastCall =
resizePtySpy.mock.calls[resizePtySpy.mock.calls.length - 1];
// Check the height argument specifically
expect(lastCall[2]).toBe(1);
unmount();
unmount!();
});
});
@@ -1672,11 +1742,15 @@ describe('AppContainer State Management', () => {
closeModelDialog: vi.fn(),
});
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
expect(capturedUIState.isModelDialogOpen).toBe(true);
unmount();
unmount!();
});
it('should provide model dialog actions in the UIActionsContext', async () => {
@@ -1688,7 +1762,11 @@ describe('AppContainer State Management', () => {
closeModelDialog: mockCloseModelDialog,
});
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Verify that the actions are correctly passed through context
@@ -1696,13 +1774,17 @@ describe('AppContainer State Management', () => {
capturedUIActions.closeModelDialog();
});
expect(mockCloseModelDialog).toHaveBeenCalled();
unmount();
unmount!();
});
});
describe('CoreEvents Integration', () => {
it('subscribes to UserFeedback and drains backlog on mount', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
expect(mockCoreEvents.on).toHaveBeenCalledWith(
@@ -1710,14 +1792,18 @@ describe('AppContainer State Management', () => {
expect.any(Function),
);
expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1);
unmount();
unmount!();
});
it('unsubscribes from UserFeedback on unmount', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount();
unmount!();
expect(mockCoreEvents.off).toHaveBeenCalledWith(
CoreEvent.UserFeedback,
@@ -1726,7 +1812,11 @@ describe('AppContainer State Management', () => {
});
it('adds history item when UserFeedback event is received', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Get the registered handler
@@ -1751,14 +1841,18 @@ describe('AppContainer State Management', () => {
}),
expect.any(Number),
);
unmount();
unmount!();
});
it('updates currentModel when ModelChanged event is received', async () => {
// Arrange: Mock initial model
vi.spyOn(mockConfig, 'getModel').mockReturnValue('initial-model');
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
expect(capturedUIState?.currentModel).toBe('initial-model');
});
@@ -1770,13 +1864,15 @@ describe('AppContainer State Management', () => {
expect(handler).toBeDefined();
// Act: Simulate ModelChanged event
// Update config mock to return new model since the handler reads from config
vi.spyOn(mockConfig, 'getModel').mockReturnValue('new-model');
act(() => {
handler({ model: 'new-model' });
});
// Assert: Verify model is updated
expect(capturedUIState.currentModel).toBe('new-model');
unmount();
unmount!();
});
});
@@ -1799,10 +1895,14 @@ describe('AppContainer State Management', () => {
});
// The main assertion is that the render does not throw.
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
unmount();
unmount!();
});
});
describe('Banner Text', () => {
@@ -1812,10 +1912,14 @@ describe('AppContainer State Management', () => {
authType: AuthType.USE_GEMINI,
apiKey: 'fake-key',
});
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
expect(capturedUIState.bannerData.defaultText).toBeDefined();
unmount();
unmount!();
});
});
});
@@ -1838,7 +1942,11 @@ describe('AppContainer State Management', () => {
});
it('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => {
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
const { onCancelSubmit } = extractUseGeminiStreamArgs(
@@ -1851,7 +1959,7 @@ describe('AppContainer State Management', () => {
expect(mockSetText).toHaveBeenCalledWith('');
unmount();
unmount!();
});
it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => {
@@ -1862,7 +1970,11 @@ describe('AppContainer State Management', () => {
initializeFromLogger: vi.fn(),
});
const { unmount } = renderAppContainer();
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() =>
expect(capturedUIState.userMessages).toContain('previous message'),
);
@@ -1877,7 +1989,7 @@ describe('AppContainer State Management', () => {
expect(mockSetText).toHaveBeenCalledWith('previous message');
unmount();
unmount!();
});
it('input history is independent from conversation history (survives /clear)', async () => {
@@ -1890,7 +2002,13 @@ describe('AppContainer State Management', () => {
initializeFromLogger: vi.fn(),
});
const { unmount } = renderAppContainer();
let rerender: (tree: ReactElement) => void;
let unmount;
await act(async () => {
const result = renderAppContainer();
rerender = result.rerender;
unmount = result.unmount;
});
// Verify userMessages is populated from inputHistory
await waitFor(() =>
@@ -1908,12 +2026,17 @@ describe('AppContainer State Management', () => {
loadHistory: vi.fn(),
});
await act(async () => {
// Rerender to apply the new mock.
rerender(getAppContainer());
});
// Verify that userMessages still contains the input history
// (it should not be affected by clearing conversation history)
expect(capturedUIState.userMessages).toContain('first prompt');
expect(capturedUIState.userMessages).toContain('second prompt');
unmount();
unmount!();
});
});
@@ -1928,7 +2051,11 @@ describe('AppContainer State Management', () => {
// Clear previous calls
mocks.mockStdout.write.mockClear();
const { unmount } = renderAppContainer();
let compUnmount: () => void = () => {};
await act(async () => {
const { unmount } = renderAppContainer();
compUnmount = unmount;
});
// Allow async effects to run
await waitFor(() => expect(capturedUIState).toBeTruthy());
@@ -1944,7 +2071,7 @@ describe('AppContainer State Management', () => {
);
expect(clearTerminalCalls).toHaveLength(0);
unmount();
compUnmount();
});
});
});
+4 -18
View File
@@ -35,7 +35,6 @@ import {
type IdeContext,
type UserTierId,
type UserFeedbackPayload,
DEFAULT_GEMINI_FLASH_MODEL,
IdeClient,
ideContextStore,
getErrorMessage,
@@ -50,7 +49,6 @@ import {
coreEvents,
CoreEvent,
refreshServerHierarchicalMemory,
type ModelChangedPayload,
type MemoryChangedPayload,
writeToStdout,
disableMouseEvents,
@@ -256,12 +254,7 @@ export const AppContainer = (props: AppContainerProps) => {
);
// Helper to determine the effective model, considering the fallback state.
const getEffectiveModel = useCallback(() => {
if (config.isInFallbackMode()) {
return DEFAULT_GEMINI_FLASH_MODEL;
}
return config.getModel();
}, [config]);
const getEffectiveModel = useCallback(() => config.getModel(), [config]);
const [currentModel, setCurrentModel] = useState(getEffectiveModel());
@@ -340,22 +333,15 @@ export const AppContainer = (props: AppContainerProps) => {
// Subscribe to fallback mode and model changes from core
useEffect(() => {
const handleFallbackModeChanged = () => {
const effectiveModel = getEffectiveModel();
setCurrentModel(effectiveModel);
const handleModelChanged = () => {
setCurrentModel(config.getModel());
};
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]);
}, [getEffectiveModel, config]);
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
useConsoleMessages();
+6 -2
View File
@@ -7,7 +7,11 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { shortenPath, tildeifyPath } from '@google/gemini-cli-core';
import {
shortenPath,
tildeifyPath,
getDisplayString,
} from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import { ThemedGradient } from './ThemedGradient.js';
@@ -145,7 +149,7 @@ export const Footer: React.FC = () => {
<Box alignItems="center" justifyContent="flex-end">
<Box alignItems="center">
<Text color={theme.text.accent}>
{model}
{getDisplayString(model)}
{!hideContextPercentage && (
<>
{' '}
@@ -13,6 +13,8 @@ import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
FLASH_PREVIEW_MODEL_REVERT_BEFORE_MERGE,
PREVIEW_GEMINI_FLASH_MODEL,
UserTierId,
} from '@google/gemini-cli-core';
@@ -44,7 +46,9 @@ export function ProQuotaDialog({
// flash and flash lite don't have options to switch or upgrade.
if (
failedModel === DEFAULT_GEMINI_FLASH_MODEL ||
failedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL
failedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL ||
failedModel === PREVIEW_GEMINI_FLASH_MODEL ||
failedModel === FLASH_PREVIEW_MODEL_REVERT_BEFORE_MERGE
) {
items = [
{
@@ -283,7 +283,7 @@ describe('useQuotaAndFallback', () => {
const lastCall = (mockHistoryManager.addItem as Mock).mock
.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toContain('Switched to fallback model.');
expect(lastCall.text).toContain('Switched to fallback model model-B');
});
}
@@ -316,9 +316,9 @@ describe('useQuotaAndFallback', () => {
const message = request!.message;
expect(message).toBe(
`It seems like you don't have access to Gemini 3.
`It seems like you don't have access to gemini-3-pro-preview.
Learn more at https://goo.gle/enable-preview-features
To disable Gemini 3, disable "Preview features" in /settings.`,
To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
);
// Simulate the user choosing to switch
@@ -415,7 +415,9 @@ To disable Gemini 3, disable "Preview features" in /settings.`,
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toContain('Switched to fallback model.');
expect(lastCall.text).toContain(
'Switched to fallback model gemini-flash',
);
});
it('should show a special message when falling back from the preview model', async () => {
@@ -449,7 +451,7 @@ To disable Gemini 3, disable "Preview features" in /settings.`,
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toContain(
`Switched to fallback model gemini-2.5-pro. We will periodically check if ${PREVIEW_GEMINI_MODEL} is available again.`,
`Switched to fallback model gemini-2.5-pro`,
);
});
@@ -484,7 +486,7 @@ To disable Gemini 3, disable "Preview features" in /settings.`,
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toContain(
`Switched to fallback model gemini-2.5-flash.`,
`Switched to fallback model gemini-2.5-flash`,
);
});
});
@@ -14,6 +14,7 @@ import {
type UserTierId,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
VALID_GEMINI_MODELS,
} from '@google/gemini-cli-core';
import { useCallback, useEffect, useRef, useState } from 'react';
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -71,12 +72,15 @@ export function useQuotaAndFallback({
`/auth to switch to API key.`,
].filter(Boolean);
message = messageLines.join('\n');
} else if (error instanceof ModelNotFoundError) {
} else if (
error instanceof ModelNotFoundError &&
VALID_GEMINI_MODELS.has(failedModel)
) {
isModelNotFoundError = true;
const messageLines = [
`It seems like you don't have access to Gemini 3.`,
`It seems like you don't have access to ${failedModel}.`,
`Learn more at https://goo.gle/enable-preview-features`,
`To disable Gemini 3, disable "Preview features" in /settings.`,
`To disable ${failedModel}, disable "Preview features" in /settings.`,
];
message = messageLines.join('\n');
} else {
@@ -120,30 +124,20 @@ export function useQuotaAndFallback({
isDialogPending.current = false; // Reset the flag here
if (choice === 'retry_always') {
// If we were recovering from a Preview Model failure, show a specific message.
if (proQuotaRequest.failedModel === PREVIEW_GEMINI_MODEL) {
const showPeriodicalCheckMessage =
!proQuotaRequest.isModelNotFoundError &&
proQuotaRequest.fallbackModel === DEFAULT_GEMINI_MODEL;
historyManager.addItem(
{
type: MessageType.INFO,
text: `Switched to fallback model ${proQuotaRequest.fallbackModel}. ${showPeriodicalCheckMessage ? `We will periodically check if ${PREVIEW_GEMINI_MODEL} is available again.` : ''}`,
},
Date.now(),
);
} else {
historyManager.addItem(
{
type: MessageType.INFO,
text: 'Switched to fallback model.',
},
Date.now(),
);
}
// Explicitly set the model to the fallback model to persist the user's choice.
// This ensures the Footer updates and future turns use this model.
config.setModel(proQuotaRequest.fallbackModel);
historyManager.addItem(
{
type: MessageType.INFO,
text: `Switched to fallback model ${proQuotaRequest.fallbackModel}`,
},
Date.now(),
);
}
},
[proQuotaRequest, historyManager],
[proQuotaRequest, historyManager, config],
);
return {