From f08c4e40497e2262a0be5b02b27ba549829527c2 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Tue, 31 Mar 2026 20:04:14 +0900 Subject: [PATCH 1/7] refactor(gemini): consolidate model definitions into shared module - Add GEMINI_MODEL_LABELS (single source of truth), GEMINI_MODEL_PRESETS (derived via Object.keys), and DEFAULT_GEMINI_MODEL to shared/modes.ts - Import DEFAULT_GEMINI_MODEL from @hapi/protocol in cli/config.ts - Generate web model options from shared presets and labels - Use GEMINI_MODEL_PRESETS.join() for ACP error message --- cli/src/agent/backends/acp/AcpStdioTransport.ts | 3 ++- cli/src/gemini/utils/config.ts | 5 +++-- shared/src/modes.ts | 12 ++++++++++++ web/src/components/NewSession/types.ts | 10 ++++------ 4 files changed, 21 insertions(+), 9 deletions(-) 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/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/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/NewSession/types.ts b/web/src/components/NewSession/types.ts index 55ead79d0..81d9bb063 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, DEFAULT_GEMINI_MODEL } 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: [], } From 94d21191cab4a662b2fcf486273a6bba8742cba7 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Tue, 31 Mar 2026 20:04:26 +0900 Subject: [PATCH 2/7] feat(gemini): support mid-session model change - Add model field handling to Gemini set-session-config RPC handler, including null (Default) support, following the runClaude.ts pattern - Pass null through to hub for DB clearing on Default selection - Add setModel() to GeminiSession for keepalive/DB persistence - Read current model from session in loop launchers on local/remote switch - Allow /sessions/:id/model endpoint for Gemini sessions (was Claude-only) - Introduce supportsModelChange() flavor capability function - Add flavor-aware model options for HappyComposer UI, including custom model display for env/config-provided non-preset models --- cli/src/gemini/loop.ts | 9 ++- cli/src/gemini/runGemini.test.ts | 78 ++++++++++++++++++- cli/src/gemini/runGemini.ts | 27 ++++++- cli/src/gemini/session.ts | 4 + hub/src/web/routes/sessions.ts | 4 +- .../AssistantChat/HappyComposer.tsx | 14 ++-- .../AssistantChat/modelOptions.test.ts | 30 +++++++ .../components/AssistantChat/modelOptions.ts | 40 ++++++++++ web/src/lib/agentFlavorUtils.ts | 4 + 9 files changed, 193 insertions(+), 17 deletions(-) create mode 100644 web/src/components/AssistantChat/modelOptions.test.ts create mode 100644 web/src/components/AssistantChat/modelOptions.ts 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..630a1cd5f 100644 --- a/cli/src/gemini/runGemini.test.ts +++ b/cli/src/gemini/runGemini.test.ts @@ -97,14 +97,88 @@ 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-2.5-pro'); + 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('passes resumeSessionId through to geminiLoop', async () => { diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts index 9c2a9c1a8..6a2176159 100644 --- a/cli/src/gemini/runGemini.ts +++ b/cli/src/gemini/runGemini.ts @@ -62,7 +62,7 @@ export async function runGemini(opts: { const sessionWrapperRef: { current: GeminiSession | null } = { current: null }; let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; - const resolvedModel = runtimeConfig.model; + let resolvedModel = runtimeConfig.model; const hookServer = await startHookServer({ onSessionHook: (sessionId, data) => { @@ -105,7 +105,8 @@ export async function runGemini(opts: { return; } sessionInstance.setPermissionMode(currentPermissionMode); - logger.debug(`[gemini] Synced session permission mode for keepalive: ${currentPermissionMode}`); + sessionInstance.setModel(resolvedModel); + logger.debug(`[gemini] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${resolvedModel}`); }; session.onUserMessage((message) => { @@ -125,18 +126,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) { + const newModel = resolveModel(config.model); + resolvedModel = newModel ?? runtimeConfig.model; + applied.model = newModel; } syncSessionMode(); - return { applied: { permissionMode: currentPermissionMode } }; + return { applied }; }); try { 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/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/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..6cf92f449 --- /dev/null +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -0,0 +1,30 @@ +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 (Gemini 2.5 Pro)' }) + 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) + }) +}) + +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/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' +} From 7edef2ff192650108f47f1d6f7640f947afa999f Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Tue, 31 Mar 2026 20:14:44 +0900 Subject: [PATCH 3/7] fix(gemini): preserve null model through keepalive path Separate sessionModel (nullable, for DB persistence) from resolvedModel (always concrete, for runtime). syncSessionMode() now stores sessionModel so the hub correctly clears the model preference on Default selection. --- cli/src/gemini/runGemini.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts index 6a2176159..1cc4dcffb 100644 --- a/cli/src/gemini/runGemini.ts +++ b/cli/src/gemini/runGemini.ts @@ -62,7 +62,8 @@ export async function runGemini(opts: { const sessionWrapperRef: { current: GeminiSession | null } = { current: null }; let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; - let resolvedModel = runtimeConfig.model; + let sessionModel: string | null = persistedModel ?? null; + let resolvedModel = sessionModel ?? runtimeConfig.model; const hookServer = await startHookServer({ onSessionHook: (sessionId, data) => { @@ -105,7 +106,7 @@ export async function runGemini(opts: { return; } sessionInstance.setPermissionMode(currentPermissionMode); - sessionInstance.setModel(resolvedModel); + sessionInstance.setModel(sessionModel); logger.debug(`[gemini] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${resolvedModel}`); }; @@ -149,9 +150,9 @@ export async function runGemini(opts: { } if (config.model !== undefined) { - const newModel = resolveModel(config.model); - resolvedModel = newModel ?? runtimeConfig.model; - applied.model = newModel; + sessionModel = resolveModel(config.model); + resolvedModel = sessionModel ?? runtimeConfig.model; + applied.model = sessionModel; } syncSessionMode(); From 0b86e447ac7ec182f9113be3e18ef334013b1957 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Tue, 31 Mar 2026 20:18:13 +0900 Subject: [PATCH 4/7] test(gemini): add coverage for bot review findings - Test null model propagation through keepalive/syncSessionMode path - Test custom env/config model appears in Gemini model options - Test preset model is not duplicated in options --- cli/src/gemini/runGemini.test.ts | 35 +++++++++++++++++++ .../AssistantChat/modelOptions.test.ts | 11 ++++++ 2 files changed, 46 insertions(+) diff --git a/cli/src/gemini/runGemini.test.ts b/cli/src/gemini/runGemini.test.ts index 630a1cd5f..c7ee8e4f2 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(); @@ -181,6 +193,29 @@ describe('runGemini', () => { 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 resumeSessionId through to geminiLoop', async () => { resolveGeminiRuntimeConfigMock.mockReturnValue({ model: 'gemini-2.5-pro', diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index 6cf92f449..fcf78d428 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -15,6 +15,17 @@ describe('getModelOptionsForFlavor', () => { 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', () => { From 97b01292424670708acb7491037f903331305cb4 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Tue, 31 Mar 2026 21:16:13 +0900 Subject: [PATCH 5/7] fix(web): use plain Default label for Gemini model selector The resolved default model depends on env/local config which the web cannot read. Showing a specific model name would be misleading when the actual default differs from the hardcoded label. --- web/src/components/AssistantChat/modelOptions.test.ts | 2 +- web/src/components/NewSession/types.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index fcf78d428..0bf4a3a4a 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -4,7 +4,7 @@ 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 (Gemini 2.5 Pro)' }) + 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) }) diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts index 81d9bb063..332cf2e02 100644 --- a/web/src/components/NewSession/types.ts +++ b/web/src/components/NewSession/types.ts @@ -1,4 +1,4 @@ -import { GEMINI_MODEL_PRESETS, GEMINI_MODEL_LABELS, DEFAULT_GEMINI_MODEL } from '@hapi/protocol' +import { GEMINI_MODEL_PRESETS, GEMINI_MODEL_LABELS } from '@hapi/protocol' export type AgentType = 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' export type SessionType = 'simple' | 'worktree' @@ -25,7 +25,7 @@ export const MODEL_OPTIONS: Record ({ value: m, label: GEMINI_MODEL_LABELS[m] })), ], opencode: [], From 70d57430c05ac0ee77619981946fc835c327396b Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Tue, 31 Mar 2026 21:23:25 +0900 Subject: [PATCH 6/7] fix(gemini): resolve machine default on Default selection, not startup model When a session started with an explicit model, selecting Default fell back to that startup model instead of the machine default. Now calls resolveGeminiRuntimeConfig() without args to obtain the true machine default (env > local config > hardcoded). Also use plain "Default" label since the web cannot know the resolved machine default. --- cli/src/gemini/runGemini.test.ts | 34 ++++++++++++++++++++++++++++++++ cli/src/gemini/runGemini.ts | 5 +++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/cli/src/gemini/runGemini.test.ts b/cli/src/gemini/runGemini.test.ts index c7ee8e4f2..36bb813d7 100644 --- a/cli/src/gemini/runGemini.test.ts +++ b/cli/src/gemini/runGemini.test.ts @@ -216,6 +216,40 @@ describe('runGemini', () => { expect(mockGeminiSession.setModel).toHaveBeenLastCalledWith(null); }); + it('falls back to machine default (not startup model) when Default is selected', async () => { + // Session started with explicit model + resolveGeminiRuntimeConfigMock.mockImplementation((opts?: { model?: string }) => { + if (opts?.model) { + return { model: opts.model, modelSource: 'explicit' }; + } + // Machine default (no args) — e.g. from env or hardcoded + return { model: 'gemini-2.5-pro', modelSource: 'default' }; + }); + + await runGemini({ model: 'gemini-2.5-flash' }); + + 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; + + // Verify session started with explicit model + expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-2.5-flash'); + + // Select Default (null) — session should store null + await handler({ model: null }); + expect(mockGeminiSession.setModel).toHaveBeenLastCalledWith(null); + + // resolvedModel (used for messageQueue/runtime) should be machine default, not startup model + // We verify this indirectly: trigger a user message and check the mode.model in the queue + const onUserMessage = harness.session.onUserMessage.mock.calls[0][0]; + onUserMessage({ content: { text: 'test', attachments: [] } }); + const lastMode = harness.geminiLoopArgs[0]; // loop was already called + // The key assertion: resolveGeminiRuntimeConfig() was called without args to get machine default + expect(resolveGeminiRuntimeConfigMock).toHaveBeenCalledWith(); + }); + it('passes resumeSessionId through to geminiLoop', async () => { resolveGeminiRuntimeConfigMock.mockReturnValue({ model: 'gemini-2.5-pro', diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts index 1cc4dcffb..4a096af36 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 @@ -63,7 +64,7 @@ export async function runGemini(opts: { const sessionWrapperRef: { current: GeminiSession | null } = { current: null }; let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; let sessionModel: string | null = persistedModel ?? null; - let resolvedModel = sessionModel ?? runtimeConfig.model; + let resolvedModel = sessionModel ?? machineDefault; const hookServer = await startHookServer({ onSessionHook: (sessionId, data) => { @@ -151,7 +152,7 @@ export async function runGemini(opts: { if (config.model !== undefined) { sessionModel = resolveModel(config.model); - resolvedModel = sessionModel ?? runtimeConfig.model; + resolvedModel = sessionModel ?? machineDefault; applied.model = sessionModel; } From b9e1885b32bb4799570425ef8c678d7dc0f1a31c Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Tue, 31 Mar 2026 22:26:59 +0900 Subject: [PATCH 7/7] fix(gemini): pass machine default to loop fallback instead of startup model geminiLoop receives machineDefault (from resolveGeminiRuntimeConfig() without args) instead of resolvedModel. This ensures getCurrentModel() in loop.ts falls back to the machine default when session model is null (Default selected), not the explicit model the session was started with. --- cli/src/gemini/runGemini.test.ts | 27 ++++----------------------- cli/src/gemini/runGemini.ts | 2 +- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/cli/src/gemini/runGemini.test.ts b/cli/src/gemini/runGemini.test.ts index 36bb813d7..52ee2eb8f 100644 --- a/cli/src/gemini/runGemini.test.ts +++ b/cli/src/gemini/runGemini.test.ts @@ -216,38 +216,19 @@ describe('runGemini', () => { expect(mockGeminiSession.setModel).toHaveBeenLastCalledWith(null); }); - it('falls back to machine default (not startup model) when Default is selected', async () => { - // Session started with explicit model + 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' }; } - // Machine default (no args) — e.g. from env or hardcoded return { model: 'gemini-2.5-pro', modelSource: 'default' }; }); await runGemini({ model: 'gemini-2.5-flash' }); - 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; - - // Verify session started with explicit model - expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-2.5-flash'); - - // Select Default (null) — session should store null - await handler({ model: null }); - expect(mockGeminiSession.setModel).toHaveBeenLastCalledWith(null); - - // resolvedModel (used for messageQueue/runtime) should be machine default, not startup model - // We verify this indirectly: trigger a user message and check the mode.model in the queue - const onUserMessage = harness.session.onUserMessage.mock.calls[0][0]; - onUserMessage({ content: { text: 'test', attachments: [] } }); - const lastMode = harness.geminiLoopArgs[0]; // loop was already called - // The key assertion: resolveGeminiRuntimeConfig() was called without args to get machine default - expect(resolveGeminiRuntimeConfigMock).toHaveBeenCalledWith(); + // geminiLoop should receive machine default as fallback, not the explicit startup model + expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-2.5-pro'); }); it('passes resumeSessionId through to geminiLoop', async () => { diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts index 4a096af36..b290f7e6c 100644 --- a/cli/src/gemini/runGemini.ts +++ b/cli/src/gemini/runGemini.ts @@ -169,7 +169,7 @@ export async function runGemini(opts: { session, api, permissionMode: currentPermissionMode, - model: resolvedModel, + model: machineDefault, hookSettingsPath, resumeSessionId: opts.resumeSessionId, onModeChange: createModeChangeHandler(session),