feat(gemini): support mid-session model change (#379)

This commit is contained in:
Junmo Kim
2026-04-01 11:15:11 +08:00
committed by GitHub
parent fa0ce1ca94
commit 4eb88c5d7e
13 changed files with 277 additions and 26 deletions
@@ -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;
+7 -2
View File
@@ -46,17 +46,22 @@ export async function geminiLoop(opts: GeminiLoopOptions): Promise<void> {
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
+125 -1
View File
@@ -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<Record<string, unknown>>,
geminiLoopArgs: [] as Array<Record<string, unknown>>,
@@ -24,6 +30,10 @@ vi.mock('@/agent/sessionFactory', () => ({
vi.mock('./loop', () => ({
geminiLoop: vi.fn(async (options: Record<string, unknown>) => {
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<unknown>;
const result = await handler({ model: 'gemini-2.5-flash' }) as Record<string, unknown>;
const applied = result.applied as Record<string, unknown>;
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<unknown>;
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<unknown>;
const result = await handler({ model: null }) as Record<string, unknown>;
const applied = result.applied as Record<string, unknown>;
// 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<unknown>;
const result = await handler({ permissionMode: 'default' }) as Record<string, unknown>;
const applied = result.applied as Record<string, unknown>;
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<unknown>;
// 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');
});
+26 -5
View File
@@ -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<string, unknown> = {};
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),
+4
View File
@@ -78,6 +78,10 @@ export class GeminiSession extends AgentSessionBase<GeminiMode> {
this.permissionMode = mode;
};
setModel = (model: string | null): void => {
this.model = model;
};
recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
this.localLaunchFailure = { message, exitReason };
};
+3 -2
View File
@@ -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;
+2 -2
View File
@@ -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 {
+12
View File
@@ -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<PermissionMode, string> = {
default: 'Default',
acceptEdits: 'Accept Edits',
@@ -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
@@ -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()
})
})
@@ -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)
}
+4 -6
View File
@@ -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<AgentType, { value: string; label: string }[]
],
cursor: [],
gemini: [
{ value: 'auto', label: 'Auto' },
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
{ value: 'auto', label: 'Default' },
...GEMINI_MODEL_PRESETS.map(m => ({ value: m, label: GEMINI_MODEL_LABELS[m] })),
],
opencode: [],
}
+4
View File
@@ -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'
}