From b44885ae676652db2905e8cab6d8331a67adad6e Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Mon, 29 Jun 2026 11:42:41 +0800 Subject: [PATCH] feat(gemini): remove launchable Gemini CLI agent, keep old sessions readable (#953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gemini): remove launchable Gemini CLI agent, keep sessions readable Google sunset the consumer Gemini CLI (Pro/Ultra/free tiers stopped serving requests 2026-06-18). This removes the ability to launch/create Gemini CLI sessions while keeping existing stored Gemini sessions fully readable in the web UI. Removed (no longer launchable): - cli/src/gemini/ runtime (runGemini, loop, local/remote launchers, session, ACP backend, config, scanner) + GeminiDisplay ink view - `hapi gemini` command + registry entry + usage line - runner spawn branch & buildCliArgs mapping now reject gemini with a clear error; resume dispatch throws a clear "no longer supported" error - gemini dropped from the new-session agent selector via new CREATABLE_AGENT_FLAVORS, and from preferred-agent defaults Kept (read path — existing sessions still validate, load, render): - `gemini` in AGENT_FLAVORS / AgentFlavorSchema, FLAVOR_CAPS / FLAVOR_LABELS - AgentFlavorIcon badge, model-option labels, ACP message normalization, metadata.geminiSessionId, hub session dedup/resume-id Note: the Gemini *Live voice* backend is a separate feature and is untouched. Adds read-guarantee tests (stored gemini validates; excluded from creatable). typecheck + full suite green. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 * fix(gemini): reject gemini resume before handoff (#953 review) HAPI Bot [Major]: `hapi resume ` called handoffSessionToLocal() — which tells the running remote agent to exit — before reaching the gemini-unsupported throw in dispatchLocalResume, so it could stop the live/readable session and then fail locally. Move the gemini guard into resumeCommand.run before the handoff, so an active Gemini session is left running/readable instead of being stopped. Keep the dispatch-layer guard as defense-in-depth. Adds a regression test asserting handoffSessionToLocal is not called for an active gemini target. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 * fix(gemini): harden against stale gemini input (#953 review) Two [Minor] follow-ups from HAPI Bot: - newSessionFormDraft: coerce a restored browse draft's agent to a creatable flavor, so a pre-removal 'gemini' draft cannot submit agent:'gemini' even though the selector no longer offers it. - buildCliArgs: reject 'gemini' explicitly instead of silently falling through to the 'claude' command if the exported helper is reused outside the guarded spawnSession path. Updated the buildCliArgs precedence test to a creatable agent and added a test asserting buildCliArgs('gemini') throws. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 * fix(gemini): reset dependent draft fields when coercing stale agent (#953 review) Follow-up [Minor]: coercing a stale gemini draft's agent to claude left model/base/effort untouched, so a { agent:'gemini', model:'gemini-2.5-pro' } draft restored as claude *with* a Gemini model, which handleCreate() then sent to the runner. Now reset model / cursorSelectedBase / effort / modelReasoningEffort to defaults whenever the agent is coerced. Adds a regression test. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 * fix(gemini): tombstone `hapi gemini` so it errors clearly (#953 review) HAPI Bot [Major]: after removing geminiCommand from the registry, resolveCommand() treats `gemini` as an unknown subcommand and falls through to the default Claude command (forwarding "gemini" as an arg), so `hapi gemini` silently started Claude instead of reporting the sunset. Add an explicit tombstone `gemini` command that prints the sunset error and exits 1. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 * test(web): assert AgentSelector hides the sunset Gemini agent (#953) Render regression test confirming the new-session AgentSelector offers exactly CREATABLE_AGENT_FLAVORS and never shows a Gemini radio. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: HAPI Co-authored-by: Claude Opus 4.8 --- cli/src/commands/claude.ts | 1 - cli/src/commands/gemini.ts | 30 -- cli/src/commands/registry.ts | 20 +- cli/src/commands/resume.test.ts | 31 ++ cli/src/commands/resume.ts | 21 +- cli/src/gemini/geminiLocal.ts | 51 --- cli/src/gemini/geminiLocalLauncher.ts | 104 ------ cli/src/gemini/geminiRemoteLauncher.test.ts | 235 ------------- cli/src/gemini/geminiRemoteLauncher.ts | 306 ----------------- cli/src/gemini/loop.ts | 69 ---- cli/src/gemini/runGemini.test.ts | 308 ------------------ cli/src/gemini/runGemini.ts | 199 ----------- cli/src/gemini/session.ts | 100 ------ cli/src/gemini/types.ts | 8 - cli/src/gemini/utils/config.ts | 143 -------- cli/src/gemini/utils/geminiBackend.ts | 50 --- cli/src/gemini/utils/permissionHandler.ts | 170 ---------- cli/src/gemini/utils/sessionScanner.ts | 176 ---------- cli/src/runner/buildCliArgs.test.ts | 6 +- cli/src/runner/run.ts | 22 +- cli/src/ui/ink/GeminiDisplay.tsx | 187 ----------- shared/src/modes.test.ts | 21 ++ shared/src/modes.ts | 8 + .../NewSession/AgentSelector.test.tsx | 28 ++ .../components/NewSession/AgentSelector.tsx | 4 +- .../NewSession/newSessionFormDraft.test.ts | 25 ++ .../NewSession/newSessionFormDraft.ts | 23 +- web/src/components/NewSession/preferences.ts | 6 +- 28 files changed, 182 insertions(+), 2170 deletions(-) delete mode 100644 cli/src/commands/gemini.ts delete mode 100644 cli/src/gemini/geminiLocal.ts delete mode 100644 cli/src/gemini/geminiLocalLauncher.ts delete mode 100644 cli/src/gemini/geminiRemoteLauncher.test.ts delete mode 100644 cli/src/gemini/geminiRemoteLauncher.ts delete mode 100644 cli/src/gemini/loop.ts delete mode 100644 cli/src/gemini/runGemini.test.ts delete mode 100644 cli/src/gemini/runGemini.ts delete mode 100644 cli/src/gemini/session.ts delete mode 100644 cli/src/gemini/types.ts delete mode 100644 cli/src/gemini/utils/config.ts delete mode 100644 cli/src/gemini/utils/geminiBackend.ts delete mode 100644 cli/src/gemini/utils/permissionHandler.ts delete mode 100644 cli/src/gemini/utils/sessionScanner.ts delete mode 100644 cli/src/ui/ink/GeminiDisplay.tsx create mode 100644 web/src/components/NewSession/AgentSelector.test.tsx diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts index 6b72a517..367a0d12 100644 --- a/cli/src/commands/claude.ts +++ b/cli/src/commands/claude.ts @@ -88,7 +88,6 @@ ${chalk.bold('Usage:')} hapi auth Manage authentication hapi codex Start Codex mode hapi cursor Start Cursor Agent mode - hapi gemini Start Gemini ACP mode hapi opencode Start OpenCode ACP mode hapi resume [id] Resume an existing HAPI session locally hapi mcp Start MCP stdio bridge diff --git a/cli/src/commands/gemini.ts b/cli/src/commands/gemini.ts deleted file mode 100644 index fb7e9bfa..00000000 --- a/cli/src/commands/gemini.ts +++ /dev/null @@ -1,30 +0,0 @@ -import chalk from 'chalk' -import { authAndSetupMachineIfNeeded } from '@/ui/auth' -import { initializeToken } from '@/ui/tokenInit' -import { maybeAutoStartServer } from '@/utils/autoStartServer' -import type { CommandDefinition } from './types' -import { GEMINI_PERMISSION_MODES } from '@hapi/protocol/modes' -import { parseRemoteAgentCommandOptions } from './agentCommandOptions' - -export const geminiCommand: CommandDefinition = { - name: 'gemini', - requiresRuntimeAssets: true, - run: async ({ commandArgs }) => { - try { - const options = parseRemoteAgentCommandOptions(commandArgs, GEMINI_PERMISSION_MODES) - - await initializeToken() - await maybeAutoStartServer() - await authAndSetupMachineIfNeeded() - - const { runGemini } = await import('@/gemini/runGemini') - await runGemini(options) - } catch (error) { - console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') - if (process.env.DEBUG) { - console.error(error) - } - process.exit(1) - } - } -} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 7e93ca4f..358281df 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -1,3 +1,4 @@ +import chalk from 'chalk' import { authCommand } from './auth' import { claudeCommand } from './claude' import { codexCommand } from './codex' @@ -6,7 +7,6 @@ import { connectCommand } from './connect' import { runnerCommand } from './runner' import { resumeCommand } from './resume' import { doctorCommand } from './doctor' -import { geminiCommand } from './gemini' import { kimiCommand } from './kimi' import { opencodeCommand } from './opencode' import { piCommand } from './pi' @@ -16,12 +16,28 @@ import { notifyCommand } from './notify' import { hubCommand } from './hub' import type { CommandContext, CommandDefinition } from './types' +// Gemini CLI was sunset (Google stopped serving the consumer Gemini CLI on +// 2026-06-18) so the agent is no longer launchable. Keep an explicit tombstone +// command so `hapi gemini` reports a clear error instead of falling through to +// the default Claude command with "gemini" as a forwarded argument. +const removedGeminiCommand: CommandDefinition = { + name: 'gemini', + requiresRuntimeAssets: false, + run: async () => { + console.error( + chalk.red('Error:'), + 'Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18). Existing Gemini sessions remain viewable in the web UI.' + ) + process.exit(1) + } +} + const COMMANDS: CommandDefinition[] = [ authCommand, connectCommand, codexCommand, cursorCommand, - geminiCommand, + removedGeminiCommand, kimiCommand, opencodeCommand, piCommand, diff --git a/cli/src/commands/resume.test.ts b/cli/src/commands/resume.test.ts index 73b0de0a..4c3c709f 100644 --- a/cli/src/commands/resume.test.ts +++ b/cli/src/commands/resume.test.ts @@ -142,6 +142,37 @@ describe('resumeCommand', () => { }) }) + it('rejects an active Gemini target before handoff (no longer supported, leaves running session alone)', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 'undefined'}`) + }) as never) + + getLocalResumeTargetMock.mockResolvedValue({ + sessionId: 'hapi-session-gemini', + flavor: 'gemini', + directory: '/tmp/project', + machineId: 'machine-1', + active: true, + thinking: false, + controlledByUser: false, + agentSessionId: 'gemini-conv-1', + model: 'gemini-2.5-pro', + permissionMode: 'default' + }) + + try { + await expect(resumeCommand.run(createContext(['hapi-session-gemini']))).rejects.toThrow('process.exit:1') + // Regression (#953): the gemini guard must fire BEFORE handoff so an + // active Gemini session is not stopped and then left failing locally. + expect(handoffSessionToLocalMock).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('no longer supported')) + } finally { + consoleErrorSpy.mockRestore() + exitSpy.mockRestore() + } + }) + it('fails before launching when the target belongs to another machine', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { diff --git a/cli/src/commands/resume.ts b/cli/src/commands/resume.ts index cf2af593..146189d6 100644 --- a/cli/src/commands/resume.ts +++ b/cli/src/commands/resume.ts @@ -7,7 +7,6 @@ import type { ClaudePermissionMode, CodexPermissionMode, CursorPermissionMode, - GeminiPermissionMode, KimiPermissionMode, OpencodePermissionMode } from '@hapi/protocol/types' @@ -104,17 +103,7 @@ async function dispatchLocalResume(target: LocalResumeTarget): Promise { } if (target.flavor === 'gemini') { - const { runGemini } = await import('@/gemini/runGemini') - await runGemini({ - existingSessionId: base.existingSessionId, - workingDirectory: base.workingDirectory, - resumeSessionId: base.resumeSessionId, - startedBy: base.startedBy, - permissionMode: base.permissionMode as GeminiPermissionMode | undefined, - startingMode: 'local', - model: target.model ?? undefined - }) - return + throw new Error('Gemini CLI is no longer supported and cannot be resumed (Google sunset the consumer Gemini CLI on 2026-06-18). The session history remains viewable in the web UI.') } if (target.flavor === 'opencode') { @@ -209,6 +198,14 @@ export const resumeCommand: CommandDefinition = { assertTargetMachine(target, machineId) assertDirectoryExists(target) + // Gemini CLI is no longer launchable (Google sunset the consumer + // Gemini CLI on 2026-06-18). Reject BEFORE the handoff below so an + // active Gemini session is left running/readable rather than being + // stopped by handoffSessionToLocal and then failing locally. + if (target.flavor === 'gemini') { + throw new Error('Gemini CLI is no longer supported and cannot be resumed (Google sunset the consumer Gemini CLI on 2026-06-18). The session history remains viewable in the web UI.') + } + if (target.active && target.controlledByUser) { throw new Error('Session is already controlled by a local terminal') } diff --git a/cli/src/gemini/geminiLocal.ts b/cli/src/gemini/geminiLocal.ts deleted file mode 100644 index 551145df..00000000 --- a/cli/src/gemini/geminiLocal.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { logger } from '@/ui/logger'; -import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; - -export async function geminiLocal(opts: { - path: string; - sessionId: string | null; - abort: AbortSignal; - model?: string; - approvalMode?: string; - allowedTools?: string[]; - hookSettingsPath?: string; -}): Promise { - const args: string[] = []; - - if (opts.sessionId) { - args.push('--resume', opts.sessionId); - } - if (opts.model) { - args.push('--model', opts.model); - } - if (opts.approvalMode) { - args.push('--approval-mode', opts.approvalMode); - } - if (opts.allowedTools && opts.allowedTools.length > 0) { - args.push('--allowed-tools', ...opts.allowedTools); - } - - const env: NodeJS.ProcessEnv = { - ...process.env, - GEMINI_PROJECT_DIR: opts.path - }; - if (opts.hookSettingsPath) { - env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = opts.hookSettingsPath; - } - - logger.debug(`[GeminiLocal] Spawning gemini with args: ${JSON.stringify(args)}`); - - await spawnWithTerminalGuard({ - command: 'gemini', - args, - cwd: opts.path, - env, - signal: opts.abort, - shell: process.platform === 'win32', - logLabel: 'GeminiLocal', - spawnName: 'gemini', - installHint: 'Gemini CLI', - includeCause: true, - logExit: true - }); -} diff --git a/cli/src/gemini/geminiLocalLauncher.ts b/cli/src/gemini/geminiLocalLauncher.ts deleted file mode 100644 index adfb8f25..00000000 --- a/cli/src/gemini/geminiLocalLauncher.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { geminiLocal } from './geminiLocal'; -import { GeminiSession } from './session'; -import { createGeminiSessionScanner } from './utils/sessionScanner'; -import type { PermissionMode } from './types'; -import { randomUUID } from 'node:crypto'; -import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; - -type GeminiScannerHandle = Awaited>; - -function mapApprovalMode(mode: PermissionMode | undefined): string | undefined { - if (!mode || mode === 'default' || mode === 'read-only') { - return 'default'; - } - if (mode === 'safe-yolo') { - return 'auto_edit'; - } - return 'yolo'; -} - -export async function geminiLocalLauncher( - session: GeminiSession, - opts: { - model?: string; - allowedTools?: string[]; - hookSettingsPath?: string; - } -): Promise<'switch' | 'exit'> { - const launcher = new BaseLocalLauncher({ - label: 'gemini-local', - failureLabel: 'Local Gemini process failed', - queue: session.queue, - rpcHandlerManager: session.client.rpcHandlerManager, - startedBy: session.startedBy, - startingMode: session.startingMode, - launch: async (abortSignal) => { - await geminiLocal({ - path: session.path, - sessionId: session.sessionId, - abort: abortSignal, - model: opts.model, - approvalMode: mapApprovalMode(session.getPermissionMode() as PermissionMode | undefined), - allowedTools: opts.allowedTools, - hookSettingsPath: opts.hookSettingsPath - }); - }, - sendFailureMessage: (message) => { - session.sendSessionEvent({ type: 'message', message }); - }, - recordLocalLaunchFailure: (message, exitReason) => { - session.recordLocalLaunchFailure(message, exitReason); - } - }); - - let scanner: GeminiScannerHandle | null = null; - - const handleTranscriptMessage = (message: { type?: string; content?: string }) => { - if (message.type === 'user' && typeof message.content === 'string') { - session.sendUserMessage(message.content); - return; - } - if (message.type === 'gemini' && typeof message.content === 'string') { - session.sendAgentMessage({ - type: 'message', - message: message.content, - id: randomUUID() - }); - } - }; - - const ensureScanner = async (transcriptPath: string): Promise => { - if (scanner) { - scanner.onNewSession(transcriptPath); - return; - } - scanner = await createGeminiSessionScanner({ - transcriptPath, - onMessage: handleTranscriptMessage, - onSessionId: (sessionId) => session.onSessionFound(sessionId) - }); - }; - - const handleTranscriptPath = (transcriptPath: string) => { - void ensureScanner(transcriptPath); - }; - - const hadTranscriptPath = Boolean(session.transcriptPath); - if (hadTranscriptPath && session.transcriptPath) { - await ensureScanner(session.transcriptPath); - } else { - session.addTranscriptPathCallback(handleTranscriptPath); - } - - try { - return await launcher.run(); - } finally { - if (!hadTranscriptPath) { - session.removeTranscriptPathCallback(handleTranscriptPath); - } - - if (scanner !== null) { - await (scanner as GeminiScannerHandle).cleanup(); - } - } -} diff --git a/cli/src/gemini/geminiRemoteLauncher.test.ts b/cli/src/gemini/geminiRemoteLauncher.test.ts deleted file mode 100644 index 4b1aab67..00000000 --- a/cli/src/gemini/geminiRemoteLauncher.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import type { GeminiMode, PermissionMode } from './types'; - -const harness = vi.hoisted(() => ({ - setModelArgs: [] as Array<{ sessionId: string; modelId: string }>, - promptCount: 0, - events: [] as string[], - setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise) -})); - -vi.mock('./utils/geminiBackend', () => ({ - createGeminiBackend: vi.fn(() => ({ - initialize: vi.fn(async () => {}), - newSession: vi.fn(async () => 'acp-session-1'), - loadSession: vi.fn(async () => 'acp-session-1'), - setModel: vi.fn(async (sessionId: string, modelId: string) => { - harness.events.push(`setModel:${modelId}`); - harness.setModelArgs.push({ sessionId, modelId }); - if (harness.setModelImpl) { - await harness.setModelImpl(sessionId, modelId); - } - }), - prompt: vi.fn(async () => { - harness.events.push('prompt:start'); - harness.promptCount++; - await new Promise((resolve) => setImmediate(resolve)); - harness.events.push('prompt:end'); - }), - cancelPrompt: vi.fn(async () => {}), - respondToPermission: vi.fn(async () => {}), - onStderrError: vi.fn(), - onPermissionRequest: vi.fn(), - disconnect: vi.fn(async () => {}) - })) -})); - -vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({ - buildHapiMcpBridge: async () => ({ - server: { stop: () => {} }, - mcpServers: {} - }) -})); - -vi.mock('./utils/permissionHandler', () => ({ - GeminiPermissionHandler: class { - async cancelAll(): Promise {} - } -})); - -vi.mock('./utils/config', () => ({ - resolveGeminiRuntimeConfig: () => ({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }) -})); - -vi.mock('@/ui/ink/GeminiDisplay', () => ({ - GeminiDisplay: () => null -})); - -vi.mock('@/ui/logger', () => ({ - logger: { - debug: vi.fn(), - warn: vi.fn(), - info: vi.fn() - } -})); - -import { geminiRemoteLauncher } from './geminiRemoteLauncher'; - -function createMode(model?: string): GeminiMode { - return { - permissionMode: 'default' as PermissionMode, - model - }; -} - -function createSessionStub(items: Array<{ message: string; mode: GeminiMode }>) { - const queue = new MessageQueue2((mode) => JSON.stringify(mode)); - items.forEach(({ message, mode }, index) => { - if (index === 0 && items.length > 1) { - queue.pushIsolateAndClear(message, mode); - } else { - queue.push(message, mode); - } - }); - queue.close(); - - const sessionEvents: Array<{ type: string; [key: string]: unknown }> = []; - const rpcHandlers = new Map unknown>(); - - const client = { - rpcHandlerManager: { - registerHandler(method: string, handler: (params: unknown) => unknown) { - rpcHandlers.set(method, handler); - } - }, - sendAgentMessage(_message: unknown) {}, - sendUserMessage(_text: string) {}, - sendSessionEvent(event: { type: string; [key: string]: unknown }) { - sessionEvents.push(event); - } - }; - - const session = { - path: '/tmp/hapi-gemini-test', - logPath: '/tmp/hapi-gemini-test/test.log', - client, - queue, - sessionId: null as string | null, - thinking: false, - getPermissionMode() { - return 'default' as const; - }, - setModel(_model: string | null) {}, - onThinkingChange(thinking: boolean) { - session.thinking = thinking; - }, - onSessionFound(id: string) { - session.sessionId = id; - }, - sendAgentMessage(_message: unknown) {}, - sendSessionEvent(event: { type: string; [key: string]: unknown }) { - client.sendSessionEvent(event); - }, - sendUserMessage(_text: string) {} - }; - - return { session, sessionEvents, rpcHandlers }; -} - -describe('geminiRemoteLauncher inline model switch', () => { - afterEach(() => { - harness.setModelArgs = []; - harness.promptCount = 0; - harness.events = []; - harness.setModelImpl = null; - }); - - it('calls setModel between turns when the queued model differs from the running backend model', async () => { - const { session } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-2.5-pro') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - expect(harness.setModelArgs).toEqual([ - { sessionId: 'acp-session-1', modelId: 'gemini-2.5-pro' } - ]); - expect(harness.promptCount).toBe(2); - }); - - it('does not call setModel when the model is unchanged across turns', async () => { - const { session } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-3-flash-preview') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - expect(harness.setModelArgs).toEqual([]); - expect(harness.promptCount).toBe(2); - }); - - it('latches inline switching off after a method-not-found response and notifies the user once', async () => { - harness.setModelImpl = async () => { - throw new Error('Method not found: session/set_model'); - }; - const { session, sessionEvents } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-2.5-pro') }, - { message: 'third', mode: createMode('gemini-2.5-flash') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - // Only one setModel attempt — latched off after the first method-not-found - expect(harness.setModelArgs).toEqual([ - { sessionId: 'acp-session-1', modelId: 'gemini-2.5-pro' } - ]); - const unsupportedMessages = sessionEvents.filter( - (event) => - event.type === 'message' && - typeof event.message === 'string' && - event.message.includes('does not support inline model switching') - ); - expect(unsupportedMessages.length).toBe(1); - expect(harness.promptCount).toBe(3); - }); - - it('reports a transient setModel error and continues with the previous model', async () => { - let attempts = 0; - harness.setModelImpl = async () => { - attempts++; - throw new Error('Transient backend failure'); - }; - const { session, sessionEvents } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-2.5-pro') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - expect(attempts).toBe(1); - const failureMessages = sessionEvents.filter( - (event) => - event.type === 'message' && - typeof event.message === 'string' && - event.message.includes('Failed to switch model') - ); - expect(failureMessages.length).toBe(1); - expect(failureMessages[0]?.message).toContain('gemini-2.5-pro'); - expect(harness.promptCount).toBe(2); - }); - - it('serializes setModel after the previous prompt resolves', async () => { - const { session } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-2.5-pro') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - // Order must be: prompt(1) start/end → setModel → prompt(2) start/end - expect(harness.events).toEqual([ - 'prompt:start', - 'prompt:end', - 'setModel:gemini-2.5-pro', - 'prompt:start', - 'prompt:end' - ]); - }); -}); diff --git a/cli/src/gemini/geminiRemoteLauncher.ts b/cli/src/gemini/geminiRemoteLauncher.ts deleted file mode 100644 index fa6e3cb5..00000000 --- a/cli/src/gemini/geminiRemoteLauncher.ts +++ /dev/null @@ -1,306 +0,0 @@ -import React from 'react'; -import { logger } from '@/ui/logger'; -import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; -import { convertAgentMessage } from '@/agent/messageConverter'; -import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types'; -import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase'; -import { GeminiDisplay } from '@/ui/ink/GeminiDisplay'; -import type { GeminiSession } from './session'; -import type { PermissionMode } from './types'; -import { createGeminiBackend } from './utils/geminiBackend'; -import { GeminiPermissionHandler } from './utils/permissionHandler'; -import { resolveGeminiRuntimeConfig } from './utils/config'; - -class GeminiRemoteLauncher extends RemoteLauncherBase { - private readonly session: GeminiSession; - private readonly model?: string; - private readonly hookSettingsPath?: string; - private backend: ReturnType | null = null; - private permissionHandler: GeminiPermissionHandler | null = null; - private happyServer: { stop: () => void } | null = null; - private abortController = new AbortController(); - private displayModel: string | null = null; - private displayPermissionMode: PermissionMode | null = null; - private currentBackendModel: string | null = null; - private setModelSupported: boolean | undefined = undefined; - - constructor(session: GeminiSession, opts: { model?: string; hookSettingsPath?: string }) { - super(process.env.DEBUG ? session.logPath : undefined); - this.session = session; - this.model = opts.model; - this.hookSettingsPath = opts.hookSettingsPath; - } - - public async launch(): Promise { - return this.start({ - onExit: () => this.handleExitFromUi(), - onSwitchToLocal: () => this.handleSwitchFromUi() - }); - } - - protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { - return React.createElement(GeminiDisplay, context); - } - - protected async runMainLoop(): Promise { - const session = this.session; - const messageBuffer = this.messageBuffer; - - const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); - this.happyServer = happyServer; - - const runtimeConfig = resolveGeminiRuntimeConfig({ model: this.model }); - this.displayModel = runtimeConfig.model; - messageBuffer.addMessage(`[MODEL:${runtimeConfig.model}]`, 'system'); - - const backend = createGeminiBackend({ - model: runtimeConfig.model, - token: runtimeConfig.token, - hookSettingsPath: this.hookSettingsPath, - cwd: session.path, - permissionMode: session.getPermissionMode() as string | undefined - }); - this.backend = backend; - - backend.onStderrError((error) => { - logger.debug('[gemini-remote] stderr error', error); - session.sendSessionEvent({ type: 'message', message: error.message }); - messageBuffer.addMessage(error.message, 'status'); - }); - - await backend.initialize(); - - const resumeSessionId = session.sessionId; - const acpMcpServers = toAcpMcpServers(mcpServers); - let acpSessionId: string; - if (resumeSessionId) { - try { - acpSessionId = await backend.loadSession({ - sessionId: resumeSessionId, - cwd: session.path, - mcpServers: acpMcpServers - }); - } catch (error) { - logger.warn('[gemini-remote] resume failed, starting new session', error); - session.sendSessionEvent({ - type: 'message', - message: 'Gemini resume failed; starting a new session.' - }); - acpSessionId = await backend.newSession({ - cwd: session.path, - mcpServers: acpMcpServers - }); - } - } else { - acpSessionId = await backend.newSession({ - cwd: session.path, - mcpServers: acpMcpServers - }); - } - session.onSessionFound(acpSessionId); - - this.permissionHandler = new GeminiPermissionHandler( - session.client, - backend, - () => session.getPermissionMode() as PermissionMode | undefined - ); - this.currentBackendModel = runtimeConfig.model; - this.applyDisplayMode(session.getPermissionMode() as PermissionMode, this.currentBackendModel); - - this.setupAbortHandlers(session.client.rpcHandlerManager, { - onAbort: () => this.handleAbort(), - onSwitch: () => this.handleSwitchRequest() - }); - - const sendReady = () => { - session.sendSessionEvent({ type: 'ready' }); - }; - - while (!this.shouldExit) { - const batch = await session.queue.waitForMessagesAndGetAsString(this.abortController.signal); - if (!batch) { - if (this.abortController.signal.aborted && !this.shouldExit) { - continue; - } - break; - } - - // Inline model change via RPC. If the running gemini-cli build does not - // implement session/set_model, we learn that from the first method-not-found - // response and stop attempting it for the rest of this session. - if (batch.mode.model && batch.mode.model !== this.currentBackendModel) { - if (!backend.setModel || this.setModelSupported === false) { - batch.mode.model = this.currentBackendModel!; - } else { - logger.debug(`[gemini-remote] Switching model inline: ${this.currentBackendModel} -> ${batch.mode.model}`); - try { - await backend.setModel(acpSessionId, batch.mode.model); - this.currentBackendModel = batch.mode.model; - this.setModelSupported = true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const methodNotFound = /method not found/i.test(message); - if (methodNotFound && this.setModelSupported === undefined) { - this.setModelSupported = false; - logger.warn('[gemini-remote] Gemini CLI build does not support session/set_model; inline switching disabled for this session'); - session.sendSessionEvent({ - type: 'message', - message: 'This Gemini CLI build does not support inline model switching. Restart the session to apply a different model.' - }); - } else { - logger.warn('[gemini-remote] Inline model switch failed', error); - session.sendSessionEvent({ - type: 'message', - message: `Failed to switch model to ${batch.mode.model}. Continuing with ${this.currentBackendModel}.` - }); - } - batch.mode.model = this.currentBackendModel!; - } - } - } - - this.applyDisplayMode(batch.mode.permissionMode, batch.mode.model); - messageBuffer.addMessage(batch.message, 'user'); - - const promptContent: PromptContent[] = [{ - type: 'text', - text: batch.message - }]; - - session.onThinkingChange(true); - - try { - await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { - this.handleAgentMessage(message); - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.warn('[gemini-remote] prompt failed', { message: errorMessage }); - session.sendSessionEvent({ - type: 'message', - message: `Gemini prompt failed: ${errorMessage}` - }); - messageBuffer.addMessage(`Gemini prompt failed: ${errorMessage}`, 'status'); - } finally { - session.onThinkingChange(false); - await this.permissionHandler?.cancelAll('Prompt finished'); - if (session.queue.size() === 0 && !this.shouldExit) { - sendReady(); - } - } - } - } - - protected async cleanup(): Promise { - this.clearAbortHandlers(this.session.client.rpcHandlerManager); - - if (this.permissionHandler) { - await this.permissionHandler.cancelAll('Session ended'); - this.permissionHandler = null; - } - - if (this.backend) { - await this.backend.disconnect(); - this.backend = null; - } - - if (this.happyServer) { - this.happyServer.stop(); - this.happyServer = null; - } - } - - private handleAgentMessage(message: AgentMessage): void { - const converted = convertAgentMessage(message); - if (converted) { - this.session.sendAgentMessage(converted); - } - - switch (message.type) { - case 'text': - this.messageBuffer.addMessage(message.text, 'assistant'); - break; - case 'reasoning': - if (message.live) { - break; - } - this.messageBuffer.addMessage(`[Thinking] ${message.text.substring(0, 100)}...`, 'system'); - break; - case 'tool_call': - this.messageBuffer.addMessage(`Tool call: ${message.name}`, 'tool'); - break; - case 'tool_result': - this.messageBuffer.addMessage('Tool result received', 'result'); - break; - case 'usage': - break; - case 'plan': - this.messageBuffer.addMessage('Plan updated', 'status'); - break; - case 'error': - this.messageBuffer.addMessage(message.message, 'status'); - break; - case 'turn_complete': - this.messageBuffer.addMessage('Turn complete', 'status'); - break; - default: { - const _exhaustive: never = message; - return _exhaustive; - } - } - } - - private applyDisplayMode(permissionMode: PermissionMode | undefined, model?: string): void { - if (permissionMode && permissionMode !== this.displayPermissionMode) { - this.displayPermissionMode = permissionMode; - this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system'); - } - if (model && model !== this.displayModel) { - this.displayModel = model; - this.messageBuffer.addMessage(`[MODEL:${model}]`, 'system'); - } - } - - private async handleAbort(): Promise { - const backend = this.backend; - if (backend && this.session.sessionId) { - await backend.cancelPrompt(this.session.sessionId); - } - await this.permissionHandler?.cancelAll('User aborted'); - this.session.sendSessionEvent({ type: 'message', message: 'Session aborted' }); - this.session.queue.reset(); - this.session.onThinkingChange(false); - this.abortController.abort(); - this.abortController = new AbortController(); - this.messageBuffer.addMessage('Turn aborted', 'status'); - } - - private async handleExitFromUi(): Promise { - await this.requestExit('exit', () => this.handleAbort()); - } - - private async handleSwitchFromUi(): Promise { - await this.requestExit('switch', () => this.handleAbort()); - } - - private async handleSwitchRequest(): Promise { - await this.requestExit('switch', () => this.handleAbort()); - } -} - -function toAcpMcpServers(config: Record): McpServerStdio[] { - return Object.entries(config).map(([name, entry]) => ({ - name, - command: entry.command, - args: entry.args, - env: [] - })); -} - -export async function geminiRemoteLauncher( - session: GeminiSession, - opts: { model?: string; hookSettingsPath?: string } -): Promise<'switch' | 'exit'> { - const launcher = new GeminiRemoteLauncher(session, opts); - return launcher.launch(); -} diff --git a/cli/src/gemini/loop.ts b/cli/src/gemini/loop.ts deleted file mode 100644 index 453e5526..00000000 --- a/cli/src/gemini/loop.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import { logger } from '@/ui/logger'; -import { runLocalRemoteSession } from '@/agent/loopBase'; -import { GeminiSession } from './session'; -import { geminiLocalLauncher } from './geminiLocalLauncher'; -import { geminiRemoteLauncher } from './geminiRemoteLauncher'; -import { ApiClient, ApiSessionClient } from '@/lib'; -import type { GeminiMode, PermissionMode } from './types'; - -interface GeminiLoopOptions { - path: string; - startingMode?: 'local' | 'remote'; - startedBy?: 'runner' | 'terminal'; - onModeChange: (mode: 'local' | 'remote') => void; - messageQueue: MessageQueue2; - session: ApiSessionClient; - api: ApiClient; - permissionMode?: PermissionMode; - model?: string; - hookSettingsPath?: string; - allowedTools?: string[]; - resumeSessionId?: string; - onSessionReady?: (session: GeminiSession) => void; -} - -export async function geminiLoop(opts: GeminiLoopOptions): Promise { - const logPath = logger.getLogPath(); - const startedBy = opts.startedBy ?? 'terminal'; - const startingMode = opts.startingMode ?? 'local'; - - const session = new GeminiSession({ - api: opts.api, - client: opts.session, - path: opts.path, - sessionId: opts.resumeSessionId ?? null, - logPath, - messageQueue: opts.messageQueue, - onModeChange: opts.onModeChange, - mode: startingMode, - startedBy, - startingMode, - permissionMode: opts.permissionMode ?? 'default' - }); - - if (opts.resumeSessionId) { - 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: getCurrentModel(), - allowedTools: opts.allowedTools, - hookSettingsPath: opts.hookSettingsPath - }), - runRemote: (instance) => geminiRemoteLauncher(instance, { - model: getCurrentModel(), - hookSettingsPath: opts.hookSettingsPath - }), - onSessionReady: opts.onSessionReady - }); -} diff --git a/cli/src/gemini/runGemini.test.ts b/cli/src/gemini/runGemini.test.ts deleted file mode 100644 index 9b9b3be4..00000000 --- a/cli/src/gemini/runGemini.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockGeminiSession = vi.hoisted(() => ({ - setModel: vi.fn(), - setPermissionMode: vi.fn(), - pushKeepAlive: vi.fn(), - thinking: false, - stopKeepAlive: vi.fn() -})); - -const harness = vi.hoisted(() => ({ - bootstrapArgs: [] as Array>, - geminiLoopArgs: [] as Array>, - geminiLoopError: null as Error | null, - session: { - onUserMessage: vi.fn(), - onCancelQueuedMessage: vi.fn(), - rpcHandlerManager: { - registerHandler: vi.fn() - } - } -})); - -vi.mock('@/agent/sessionFactory', () => ({ - bootstrapSession: vi.fn(async (options: Record) => { - harness.bootstrapArgs.push(options); - return { - api: {}, - session: harness.session - }; - }) -})); - -vi.mock('./loop', () => ({ - geminiLoop: vi.fn(async (options: Record) => { - harness.geminiLoopArgs.push(options); - if (harness.geminiLoopError) { - throw harness.geminiLoopError; - } - const onSessionReady = options.onSessionReady as ((session: unknown) => void) | undefined; - if (onSessionReady) { - onSessionReady(mockGeminiSession); - } - }) -})); - -vi.mock('@/claude/registerKillSessionHandler', () => ({ - registerKillSessionHandler: vi.fn() -})); - -const lifecycleMock = vi.hoisted(() => ({ - registerProcessHandlers: vi.fn(), - cleanupAndExit: vi.fn(async () => {}), - markCrash: vi.fn(), - setExitCode: vi.fn(), - setArchiveReason: vi.fn(), - setSessionEndReason: vi.fn(), - hasExplicitSessionEndReason: vi.fn(() => false) -})); - -vi.mock('@/agent/runnerLifecycle', () => ({ - createModeChangeHandler: vi.fn(() => vi.fn()), - createRunnerLifecycle: vi.fn(() => lifecycleMock), - setControlledByUser: vi.fn() -})); - -vi.mock('@/claude/utils/startHookServer', () => ({ - startHookServer: vi.fn(async () => ({ - port: 1234, - token: 'token', - stop: vi.fn() - })) -})); - -vi.mock('@/modules/common/hooks/generateHookSettings', () => ({ - cleanupHookSettingsFile: vi.fn(), - generateHookSettingsFile: vi.fn(() => '/tmp/gemini-hooks.json') -})); - -const resolveGeminiRuntimeConfigMock = vi.hoisted(() => vi.fn()); - -vi.mock('./utils/config', () => ({ - resolveGeminiRuntimeConfig: resolveGeminiRuntimeConfigMock -})); - -vi.mock('@/ui/logger', () => ({ - logger: { - debug: vi.fn() - } -})); - -vi.mock('@/utils/attachmentFormatter', () => ({ - formatMessageWithAttachments: vi.fn((text: string) => text) -})); - -import { runGemini } from './runGemini'; - -describe('runGemini', () => { - beforeEach(() => { - harness.bootstrapArgs.length = 0; - harness.geminiLoopArgs.length = 0; - harness.geminiLoopError = null; - mockGeminiSession.setModel.mockReset(); - mockGeminiSession.setPermissionMode.mockReset(); - harness.session.onUserMessage.mockReset(); - harness.session.rpcHandlerManager.registerHandler.mockReset(); - lifecycleMock.registerProcessHandlers.mockClear(); - lifecycleMock.cleanupAndExit.mockClear(); - lifecycleMock.markCrash.mockClear(); - lifecycleMock.setExitCode.mockClear(); - lifecycleMock.setArchiveReason.mockClear(); - lifecycleMock.setSessionEndReason.mockClear(); - resolveGeminiRuntimeConfigMock.mockReset(); - }); - - it('persists a resolved config model before bootstrapping the session', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-pro-preview', - modelSource: 'local' - }); - - await runGemini({}); - - expect(harness.bootstrapArgs[0]?.model).toBe('gemini-3-pro-preview'); - expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-3-pro-preview'); - }); - - it('does not persist the hardcoded default fallback model so it floats with machine config', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - 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('pushes a keepAlive immediately after a config change so the hub UI reflects it', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }); - - await runGemini({}); - - // Reset to ignore pushKeepAlive fired from initial onSessionReady setup - mockGeminiSession.pushKeepAlive.mockClear(); - - 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 handler({ model: 'gemini-2.5-flash' }); - - expect(mockGeminiSession.pushKeepAlive).toHaveBeenCalledTimes(1); - }); - - 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'); - }); - - it('passes resumeSessionId through to geminiLoop', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-2.5-pro', - modelSource: 'default' - }); - - await runGemini({ resumeSessionId: 'a6157ffa-f692-4b73-82d5-63d42177f4f9' }); - - expect(harness.geminiLoopArgs[0]?.resumeSessionId).toBe('a6157ffa-f692-4b73-82d5-63d42177f4f9'); - }); - - it('does not set resumeSessionId when not provided', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-2.5-pro', - modelSource: 'default' - }); - - await runGemini({}); - - expect(harness.geminiLoopArgs[0]?.resumeSessionId).toBeUndefined(); - }); - - it('preserves crash session end reason instead of overwriting it as completed', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-2.5-pro', - modelSource: 'default' - }); - harness.geminiLoopError = new Error('loop failed'); - - await runGemini({}); - - expect(lifecycleMock.markCrash).toHaveBeenCalledWith(harness.geminiLoopError); - expect(lifecycleMock.setSessionEndReason).not.toHaveBeenCalledWith('completed'); - expect(lifecycleMock.cleanupAndExit).toHaveBeenCalled(); - }); -}); diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts deleted file mode 100644 index c2e66722..00000000 --- a/cli/src/gemini/runGemini.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { logger } from '@/ui/logger'; -import { geminiLoop } from './loop'; -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import { hashObject } from '@/utils/deterministicJson'; -import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; -import type { AgentState } from '@/api/types'; -import type { GeminiSession } from './session'; -import type { GeminiMode, PermissionMode } from './types'; -import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory'; -import { registerLocalHandoffHandler } from '@/agent/localHandoff'; -import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; -import { startHookServer } from '@/claude/utils/startHookServer'; -import { cleanupHookSettingsFile, generateHookSettingsFile } from '@/modules/common/hooks/generateHookSettings'; -import { resolveGeminiRuntimeConfig } from './utils/config'; -import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; -import { getInvokedCwd } from '@/utils/invokedCwd'; - -export async function runGemini(opts: { - startedBy?: 'runner' | 'terminal'; - startingMode?: 'local' | 'remote'; - permissionMode?: PermissionMode; - model?: string; - resumeSessionId?: string; - existingSessionId?: string; - workingDirectory?: string; -} = {}): Promise { - const workingDirectory = opts.workingDirectory ?? getInvokedCwd(); - const startedBy = opts.startedBy ?? 'terminal'; - - logger.debug(`[gemini] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`); - - if (startedBy === 'runner' && opts.startingMode === 'local') { - logger.debug('[gemini] Runner spawn requested with local mode; forcing remote mode'); - opts.startingMode = 'remote'; - } - - const initialState: AgentState = { - controlledByUser: false - }; - - const machineDefault = resolveGeminiRuntimeConfig().model; - const runtimeConfig = resolveGeminiRuntimeConfig({ model: opts.model }); - // Persist only when the user (or env/local config) chose the model. The hardcoded - // default remains undefined in the DB so it floats with the machine config across - // gemini-cli upgrades. Mid-session selections are persisted by the hub via the - // set-session-config RPC, not by this initial bootstrap. - const persistedModel = runtimeConfig.modelSource === 'default' - ? undefined - : runtimeConfig.model; - - const bootstrap = opts.existingSessionId - ? await bootstrapExistingSession({ - sessionId: opts.existingSessionId, - flavor: 'gemini', - startedBy, - workingDirectory - }) - : await bootstrapSession({ - flavor: 'gemini', - startedBy, - workingDirectory, - agentState: initialState, - model: persistedModel - }); - const { api, session } = bootstrap; - - const startingMode: 'local' | 'remote' = opts.startingMode - ?? (startedBy === 'runner' ? 'remote' : 'local'); - - setControlledByUser(session, startingMode); - - const messageQueue = new MessageQueue2((mode) => hashObject({ - permissionMode: mode.permissionMode, - model: mode.model - })); - - const sessionWrapperRef: { current: GeminiSession | null } = { current: null }; - let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; - let sessionModel: string | null = persistedModel ?? null; - let resolvedModel = sessionModel ?? machineDefault; - - const hookServer = await startHookServer({ - onSessionHook: (sessionId, data) => { - logger.debug(`[gemini] Session hook received: ${sessionId}`); - const currentSession = sessionWrapperRef.current; - if (!currentSession) { - return; - } - if (currentSession.sessionId !== sessionId) { - currentSession.onSessionFound(sessionId); - } - if (typeof data.transcript_path === 'string') { - currentSession.onTranscriptPathFound(data.transcript_path); - } - } - }); - - const hookSettingsPath = generateHookSettingsFile(hookServer.port, hookServer.token, { - filenamePrefix: 'gemini-session-hook', - logLabel: 'gemini-hook-settings', - hooksEnabled: true - }); - - const lifecycle = createRunnerLifecycle({ - session, - logTag: 'gemini', - stopKeepAlive: () => sessionWrapperRef.current?.stopKeepAlive(), - onAfterClose: () => { - hookServer.stop(); - cleanupHookSettingsFile(hookSettingsPath, 'gemini-hook-settings'); - } - }); - - lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle); - registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); - - const syncSessionMode = () => { - const sessionInstance = sessionWrapperRef.current; - if (!sessionInstance) { - return; - } - sessionInstance.setPermissionMode(currentPermissionMode); - sessionInstance.setModel(sessionModel); - - // Notify hub immediately to reflect changes in UI - sessionInstance.pushKeepAlive(); - - logger.debug(`[gemini] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${resolvedModel}`); - }; - - session.onUserMessage((message, localId) => { - const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); - const mode: GeminiMode = { - permissionMode: currentPermissionMode, - model: resolvedModel - }; - messageQueue.push(formattedText, mode, localId); - }); - - session.onCancelQueuedMessage((localId) => { - const removed = messageQueue.cancelByLocalId(localId); - logger.debug(`[gemini] cancelByLocalId(${localId}): ${removed ? 'removed' : 'not found (best-effort)'}`); - return removed; - }); - - registerSessionConfigRpc({ - rpcHandlerManager: session.rpcHandlerManager, - flavor: 'gemini', - modelMode: 'nullable', - onApply: (config) => { - if (config.permissionMode !== undefined) { - currentPermissionMode = config.permissionMode; - } - if (config.model !== undefined) { - sessionModel = config.model; - resolvedModel = sessionModel ?? machineDefault; - } - }, - onAfterApply: syncSessionMode - }); - - let crashed = false; - - try { - await geminiLoop({ - path: workingDirectory, - startingMode, - startedBy, - messageQueue, - session, - api, - permissionMode: currentPermissionMode, - model: machineDefault, - hookSettingsPath, - resumeSessionId: opts.resumeSessionId, - onModeChange: createModeChangeHandler(session), - onSessionReady: (instance) => { - sessionWrapperRef.current = instance; - syncSessionMode(); - } - }); - } catch (error) { - crashed = true; - lifecycle.markCrash(error); - logger.debug('[gemini] Loop error:', error); - } finally { - const localFailure = sessionWrapperRef.current?.localLaunchFailure; - if (localFailure?.exitReason === 'exit') { - lifecycle.setExitCode(1); - lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`); - lifecycle.setSessionEndReason('error'); - } else if (!crashed) { - lifecycle.setSessionEndReason('completed'); - } - await lifecycle.cleanupAndExit(); - } -} diff --git a/cli/src/gemini/session.ts b/cli/src/gemini/session.ts deleted file mode 100644 index 800d5658..00000000 --- a/cli/src/gemini/session.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { ApiClient, ApiSessionClient } from '@/lib'; -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import { AgentSessionBase } from '@/agent/sessionBase'; -import type { GeminiMode, PermissionMode } from './types'; -import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; - -type LocalLaunchFailure = { - message: string; - exitReason: LocalLaunchExitReason; -}; - -export class GeminiSession extends AgentSessionBase { - transcriptPath: string | null = null; - readonly startedBy: 'runner' | 'terminal'; - readonly startingMode: 'local' | 'remote'; - localLaunchFailure: LocalLaunchFailure | null = null; - - private transcriptPathCallbacks: Array<(path: string) => void> = []; - - constructor(opts: { - api: ApiClient; - client: ApiSessionClient; - path: string; - logPath: string; - sessionId: string | null; - messageQueue: MessageQueue2; - onModeChange: (mode: 'local' | 'remote') => void; - mode?: 'local' | 'remote'; - startedBy: 'runner' | 'terminal'; - startingMode: 'local' | 'remote'; - permissionMode?: PermissionMode; - }) { - super({ - api: opts.api, - client: opts.client, - path: opts.path, - logPath: opts.logPath, - sessionId: opts.sessionId, - messageQueue: opts.messageQueue, - onModeChange: opts.onModeChange, - mode: opts.mode, - sessionLabel: 'GeminiSession', - sessionIdLabel: 'Gemini', - applySessionIdToMetadata: (metadata, sessionId) => ({ - ...metadata, - geminiSessionId: sessionId - }), - permissionMode: opts.permissionMode - }); - - this.startedBy = opts.startedBy; - this.startingMode = opts.startingMode; - this.permissionMode = opts.permissionMode; - } - - onTranscriptPathFound(path: string): void { - if (this.transcriptPath === path) { - return; - } - this.transcriptPath = path; - for (const callback of this.transcriptPathCallbacks) { - callback(path); - } - } - - addTranscriptPathCallback(cb: (path: string) => void): void { - this.transcriptPathCallbacks.push(cb); - } - - removeTranscriptPathCallback(cb: (path: string) => void): void { - const index = this.transcriptPathCallbacks.indexOf(cb); - if (index !== -1) { - this.transcriptPathCallbacks.splice(index, 1); - } - } - - setPermissionMode = (mode: PermissionMode): void => { - this.permissionMode = mode; - }; - - setModel = (model: string | null): void => { - this.model = model; - }; - - recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { - this.localLaunchFailure = { message, exitReason }; - }; - - sendAgentMessage = (message: unknown): void => { - this.client.sendAgentMessage(message); - }; - - sendUserMessage = (text: string): void => { - this.client.sendUserMessage(text); - }; - - sendSessionEvent = (event: Parameters[0]): void => { - this.client.sendSessionEvent(event); - }; -} diff --git a/cli/src/gemini/types.ts b/cli/src/gemini/types.ts deleted file mode 100644 index 9f361b58..00000000 --- a/cli/src/gemini/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { GeminiPermissionMode } from '@hapi/protocol/types'; - -export type PermissionMode = GeminiPermissionMode; - -export interface GeminiMode { - permissionMode: PermissionMode; - model?: string; -} diff --git a/cli/src/gemini/utils/config.ts b/cli/src/gemini/utils/config.ts deleted file mode 100644 index 4426f52c..00000000 --- a/cli/src/gemini/utils/config.ts +++ /dev/null @@ -1,143 +0,0 @@ -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 { DEFAULT_GEMINI_MODEL }; - -export type GeminiLocalConfig = { - token?: string; - model?: string; -}; - -export type GeminiModelSource = 'explicit' | 'env' | 'local' | 'default'; - -const GEMINI_DIR = join(homedir(), '.gemini'); -const SETTINGS_PATH = join(GEMINI_DIR, 'settings.json'); -const CONFIG_PATH = join(GEMINI_DIR, 'config.json'); -const OAUTH_PATH = join(GEMINI_DIR, 'oauth_creds.json'); - -function readJsonFile(path: string): Record | null { - if (!existsSync(path)) { - return null; - } - - try { - const raw = readFileSync(path, 'utf-8'); - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') { - return parsed as Record; - } - } catch (error) { - logger.debug(`[gemini-config] Failed to read ${path}: ${error}`); - } - - return null; -} - -function extractModel(settings: Record): string | undefined { - const modelEntry = settings.model; - if (modelEntry && typeof modelEntry === 'object') { - const name = (modelEntry as Record).name; - if (typeof name === 'string' && name.trim().length > 0) { - return name.trim(); - } - } - - const model = settings.model; - if (typeof model === 'string' && model.trim().length > 0) { - return model.trim(); - } - - return undefined; -} - -function extractToken(settings: Record): string | undefined { - const tokenKeys = ['access_token', 'token', 'apiKey', GEMINI_API_KEY_ENV, GOOGLE_API_KEY_ENV]; - for (const key of tokenKeys) { - const value = settings[key]; - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim(); - } - } - return undefined; -} - -export function readGeminiLocalConfig(): GeminiLocalConfig { - const settingsFile = readJsonFile(SETTINGS_PATH); - const configFile = readJsonFile(CONFIG_PATH); - const oauthFile = readJsonFile(OAUTH_PATH); - - const model = settingsFile ? extractModel(settingsFile) : undefined; - const token = oauthFile - ? extractToken(oauthFile) - : configFile - ? extractToken(configFile) - : undefined; - - return { - model, - token - }; -} - -export function resolveGeminiRuntimeConfig(opts: { - model?: string; - token?: string; -} = {}): { model: string; token?: string; modelSource: GeminiModelSource } { - const local = readGeminiLocalConfig(); - - let modelSource: GeminiModelSource = 'default'; - let model: string = DEFAULT_GEMINI_MODEL; - - if (opts.model) { - model = opts.model; - modelSource = 'explicit'; - } else if (process.env[GEMINI_MODEL_ENV]) { - model = process.env[GEMINI_MODEL_ENV]!; - modelSource = 'env'; - } else if (local.model) { - model = local.model; - modelSource = 'local'; - } - - const token = opts.token - ?? process.env[GEMINI_API_KEY_ENV] - ?? process.env[GOOGLE_API_KEY_ENV] - ?? local.token; - - return { model, token, modelSource }; -} - -export function buildGeminiEnv(opts: { - model?: string; - token?: string; - hookSettingsPath?: string; - cwd?: string; -}): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { - ...process.env - }; - - if (opts.model) { - env[GEMINI_MODEL_ENV] = opts.model; - } - - if (opts.token && !env[GEMINI_API_KEY_ENV] && !env[GOOGLE_API_KEY_ENV]) { - env[GEMINI_API_KEY_ENV] = opts.token; - } - - if (opts.hookSettingsPath) { - env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = opts.hookSettingsPath; - } - - if (opts.cwd) { - env.GEMINI_PROJECT_DIR = opts.cwd; - } - - return env; -} diff --git a/cli/src/gemini/utils/geminiBackend.ts b/cli/src/gemini/utils/geminiBackend.ts deleted file mode 100644 index 392f8fc9..00000000 --- a/cli/src/gemini/utils/geminiBackend.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { AcpSdkBackend } from '@/agent/backends/acp'; -import { buildGeminiEnv, resolveGeminiRuntimeConfig } from './config'; - -function filterEnv(env: NodeJS.ProcessEnv): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(env)) { - if (value !== undefined) { - result[key] = value; - } - } - return result; -} - -export function createGeminiBackend(opts: { - model?: string; - token?: string; - resumeSessionId?: string | null; - hookSettingsPath?: string; - cwd?: string; - permissionMode?: string; -}): AcpSdkBackend { - const { model, token } = resolveGeminiRuntimeConfig({ - model: opts.model, - token: opts.token - }); - - const args = ['--experimental-acp']; - if (opts.resumeSessionId) { - args.push('--resume', opts.resumeSessionId); - } - if (model) { - args.push('--model', model); - } - if (opts.permissionMode === 'yolo' || opts.permissionMode === 'safe-yolo') { - args.push('--yolo'); - } - - const env = buildGeminiEnv({ - model, - token, - hookSettingsPath: opts.hookSettingsPath, - cwd: opts.cwd - }); - - return new AcpSdkBackend({ - command: 'gemini', - args, - env: filterEnv(env) - }); -} diff --git a/cli/src/gemini/utils/permissionHandler.ts b/cli/src/gemini/utils/permissionHandler.ts deleted file mode 100644 index a73629f9..00000000 --- a/cli/src/gemini/utils/permissionHandler.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { ApiSessionClient } from '@/api/apiSession'; -import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types'; -import type { GeminiPermissionMode } from '@hapi/protocol/types'; -import { deriveToolName } from '@/agent/utils'; -import { logger } from '@/ui/logger'; -import { - BasePermissionHandler, - type AutoApprovalDecision, - type PendingPermissionRequest, - type PermissionCompletion -} from '@/modules/common/permission/BasePermissionHandler'; - -interface PermissionResponseMessage { - id: string; - approved: boolean; - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; - reason?: string; -} - -function deriveToolInput(request: PermissionRequest): unknown { - if (request.rawInput !== undefined) { - return request.rawInput; - } - return request.rawOutput; -} - -function pickOptionId(request: PermissionRequest, preferredKinds: string[]): string | null { - for (const kind of preferredKinds) { - const match = request.options.find((option) => option.kind === kind); - if (match) { - return match.optionId; - } - } - return request.options.length > 0 ? request.options[0].optionId : null; -} - -function mapDecisionToOutcome(request: PermissionRequest, decision: PermissionResponseMessage['decision']): PermissionResponse { - if (decision === 'abort') { - return { outcome: 'cancelled' }; - } - - if (decision === 'approved_for_session') { - const optionId = pickOptionId(request, ['allow_always', 'allow_once']); - return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; - } - - if (decision === 'approved') { - const optionId = pickOptionId(request, ['allow_once', 'allow_always']); - return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; - } - - const optionId = pickOptionId(request, ['reject_once', 'reject_always']); - return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; -} - -export class GeminiPermissionHandler extends BasePermissionHandler { - private readonly pendingBackendRequests = new Map(); - - constructor( - session: ApiSessionClient, - private readonly backend: AgentBackend, - private readonly getPermissionMode: () => GeminiPermissionMode | undefined - ) { - super(session); - this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request)); - } - - private handlePermissionRequest(request: PermissionRequest): void { - const toolName = deriveToolName({ - title: request.title, - kind: request.kind, - rawInput: request.rawInput - }); - const toolInput = deriveToolInput(request); - const mode = this.getPermissionMode() ?? 'default'; - - const autoDecision = this.resolveAutoApprovalDecision(mode, toolName, request.toolCallId); - if (autoDecision) { - void this.autoApprove(request, toolName, toolInput, autoDecision); - return; - } - - this.pendingBackendRequests.set(request.id, request); - this.addPendingRequest(request.id, toolName, toolInput, { - resolve: () => {}, - reject: () => {} - }); - - logger.debug(`[Gemini] Permission request queued for ${toolName} (${request.id})`); - } - - private async autoApprove( - request: PermissionRequest, - toolName: string, - toolInput: unknown, - decision: AutoApprovalDecision - ): Promise { - const outcome = mapDecisionToOutcome(request, decision); - await this.backend.respondToPermission(request.sessionId, request, outcome); - - this.client.updateAgentState((currentState) => ({ - ...currentState, - completedRequests: { - ...currentState.completedRequests, - [request.id]: { - tool: toolName, - arguments: toolInput, - createdAt: Date.now(), - completedAt: Date.now(), - status: 'approved', - decision - } - } - })); - - logger.debug(`[Gemini] Auto-approved ${toolName} (${request.id}) mode=${decision}`); - } - - protected async handlePermissionResponse( - response: PermissionResponseMessage, - pending: PendingPermissionRequest - ): Promise { - const pendingRequest = this.pendingBackendRequests.get(response.id); - if (pendingRequest) { - this.pendingBackendRequests.delete(response.id); - } else { - logger.debug('[Gemini] Permission response missing backend request', response.id); - } - - const decision = response.decision ?? (response.approved ? 'approved' : 'denied'); - - if (decision === 'abort' && pendingRequest) { - await this.backend.cancelPrompt(pendingRequest.sessionId); - } - - if (pendingRequest) { - const outcome = mapDecisionToOutcome(pendingRequest, decision); - await this.backend.respondToPermission(pendingRequest.sessionId, pendingRequest, outcome); - } - - pending.resolve(); - - logger.debug(`[Gemini] Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`); - - return { - status: response.approved ? 'approved' : 'denied', - decision, - reason: response.reason - }; - } - - protected handleMissingPendingResponse(response: PermissionResponseMessage): void { - logger.debug('[Gemini] Permission response received for unknown request', response.id); - } - - async cancelAll(reason: string): Promise { - const pending = Array.from(this.pendingBackendRequests.values()); - this.pendingBackendRequests.clear(); - - for (const request of pending) { - await this.backend.respondToPermission(request.sessionId, request, { outcome: 'cancelled' }); - } - - this.cancelPendingRequests({ - completedReason: reason, - rejectMessage: reason, - decision: 'abort' - }); - } -} diff --git a/cli/src/gemini/utils/sessionScanner.ts b/cli/src/gemini/utils/sessionScanner.ts deleted file mode 100644 index a33b014a..00000000 --- a/cli/src/gemini/utils/sessionScanner.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { logger } from '@/ui/logger'; -import { - BaseSessionScanner, - SessionFileScanEntry, - SessionFileScanResult, - SessionFileScanStats -} from '@/modules/common/session/BaseSessionScanner'; - -type GeminiTranscriptMessage = { - id?: string; - type?: string; - content?: string; - [key: string]: unknown; -}; - -type GeminiTranscript = { - sessionId?: string; - messages?: GeminiTranscriptMessage[]; - [key: string]: unknown; -}; - -export async function createGeminiSessionScanner(opts: { - transcriptPath: string | null; - onMessage: (message: GeminiTranscriptMessage) => void; - onSessionId?: (sessionId: string) => void; -}) { - const scanner = new GeminiSessionScanner({ - transcriptPath: opts.transcriptPath, - onMessage: opts.onMessage, - onSessionId: opts.onSessionId - }); - - await scanner.start(); - - return { - cleanup: async () => { - await scanner.cleanup(); - }, - onNewSession: (transcriptPath: string) => { - void scanner.setTranscriptPath(transcriptPath); - } - }; -} - -class GeminiSessionScanner extends BaseSessionScanner { - private transcriptPath: string | null; - private readonly onMessage: (message: GeminiTranscriptMessage) => void; - private readonly onSessionId?: (sessionId: string) => void; - private observedSessionId: string | null = null; - - constructor(opts: { - transcriptPath: string | null; - onMessage: (message: GeminiTranscriptMessage) => void; - onSessionId?: (sessionId: string) => void; - }) { - super({ intervalMs: 2000 }); - this.transcriptPath = opts.transcriptPath; - this.onMessage = opts.onMessage; - this.onSessionId = opts.onSessionId; - } - - async setTranscriptPath(path: string): Promise { - if (this.transcriptPath === path) { - return; - } - this.transcriptPath = path; - await this.primeTranscript(path); - this.invalidate(); - } - - protected async initialize(): Promise { - if (this.transcriptPath) { - await this.primeTranscript(this.transcriptPath); - } - } - - protected async findSessionFiles(): Promise { - if (!this.transcriptPath) { - return []; - } - return [this.transcriptPath]; - } - - protected shouldWatchFile(filePath: string): boolean { - return Boolean(this.transcriptPath && filePath === this.transcriptPath); - } - - protected async parseSessionFile(filePath: string, cursor: number): Promise> { - const transcript = await readTranscript(filePath); - if (!transcript) { - return { events: [], nextCursor: cursor }; - } - - this.updateSessionId(transcript.sessionId); - - const messages = transcript.messages ?? []; - let startIndex = cursor; - if (startIndex > messages.length) { - startIndex = 0; - } - - const events: SessionFileScanEntry[] = []; - for (let index = startIndex; index < messages.length; index += 1) { - events.push({ event: messages[index], lineIndex: index }); - } - - return { - events, - nextCursor: messages.length - }; - } - - protected generateEventKey(event: GeminiTranscriptMessage, context: { filePath: string; lineIndex?: number }): string { - if (event.id && event.id.length > 0) { - return `${context.filePath}:${event.id}`; - } - return `${context.filePath}:${context.lineIndex ?? -1}`; - } - - protected async handleFileScan(stats: SessionFileScanStats): Promise { - for (const message of stats.events) { - this.onMessage(message); - } - if (stats.newCount > 0) { - logger.debug(`[gemini-session-scanner] ${stats.newCount} new messages from ${stats.filePath}`); - } - this.pruneWatchers(this.transcriptPath ? [this.transcriptPath] : []); - } - - private updateSessionId(sessionId: string | undefined): void { - if (!sessionId || sessionId.length === 0) { - return; - } - if (this.observedSessionId === sessionId) { - return; - } - this.observedSessionId = sessionId; - this.onSessionId?.(sessionId); - } - - private async primeTranscript(filePath: string): Promise { - const transcript = await readTranscript(filePath); - if (!transcript) { - return; - } - this.updateSessionId(transcript.sessionId); - - const messages = transcript.messages ?? []; - const keys = messages.map((message, index) => this.generateEventKey(message, { filePath, lineIndex: index })); - this.seedProcessedKeys(keys); - this.setCursor(filePath, messages.length); - } -} - -async function readTranscript(filePath: string): Promise { - try { - const raw = await readFile(filePath, 'utf-8'); - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') { - return null; - } - const record = parsed as Record; - const messages = Array.isArray(record.messages) - ? record.messages.filter((value): value is GeminiTranscriptMessage => Boolean(value && typeof value === 'object')) - : []; - const sessionId = typeof record.sessionId === 'string' ? record.sessionId : undefined; - return { - sessionId, - messages - }; - } catch (error) { - logger.debug(`[gemini-session-scanner] Failed to read transcript ${filePath}: ${error}`); - return null; - } -} diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index fb6d7648..d37c699f 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -31,7 +31,7 @@ describe('buildCliArgs', () => { }) it('prefers --permission-mode over --yolo when both present', () => { - const args = buildCliArgs('gemini', { + const args = buildCliArgs('cursor', { directory: '/tmp', permissionMode: 'yolo', }, true) @@ -43,6 +43,10 @@ describe('buildCliArgs', () => { expect(yoloIdx).toBe(-1) }) + it('throws for the removed gemini agent (no longer launchable)', () => { + expect(() => buildCliArgs('gemini', { directory: '/tmp' })).toThrow(/no longer supported/) + }) + it('adds --yolo when no permissionMode and yolo is true', () => { const args = buildCliArgs('claude', { directory: '/tmp', diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 694ad719..a9b3403b 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -287,6 +287,9 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options; const agent = options.agent ?? 'claude'; + if (agent === 'gemini') { + throw new Error('Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18). Existing Gemini sessions remain viewable in the web UI.'); + } const yolo = options.yolo === true; const sessionType = options.sessionType ?? 'simple'; const worktreeName = options.worktreeName; @@ -1073,19 +1076,20 @@ export function buildCliArgs( options: SpawnSessionOptions, yolo?: boolean ): string[] { + if (agent === 'gemini') { + throw new Error('Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18).'); + } const agentCommand = agent === 'codex' ? 'codex' : agent === 'cursor' ? 'cursor' - : agent === 'gemini' - ? 'gemini' - : agent === 'kimi' - ? 'kimi' - : agent === 'opencode' - ? 'opencode' - : agent === 'pi' - ? 'pi' - : 'claude'; + : agent === 'kimi' + ? 'kimi' + : agent === 'opencode' + ? 'opencode' + : agent === 'pi' + ? 'pi' + : 'claude'; const args = [agentCommand]; if (options.resumeSessionId) { if (agent === 'codex') { diff --git a/cli/src/ui/ink/GeminiDisplay.tsx b/cli/src/ui/ink/GeminiDisplay.tsx deleted file mode 100644 index 34e04e1d..00000000 --- a/cli/src/ui/ink/GeminiDisplay.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Box, Text, useStdout } from 'ink'; -import { MessageBuffer, type BufferedMessage } from './messageBuffer'; -import { useSwitchControls } from './useSwitchControls'; - -interface GeminiDisplayProps { - messageBuffer: MessageBuffer; - logPath?: string; - onExit?: () => void; - onSwitchToLocal?: () => void; -} - -function extractTag(messages: BufferedMessage[], tag: 'MODEL' | 'MODE'): string | null { - const prefix = `[${tag}:`; - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (message.type !== 'system') { - continue; - } - if (!message.content.startsWith(prefix)) { - continue; - } - const match = message.content.match(/\[\w+:(.+?)\]/); - if (match && match[1]) { - return match[1]; - } - } - return null; -} - -export const GeminiDisplay: React.FC = ({ - messageBuffer, - logPath, - onExit, - onSwitchToLocal -}) => { - const [messages, setMessages] = useState([]); - const [model, setModel] = useState(null); - const [permissionMode, setPermissionMode] = useState(null); - const { confirmationMode, actionInProgress } = useSwitchControls({ - onExit, - onSwitch: onSwitchToLocal - }); - const { stdout } = useStdout(); - const terminalWidth = stdout.columns || 80; - const terminalHeight = stdout.rows || 24; - - useEffect(() => { - setMessages(messageBuffer.getMessages()); - - const unsubscribe = messageBuffer.onUpdate((newMessages) => { - setMessages(newMessages); - const nextModel = extractTag(newMessages, 'MODEL'); - if (nextModel) { - setModel(nextModel); - } - const nextMode = extractTag(newMessages, 'MODE'); - if (nextMode) { - setPermissionMode(nextMode); - } - }); - - return () => { - unsubscribe(); - }; - }, [messageBuffer]); - - const getMessageColor = (type: BufferedMessage['type']): string => { - switch (type) { - case 'user': return 'magenta'; - case 'assistant': return 'cyan'; - case 'system': return 'blue'; - case 'tool': return 'yellow'; - case 'result': return 'green'; - case 'status': return 'gray'; - default: return 'white'; - } - }; - - const formatMessage = (msg: BufferedMessage): string => { - const lines = msg.content.split('\n'); - const maxLineLength = Math.max(1, terminalWidth - 10); - return lines.map(line => { - if (line.length <= maxLineLength) return line; - const chunks: string[] = []; - for (let i = 0; i < line.length; i += maxLineLength) { - chunks.push(line.slice(i, i + maxLineLength)); - } - return chunks.join('\n'); - }).join('\n'); - }; - - const visibleMessages = messages.filter((msg) => { - if (msg.type === 'system' && msg.content.startsWith('[MODEL:')) { - return false; - } - if (msg.type === 'system' && msg.content.startsWith('[MODE:')) { - return false; - } - return true; - }); - - return ( - - - - Gemini Agent Messages - {'-'.repeat(Math.min(terminalWidth - 4, 60))} - - - - {visibleMessages.length === 0 ? ( - Waiting for messages... - ) : ( - visibleMessages - .slice(-Math.max(1, terminalHeight - 10)) - .map((msg) => ( - - - {formatMessage(msg)} - - - )) - )} - - - - - - {actionInProgress === 'exiting' ? ( - - Exiting agent... - - ) : actionInProgress === 'switching' ? ( - - Switching to local mode... - - ) : confirmationMode === 'exit' ? ( - - Press Ctrl-C again to exit the agent - - ) : confirmationMode === 'switch' ? ( - - Press space again to switch to local mode - - ) : ( - - Gemini running {onSwitchToLocal ? '(Space to switch to local, Ctrl-C to exit)' : '(Ctrl-C to exit)'} - - )} - {(model || permissionMode) && ( - - {model ? `Model: ${model}` : 'Model: default'} - {permissionMode ? ` | Permission: ${permissionMode}` : ''} - - )} - {process.env.DEBUG && logPath && ( - - Debug logs: {logPath} - - )} - - - - ); -}; diff --git a/shared/src/modes.test.ts b/shared/src/modes.test.ts index eb97fe10..3ef31933 100644 --- a/shared/src/modes.test.ts +++ b/shared/src/modes.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, test } from 'bun:test' import { + AGENT_FLAVORS, + AgentFlavorSchema, + CREATABLE_AGENT_FLAVORS, getPermissionModeLabel, getPermissionModeOptionsForFlavor, getPermissionModeTone, @@ -7,6 +10,24 @@ import { isPermissionModeAllowedForFlavor, } from './modes' +describe('Gemini CLI sunset (read-only, not creatable)', () => { + test('gemini stays a valid flavor so existing stored sessions still validate/load', () => { + expect(AGENT_FLAVORS).toContain('gemini') + expect(AgentFlavorSchema.safeParse('gemini').success).toBe(true) + }) + + test('gemini is excluded from creatable flavors (not offered for new sessions)', () => { + expect(CREATABLE_AGENT_FLAVORS).not.toContain('gemini') + }) + + test('all other flavors remain creatable', () => { + for (const flavor of AGENT_FLAVORS) { + if (flavor === 'gemini') continue + expect(CREATABLE_AGENT_FLAVORS).toContain(flavor) + } + }) +}) + describe('getPermissionModesForFlavor', () => { test("returns [] for flavor 'pi' (RPC mode has no runtime permission switching)", () => { expect(getPermissionModesForFlavor('pi')).toEqual([]) diff --git a/shared/src/modes.ts b/shared/src/modes.ts index a8d1c665..73209677 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -11,6 +11,14 @@ export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'kimi', 'op export type AgentFlavor = typeof AGENT_FLAVORS[number] export const AgentFlavorSchema = z.enum(AGENT_FLAVORS) +// Flavors offered when CREATING a new session. Gemini CLI is intentionally +// excluded: Google sunset the consumer Gemini CLI (2026-06-18) so it can no +// longer be launched. It is kept in AGENT_FLAVORS / AgentFlavorSchema above so +// existing stored Gemini sessions still validate and remain viewable. +export const CREATABLE_AGENT_FLAVORS: readonly AgentFlavor[] = AGENT_FLAVORS.filter( + (flavor) => flavor !== 'gemini' +) + export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan'] as const export type ClaudePermissionMode = typeof CLAUDE_PERMISSION_MODES[number] diff --git a/web/src/components/NewSession/AgentSelector.test.tsx b/web/src/components/NewSession/AgentSelector.test.tsx new file mode 100644 index 00000000..fbb2ebba --- /dev/null +++ b/web/src/components/NewSession/AgentSelector.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from 'vitest' +import { render } from '@testing-library/react' +import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol' + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +import { AgentSelector } from './AgentSelector' +import type { AgentType } from './types' + +function renderedAgentValues(): string[] { + const { container } = render( + {}} /> + ) + return Array.from(container.querySelectorAll('input[type="radio"]')) + .map((el) => (el as HTMLInputElement).value) +} + +describe('AgentSelector', () => { + it('does not offer the sunset Gemini CLI as a new-session agent', () => { + expect(renderedAgentValues()).not.toContain('gemini') + }) + + it('offers exactly the creatable agent flavors', () => { + expect(renderedAgentValues()).toEqual([...CREATABLE_AGENT_FLAVORS]) + }) +}) diff --git a/web/src/components/NewSession/AgentSelector.tsx b/web/src/components/NewSession/AgentSelector.tsx index 4146f308..1de5d7a4 100644 --- a/web/src/components/NewSession/AgentSelector.tsx +++ b/web/src/components/NewSession/AgentSelector.tsx @@ -1,4 +1,4 @@ -import { AGENT_FLAVORS } from '@hapi/protocol' +import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol' import type { AgentType } from './types' import { useTranslation } from '@/lib/use-translation' @@ -15,7 +15,7 @@ export function AgentSelector(props: { {t('newSession.agent')}
- {AGENT_FLAVORS.map((agentType) => ( + {CREATABLE_AGENT_FLAVORS.map((agentType) => (