Files
hapi/cli/src/gemini/geminiRemoteLauncher.ts
T
b06251bd53 fix: stream opencode reasoning updates (#661)
Emit throttled ACP reasoning snapshots with stable stream ids so OpenCode reasoning updates render live without one row per token.

Collapse matching reasoning snapshots in the web timeline and avoid stale session 404 redirects during refetch.

Tests: targeted Vitest suite and bun typecheck.

Co-authored-by: twshe <twshe@outlook.com>
2026-05-22 08:57:50 +08:00

305 lines
12 KiB
TypeScript

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 '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();
}