mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
feat(gemini): remove launchable Gemini CLI agent, keep old sessions readable (#953)
* 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 <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(gemini): reject gemini resume before handoff (#953 review) HAPI Bot [Major]: `hapi resume <active-gemini-session>` 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 <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 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 <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 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 <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 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 <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 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 <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: HAPI <noreply@hapi.run> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
HAPI
Claude Opus 4.8
parent
26a24bb6ce
commit
b44885ae67
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -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<ReturnType<typeof createGeminiSessionScanner>>;
|
||||
|
||||
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<void> => {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void>)
|
||||
}));
|
||||
|
||||
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<void>((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<void> {}
|
||||
}
|
||||
}));
|
||||
|
||||
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<GeminiMode>((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<string, (params: unknown) => 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'
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createGeminiBackend> | 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<RemoteLauncherExitReason> {
|
||||
return this.start({
|
||||
onExit: () => this.handleExitFromUi(),
|
||||
onSwitchToLocal: () => this.handleSwitchFromUi()
|
||||
});
|
||||
}
|
||||
|
||||
protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement {
|
||||
return React.createElement(GeminiDisplay, context);
|
||||
}
|
||||
|
||||
protected async runMainLoop(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.requestExit('exit', () => this.handleAbort());
|
||||
}
|
||||
|
||||
private async handleSwitchFromUi(): Promise<void> {
|
||||
await this.requestExit('switch', () => this.handleAbort());
|
||||
}
|
||||
|
||||
private async handleSwitchRequest(): Promise<void> {
|
||||
await this.requestExit('switch', () => this.handleAbort());
|
||||
}
|
||||
}
|
||||
|
||||
function toAcpMcpServers(config: Record<string, { command: string; args: string[] }>): 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();
|
||||
}
|
||||
@@ -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<GeminiMode>;
|
||||
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<void> {
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -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<Record<string, unknown>>,
|
||||
geminiLoopArgs: [] as Array<Record<string, unknown>>,
|
||||
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<string, unknown>) => {
|
||||
harness.bootstrapArgs.push(options);
|
||||
return {
|
||||
api: {},
|
||||
session: harness.session
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('./loop', () => ({
|
||||
geminiLoop: vi.fn(async (options: Record<string, unknown>) => {
|
||||
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<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('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<unknown>;
|
||||
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<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');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<GeminiMode>((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<PermissionMode>({
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<GeminiMode> {
|
||||
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<GeminiMode>;
|
||||
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<ApiSessionClient['sendSessionEvent']>[0]): void => {
|
||||
this.client.sendSessionEvent(event);
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { GeminiPermissionMode } from '@hapi/protocol/types';
|
||||
|
||||
export type PermissionMode = GeminiPermissionMode;
|
||||
|
||||
export interface GeminiMode {
|
||||
permissionMode: PermissionMode;
|
||||
model?: string;
|
||||
}
|
||||
@@ -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<string, unknown> | 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<string, unknown>;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`[gemini-config] Failed to read ${path}: ${error}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractModel(settings: Record<string, unknown>): string | undefined {
|
||||
const modelEntry = settings.model;
|
||||
if (modelEntry && typeof modelEntry === 'object') {
|
||||
const name = (modelEntry as Record<string, unknown>).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, unknown>): 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;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { AcpSdkBackend } from '@/agent/backends/acp';
|
||||
import { buildGeminiEnv, resolveGeminiRuntimeConfig } from './config';
|
||||
|
||||
function filterEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
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)
|
||||
});
|
||||
}
|
||||
@@ -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<PermissionResponseMessage, void> {
|
||||
private readonly pendingBackendRequests = new Map<string, PermissionRequest>();
|
||||
|
||||
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<void> {
|
||||
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<void>
|
||||
): Promise<PermissionCompletion> {
|
||||
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<void> {
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<GeminiTranscriptMessage> {
|
||||
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<void> {
|
||||
if (this.transcriptPath === path) {
|
||||
return;
|
||||
}
|
||||
this.transcriptPath = path;
|
||||
await this.primeTranscript(path);
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
protected async initialize(): Promise<void> {
|
||||
if (this.transcriptPath) {
|
||||
await this.primeTranscript(this.transcriptPath);
|
||||
}
|
||||
}
|
||||
|
||||
protected async findSessionFiles(): Promise<string[]> {
|
||||
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<SessionFileScanResult<GeminiTranscriptMessage>> {
|
||||
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<GeminiTranscriptMessage>[] = [];
|
||||
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<GeminiTranscriptMessage>): Promise<void> {
|
||||
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<void> {
|
||||
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<GeminiTranscript | null> {
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
+13
-9
@@ -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') {
|
||||
|
||||
@@ -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<GeminiDisplayProps> = ({
|
||||
messageBuffer,
|
||||
logPath,
|
||||
onExit,
|
||||
onSwitchToLocal
|
||||
}) => {
|
||||
const [messages, setMessages] = useState<BufferedMessage[]>([]);
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
const [permissionMode, setPermissionMode] = useState<string | null>(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 (
|
||||
<Box flexDirection="column" width={terminalWidth} height={terminalHeight}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={terminalWidth}
|
||||
height={terminalHeight - 4}
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="gray" bold>Gemini Agent Messages</Text>
|
||||
<Text color="gray" dimColor>{'-'.repeat(Math.min(terminalWidth - 4, 60))}</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" height={terminalHeight - 10} overflow="hidden">
|
||||
{visibleMessages.length === 0 ? (
|
||||
<Text color="gray" dimColor>Waiting for messages...</Text>
|
||||
) : (
|
||||
visibleMessages
|
||||
.slice(-Math.max(1, terminalHeight - 10))
|
||||
.map((msg) => (
|
||||
<Box key={msg.id} flexDirection="column" marginBottom={1}>
|
||||
<Text color={getMessageColor(msg.type)} dimColor>
|
||||
{formatMessage(msg)}
|
||||
</Text>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
actionInProgress ? 'gray' :
|
||||
confirmationMode === 'exit' ? 'red' :
|
||||
confirmationMode === 'switch' ? 'yellow' :
|
||||
'green'
|
||||
}
|
||||
paddingX={2}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
{actionInProgress === 'exiting' ? (
|
||||
<Text color="gray" bold>
|
||||
Exiting agent...
|
||||
</Text>
|
||||
) : actionInProgress === 'switching' ? (
|
||||
<Text color="gray" bold>
|
||||
Switching to local mode...
|
||||
</Text>
|
||||
) : confirmationMode === 'exit' ? (
|
||||
<Text color="red" bold>
|
||||
Press Ctrl-C again to exit the agent
|
||||
</Text>
|
||||
) : confirmationMode === 'switch' ? (
|
||||
<Text color="yellow" bold>
|
||||
Press space again to switch to local mode
|
||||
</Text>
|
||||
) : (
|
||||
<Text color="green" bold>
|
||||
Gemini running {onSwitchToLocal ? '(Space to switch to local, Ctrl-C to exit)' : '(Ctrl-C to exit)'}
|
||||
</Text>
|
||||
)}
|
||||
{(model || permissionMode) && (
|
||||
<Text color="gray" dimColor>
|
||||
{model ? `Model: ${model}` : 'Model: default'}
|
||||
{permissionMode ? ` | Permission: ${permissionMode}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
{process.env.DEBUG && logPath && (
|
||||
<Text color="gray" dimColor>
|
||||
Debug logs: {logPath}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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([])
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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(
|
||||
<AgentSelector agent={'claude' as AgentType} isDisabled={false} onAgentChange={() => {}} />
|
||||
)
|
||||
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])
|
||||
})
|
||||
})
|
||||
@@ -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')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-2">
|
||||
{AGENT_FLAVORS.map((agentType) => (
|
||||
{CREATABLE_AGENT_FLAVORS.map((agentType) => (
|
||||
<label
|
||||
key={agentType}
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
|
||||
@@ -80,4 +80,29 @@ describe('newSessionFormDraft', () => {
|
||||
const draft = loadNewSessionFormDraft()!
|
||||
expect(newSessionDraftMatchesMachine(draft, 'machine-b')).toBe(false)
|
||||
})
|
||||
|
||||
it('coerces a stale uncreatable agent (gemini) to claude and resets dependent fields', () => {
|
||||
saveNewSessionFormDraft({
|
||||
agent: 'gemini',
|
||||
model: 'gemini-2.5-pro',
|
||||
cursorSelectedBase: 'composer-2.5',
|
||||
machineId: 'machine-1',
|
||||
effort: 'high',
|
||||
modelReasoningEffort: 'high',
|
||||
yoloMode: true,
|
||||
sessionType: 'simple',
|
||||
worktreeName: ''
|
||||
})
|
||||
|
||||
const loaded = loadNewSessionFormDraft()!
|
||||
expect(loaded.agent).toBe('claude')
|
||||
// agent-dependent fields reset so a Gemini model isn't carried into Claude
|
||||
expect(loaded.model).toBe('auto')
|
||||
expect(loaded.cursorSelectedBase).toBe('auto')
|
||||
expect(loaded.effort).toBe('auto')
|
||||
expect(loaded.modelReasoningEffort).toBe('default')
|
||||
// agent-independent fields preserved
|
||||
expect(loaded.yoloMode).toBe(true)
|
||||
expect(loaded.machineId).toBe('machine-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol'
|
||||
import type { AgentType, ClaudeEffort, CodexReasoningEffort, SessionType } from './types'
|
||||
|
||||
const DRAFT_STORAGE_KEY = 'hapi:new-session-form-draft'
|
||||
@@ -32,13 +33,25 @@ export function loadNewSessionFormDraft(): NewSessionFormDraft | null {
|
||||
if (typeof parsed.agent !== 'string' || typeof parsed.model !== 'string') {
|
||||
return null
|
||||
}
|
||||
// Coerce a stale/uncreatable agent (e.g. a pre-removal 'gemini' draft)
|
||||
// back to a launchable default. When the agent is coerced, also drop the
|
||||
// agent-dependent fields (model / cursor base / effort) so a Gemini
|
||||
// draft does not carry a Gemini model into the Claude fallback.
|
||||
const restoredAgent: AgentType = (CREATABLE_AGENT_FLAVORS as readonly string[]).includes(parsed.agent)
|
||||
? (parsed.agent as AgentType)
|
||||
: 'claude'
|
||||
const agentPreserved = restoredAgent === parsed.agent
|
||||
return {
|
||||
agent: parsed.agent as AgentType,
|
||||
model: parsed.model,
|
||||
cursorSelectedBase: typeof parsed.cursorSelectedBase === 'string' ? parsed.cursorSelectedBase : 'auto',
|
||||
agent: restoredAgent,
|
||||
model: agentPreserved ? parsed.model : 'auto',
|
||||
cursorSelectedBase: agentPreserved && typeof parsed.cursorSelectedBase === 'string'
|
||||
? parsed.cursorSelectedBase
|
||||
: 'auto',
|
||||
machineId: typeof parsed.machineId === 'string' ? parsed.machineId : null,
|
||||
effort: (parsed.effort as ClaudeEffort | undefined) ?? 'auto',
|
||||
modelReasoningEffort: (parsed.modelReasoningEffort as CodexReasoningEffort | undefined) ?? 'default',
|
||||
effort: agentPreserved ? ((parsed.effort as ClaudeEffort | undefined) ?? 'auto') : 'auto',
|
||||
modelReasoningEffort: agentPreserved
|
||||
? ((parsed.modelReasoningEffort as CodexReasoningEffort | undefined) ?? 'default')
|
||||
: 'default',
|
||||
yoloMode: Boolean(parsed.yoloMode),
|
||||
sessionType: (parsed.sessionType as SessionType | undefined) ?? 'simple',
|
||||
worktreeName: typeof parsed.worktreeName === 'string' ? parsed.worktreeName : ''
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { AGENT_FLAVORS } from '@hapi/protocol'
|
||||
import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol'
|
||||
import type { AgentType } from './types'
|
||||
|
||||
const AGENT_STORAGE_KEY = 'hapi:newSession:agent'
|
||||
const YOLO_STORAGE_KEY = 'hapi:newSession:yolo'
|
||||
|
||||
const VALID_AGENTS = AGENT_FLAVORS
|
||||
// Only launchable flavors are valid defaults; a stale 'gemini' preference
|
||||
// (no longer creatable) falls back to 'claude'.
|
||||
const VALID_AGENTS = CREATABLE_AGENT_FLAVORS
|
||||
|
||||
export function loadPreferredAgent(): AgentType {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user