diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.ts b/cli/src/agent/backends/acp/AcpStdioTransport.ts index a25ec1bcf..7dea0524a 100644 --- a/cli/src/agent/backends/acp/AcpStdioTransport.ts +++ b/cli/src/agent/backends/acp/AcpStdioTransport.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { logger } from '@/ui/logger'; import { killProcessByChildProcess } from '@/utils/process'; +import { GEMINI_MODEL_PRESETS } from '@hapi/protocol'; interface JsonRpcRequest { jsonrpc: '2.0'; @@ -303,7 +304,7 @@ export class AcpStdioTransport { if (lowerText.includes('status 404') || lowerText.includes('model not found') || lowerText.includes('not_found')) { this.stderrErrorHandler({ type: 'model_not_found', - message: 'Model not found. Available models: gemini-3.1-pro-preview, gemini-3-flash-preview, gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite', + message: `Model not found. Available models: ${GEMINI_MODEL_PRESETS.join(', ')}`, raw: text }); return; diff --git a/cli/src/gemini/loop.ts b/cli/src/gemini/loop.ts index c913944c0..453e55261 100644 --- a/cli/src/gemini/loop.ts +++ b/cli/src/gemini/loop.ts @@ -46,17 +46,22 @@ export async function geminiLoop(opts: GeminiLoopOptions): Promise { session.onSessionFound(opts.resumeSessionId); } + const getCurrentModel = (): string | undefined => { + const sessionModel = session.getModel(); + return sessionModel != null ? sessionModel : opts.model; + }; + await runLocalRemoteSession({ session, startingMode: opts.startingMode, logTag: 'gemini-loop', runLocal: (instance) => geminiLocalLauncher(instance, { - model: opts.model, + model: getCurrentModel(), allowedTools: opts.allowedTools, hookSettingsPath: opts.hookSettingsPath }), runRemote: (instance) => geminiRemoteLauncher(instance, { - model: opts.model, + model: getCurrentModel(), hookSettingsPath: opts.hookSettingsPath }), onSessionReady: opts.onSessionReady diff --git a/cli/src/gemini/runGemini.test.ts b/cli/src/gemini/runGemini.test.ts index 2069eeffa..52ee2eb8f 100644 --- a/cli/src/gemini/runGemini.test.ts +++ b/cli/src/gemini/runGemini.test.ts @@ -1,5 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +const mockGeminiSession = vi.hoisted(() => ({ + setModel: vi.fn(), + setPermissionMode: vi.fn(), + stopKeepAlive: vi.fn() +})); + const harness = vi.hoisted(() => ({ bootstrapArgs: [] as Array>, geminiLoopArgs: [] as Array>, @@ -24,6 +30,10 @@ vi.mock('@/agent/sessionFactory', () => ({ vi.mock('./loop', () => ({ geminiLoop: vi.fn(async (options: Record) => { harness.geminiLoopArgs.push(options); + const onSessionReady = options.onSessionReady as ((session: unknown) => void) | undefined; + if (onSessionReady) { + onSessionReady(mockGeminiSession); + } }) })); @@ -78,6 +88,8 @@ describe('runGemini', () => { beforeEach(() => { harness.bootstrapArgs.length = 0; harness.geminiLoopArgs.length = 0; + mockGeminiSession.setModel.mockReset(); + mockGeminiSession.setPermissionMode.mockReset(); harness.session.onUserMessage.mockReset(); harness.session.rpcHandlerManager.registerHandler.mockReset(); resolveGeminiRuntimeConfigMock.mockReset(); @@ -97,13 +109,125 @@ describe('runGemini', () => { it('does not persist the hardcoded default fallback model', async () => { resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-2.5-pro', + model: 'gemini-3-flash-preview', modelSource: 'default' }); await runGemini({}); expect(harness.bootstrapArgs[0]?.model).toBeUndefined(); + expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-3-flash-preview'); + }); + + it('applies model change via set-session-config RPC', async () => { + resolveGeminiRuntimeConfigMock.mockReturnValue({ + model: 'gemini-3-flash-preview', + modelSource: 'default' + }); + + await runGemini({}); + + const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; + const configHandler = registerCalls.find( + (call: unknown[]) => call[0] === 'set-session-config' + ); + expect(configHandler).toBeDefined(); + + const handler = configHandler![1] as (payload: unknown) => Promise; + const result = await handler({ model: 'gemini-2.5-flash' }) as Record; + const applied = result.applied as Record; + expect(applied.model).toBe('gemini-2.5-flash'); + }); + + it('rejects invalid model in set-session-config RPC', async () => { + resolveGeminiRuntimeConfigMock.mockReturnValue({ + model: 'gemini-3-flash-preview', + modelSource: 'default' + }); + + await runGemini({}); + + const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; + const configHandler = registerCalls.find( + (call: unknown[]) => call[0] === 'set-session-config' + ); + const handler = configHandler![1] as (payload: unknown) => Promise; + await expect(handler({ model: 123 })).rejects.toThrow(); + }); + + it('accepts null model (Auto) in set-session-config RPC', async () => { + resolveGeminiRuntimeConfigMock.mockReturnValue({ + model: 'gemini-3-flash-preview', + modelSource: 'default' + }); + + await runGemini({}); + + const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; + const configHandler = registerCalls.find( + (call: unknown[]) => call[0] === 'set-session-config' + ); + const handler = configHandler![1] as (payload: unknown) => Promise; + const result = await handler({ model: null }) as Record; + const applied = result.applied as Record; + // null (Default) should be passed through to hub for DB clearing + expect(applied.model).toBeNull(); + }); + + it('only includes changed fields in applied response', async () => { + resolveGeminiRuntimeConfigMock.mockReturnValue({ + model: 'gemini-3-flash-preview', + modelSource: 'default' + }); + + await runGemini({}); + + const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; + const configHandler = registerCalls.find( + (call: unknown[]) => call[0] === 'set-session-config' + ); + const handler = configHandler![1] as (payload: unknown) => Promise; + const result = await handler({ permissionMode: 'default' }) as Record; + const applied = result.applied as Record; + expect(applied.permissionMode).toBe('default'); + expect(applied).not.toHaveProperty('model'); + }); + + it('stores null model in session on Default selection for keepalive', async () => { + resolveGeminiRuntimeConfigMock.mockReturnValue({ + model: 'gemini-2.5-pro', + modelSource: 'default' + }); + + await runGemini({}); + + const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; + const configHandler = registerCalls.find( + (call: unknown[]) => call[0] === 'set-session-config' + ); + const handler = configHandler![1] as (payload: unknown) => Promise; + + // First set an explicit model + await handler({ model: 'gemini-2.5-flash' }); + expect(mockGeminiSession.setModel).toHaveBeenLastCalledWith('gemini-2.5-flash'); + + // Then select Default (null) — session should store null, not concrete model + await handler({ model: null }); + expect(mockGeminiSession.setModel).toHaveBeenLastCalledWith(null); + }); + + it('passes machine default (not startup model) to geminiLoop for fallback', async () => { + // Session started with explicit model, but machine default differs + resolveGeminiRuntimeConfigMock.mockImplementation((opts?: { model?: string }) => { + if (opts?.model) { + return { model: opts.model, modelSource: 'explicit' }; + } + return { model: 'gemini-2.5-pro', modelSource: 'default' }; + }); + + await runGemini({ model: 'gemini-2.5-flash' }); + + // geminiLoop should receive machine default as fallback, not the explicit startup model expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-2.5-pro'); }); diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts index 9c2a9c1a8..b290f7e6c 100644 --- a/cli/src/gemini/runGemini.ts +++ b/cli/src/gemini/runGemini.ts @@ -37,6 +37,7 @@ export async function runGemini(opts: { controlledByUser: false }; + const machineDefault = resolveGeminiRuntimeConfig().model; const runtimeConfig = resolveGeminiRuntimeConfig({ model: opts.model }); const persistedModel = runtimeConfig.modelSource === 'default' ? undefined @@ -62,7 +63,8 @@ export async function runGemini(opts: { const sessionWrapperRef: { current: GeminiSession | null } = { current: null }; let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; - const resolvedModel = runtimeConfig.model; + let sessionModel: string | null = persistedModel ?? null; + let resolvedModel = sessionModel ?? machineDefault; const hookServer = await startHookServer({ onSessionHook: (sessionId, data) => { @@ -105,7 +107,8 @@ export async function runGemini(opts: { return; } sessionInstance.setPermissionMode(currentPermissionMode); - logger.debug(`[gemini] Synced session permission mode for keepalive: ${currentPermissionMode}`); + sessionInstance.setModel(sessionModel); + logger.debug(`[gemini] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${resolvedModel}`); }; session.onUserMessage((message) => { @@ -125,18 +128,36 @@ export async function runGemini(opts: { return parsed.data as PermissionMode; }; + const resolveModel = (value: unknown): string | null => { + if (value === null) { + return null; + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error('Invalid model'); + } + return value.trim(); + }; + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { if (!payload || typeof payload !== 'object') { throw new Error('Invalid session config payload'); } - const config = payload as { permissionMode?: unknown }; + const config = payload as { permissionMode?: unknown; model?: unknown }; + const applied: Record = {}; if (config.permissionMode !== undefined) { currentPermissionMode = resolvePermissionMode(config.permissionMode); + applied.permissionMode = currentPermissionMode; + } + + if (config.model !== undefined) { + sessionModel = resolveModel(config.model); + resolvedModel = sessionModel ?? machineDefault; + applied.model = sessionModel; } syncSessionMode(); - return { applied: { permissionMode: currentPermissionMode } }; + return { applied }; }); try { @@ -148,7 +169,7 @@ export async function runGemini(opts: { session, api, permissionMode: currentPermissionMode, - model: resolvedModel, + model: machineDefault, hookSettingsPath, resumeSessionId: opts.resumeSessionId, onModeChange: createModeChangeHandler(session), diff --git a/cli/src/gemini/session.ts b/cli/src/gemini/session.ts index 98aa97ce1..800d5658a 100644 --- a/cli/src/gemini/session.ts +++ b/cli/src/gemini/session.ts @@ -78,6 +78,10 @@ export class GeminiSession extends AgentSessionBase { this.permissionMode = mode; }; + setModel = (model: string | null): void => { + this.model = model; + }; + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { this.localLaunchFailure = { message, exitReason }; }; diff --git a/cli/src/gemini/utils/config.ts b/cli/src/gemini/utils/config.ts index 0c90d6ec3..4426f52c4 100644 --- a/cli/src/gemini/utils/config.ts +++ b/cli/src/gemini/utils/config.ts @@ -2,11 +2,12 @@ import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { logger } from '@/ui/logger'; +import { DEFAULT_GEMINI_MODEL } from '@hapi/protocol'; export const GEMINI_API_KEY_ENV = 'GEMINI_API_KEY'; export const GOOGLE_API_KEY_ENV = 'GOOGLE_API_KEY'; export const GEMINI_MODEL_ENV = 'GEMINI_MODEL'; -export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro'; +export { DEFAULT_GEMINI_MODEL }; export type GeminiLocalConfig = { token?: string; @@ -91,7 +92,7 @@ export function resolveGeminiRuntimeConfig(opts: { const local = readGeminiLocalConfig(); let modelSource: GeminiModelSource = 'default'; - let model = DEFAULT_GEMINI_MODEL; + let model: string = DEFAULT_GEMINI_MODEL; if (opts.model) { model = opts.model; diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index c0d51203f..37eee90c3 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -316,8 +316,8 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const flavor = sessionResult.session.metadata?.flavor ?? 'claude' - if (flavor !== 'claude') { - return c.json({ error: 'Model selection is only supported for Claude sessions' }, 400) + if (flavor !== 'claude' && flavor !== 'gemini') { + return c.json({ error: 'Model selection is only supported for Claude and Gemini sessions' }, 400) } try { diff --git a/shared/src/modes.ts b/shared/src/modes.ts index af273377f..76b0fb00c 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -40,6 +40,18 @@ export type ClaudeModelPreset = typeof CLAUDE_MODEL_PRESETS[number] export type AgentFlavor = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' +export const GEMINI_MODEL_LABELS = { + 'gemini-3.1-pro-preview': 'Gemini 3.1 Pro Preview', + 'gemini-3-flash-preview': 'Gemini 3 Flash Preview', + 'gemini-2.5-pro': 'Gemini 2.5 Pro', + 'gemini-2.5-flash': 'Gemini 2.5 Flash', + 'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite', +} as const + +export type GeminiModelPreset = keyof typeof GEMINI_MODEL_LABELS +export const GEMINI_MODEL_PRESETS = Object.keys(GEMINI_MODEL_LABELS) as GeminiModelPreset[] +export const DEFAULT_GEMINI_MODEL: GeminiModelPreset = 'gemini-2.5-pro' + export const PERMISSION_MODE_LABELS: Record = { default: 'Default', acceptEdits: 'Accept Edits', diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index a72119382..fd8ec9b86 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -20,7 +20,7 @@ import { useActiveSuggestions } from '@/hooks/useActiveSuggestions' import { applySuggestion } from '@/utils/applySuggestion' import { usePlatform } from '@/hooks/usePlatform' import { usePWAInstall } from '@/hooks/usePWAInstall' -import { isClaudeFlavor } from '@/lib/agentFlavorUtils' +import { isClaudeFlavor, supportsModelChange } from '@/lib/agentFlavorUtils' import { markSkillUsed } from '@/lib/recent-skills' import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' import { Autocomplete } from '@/components/ChatInput/Autocomplete' @@ -28,7 +28,7 @@ import { StatusBar } from '@/components/AssistantChat/StatusBar' import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons' import { AttachmentItem } from '@/components/AssistantChat/AttachmentItem' import { useTranslation } from '@/lib/use-translation' -import { getClaudeComposerModelOptions, getNextClaudeComposerModel } from './claudeModelOptions' +import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions' import { getClaudeComposerEffortOptions } from './claudeEffortOptions' export interface TextInputState { @@ -266,8 +266,8 @@ export function HappyComposer(props: { [agentFlavor] ) const claudeModelOptions = useMemo( - () => getClaudeComposerModelOptions(model), - [model] + () => getModelOptionsForFlavor(agentFlavor, model), + [agentFlavor, model] ) const claudeEffortOptions = useMemo( () => getClaudeComposerEffortOptions(effort), @@ -352,9 +352,9 @@ export function HappyComposer(props: { useEffect(() => { const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { - if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelChange && isClaudeFlavor(agentFlavor)) { + if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelChange && supportsModelChange(agentFlavor)) { e.preventDefault() - onModelChange(getNextClaudeComposerModel(model)) + onModelChange(getNextModelForFlavor(agentFlavor, model)) haptic('light') } } @@ -439,7 +439,7 @@ export function HappyComposer(props: { const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0) const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) - const showModelSettings = Boolean(onModelChange && isClaudeFlavor(agentFlavor)) + const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor)) const showEffortSettings = Boolean(onEffortChange && isClaudeFlavor(agentFlavor)) const showSettingsButton = Boolean(showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings) const showAbortButton = true diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts new file mode 100644 index 000000000..0bf4a3a4a --- /dev/null +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions' + +describe('getModelOptionsForFlavor', () => { + it('returns Gemini model options for gemini flavor', () => { + const options = getModelOptionsForFlavor('gemini') + expect(options[0]).toEqual({ value: null, label: 'Default' }) + expect(options.some((o) => o.value === 'gemini-3-flash-preview')).toBe(true) + expect(options.some((o) => o.value === 'gemini-2.5-flash')).toBe(true) + }) + + it('returns Claude model options for claude flavor', () => { + const options = getModelOptionsForFlavor('claude') + expect(options[0]).toEqual({ value: null, label: 'Auto' }) + expect(options.some((o) => o.value === 'sonnet')).toBe(true) + expect(options.some((o) => o.value === 'opus')).toBe(true) + }) + + it('includes custom Gemini model from env/config in options', () => { + const options = getModelOptionsForFlavor('gemini', 'gemini-custom-experiment') + expect(options.some((o) => o.value === 'gemini-custom-experiment')).toBe(true) + }) + + it('does not duplicate a preset Gemini model', () => { + const options = getModelOptionsForFlavor('gemini', 'gemini-2.5-flash') + const flashCount = options.filter((o) => o.value === 'gemini-2.5-flash').length + expect(flashCount).toBe(1) + }) +}) + +describe('getNextModelForFlavor', () => { + it('cycles Gemini models', () => { + const next = getNextModelForFlavor('gemini', null) + expect(next).not.toBeNull() + }) + + it('cycles Claude models', () => { + const next = getNextModelForFlavor('claude', null) + expect(next).not.toBeNull() + }) +}) diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts new file mode 100644 index 000000000..57271eca0 --- /dev/null +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -0,0 +1,40 @@ +import { MODEL_OPTIONS } from '@/components/NewSession/types' +import { getClaudeComposerModelOptions, getNextClaudeComposerModel } from './claudeModelOptions' +import type { ClaudeComposerModelOption } from './claudeModelOptions' + +export type ModelOption = ClaudeComposerModelOption + +function getGeminiModelOptions(currentModel?: string | null): ModelOption[] { + const options = MODEL_OPTIONS.gemini.map((m) => ({ + value: m.value === 'auto' ? null : m.value, + label: m.label + })) + const normalized = currentModel?.trim() || null + if (normalized && !options.some((o) => o.value === normalized)) { + options.splice(1, 0, { value: normalized, label: normalized }) + } + return options +} + +function getNextGeminiModel(currentModel?: string | null): string | null { + const options = getGeminiModelOptions(currentModel) + const currentIndex = options.findIndex((o) => o.value === (currentModel ?? null)) + if (currentIndex === -1) { + return options[0]?.value ?? null + } + return options[(currentIndex + 1) % options.length]?.value ?? null +} + +export function getModelOptionsForFlavor(flavor: string | undefined | null, currentModel?: string | null): ModelOption[] { + if (flavor === 'gemini') { + return getGeminiModelOptions(currentModel) + } + return getClaudeComposerModelOptions(currentModel) +} + +export function getNextModelForFlavor(flavor: string | undefined | null, currentModel?: string | null): string | null { + if (flavor === 'gemini') { + return getNextGeminiModel(currentModel) + } + return getNextClaudeComposerModel(currentModel) +} diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts index 55ead79d0..332cf2e02 100644 --- a/web/src/components/NewSession/types.ts +++ b/web/src/components/NewSession/types.ts @@ -1,3 +1,5 @@ +import { GEMINI_MODEL_PRESETS, GEMINI_MODEL_LABELS } from '@hapi/protocol' + export type AgentType = 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' export type SessionType = 'simple' | 'worktree' export type CodexReasoningEffort = 'default' | 'low' | 'medium' | 'high' | 'xhigh' @@ -23,12 +25,8 @@ export const MODEL_OPTIONS: Record ({ value: m, label: GEMINI_MODEL_LABELS[m] })), ], opencode: [], } diff --git a/web/src/lib/agentFlavorUtils.ts b/web/src/lib/agentFlavorUtils.ts index 4758a3de4..d835b3505 100644 --- a/web/src/lib/agentFlavorUtils.ts +++ b/web/src/lib/agentFlavorUtils.ts @@ -13,3 +13,7 @@ export function isCursorFlavor(flavor?: string | null): boolean { export function isKnownFlavor(flavor?: string | null): boolean { return isClaudeFlavor(flavor) || isCodexFamilyFlavor(flavor) || isCursorFlavor(flavor) } + +export function supportsModelChange(flavor?: string | null): boolean { + return flavor === 'claude' || flavor === 'gemini' +}