diff --git a/README.md b/README.md index 215c895e..c0dd89ae 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Happy -Code on the go controlling claude code from your mobile device. +Code on the go — control AI coding agents from your mobile device. Free. Open source. Code anywhere. @@ -12,6 +12,8 @@ npm install -g happy-coder ## Usage +### Claude (default) + ```bash happy ``` @@ -21,32 +23,116 @@ This will: 2. Display a QR code to connect from your mobile device 3. Allow real-time session sharing between Claude Code and your mobile app +### Gemini + +```bash +happy gemini +``` + +Start a Gemini CLI session with remote control capabilities. + +**First time setup:** +```bash +# Authenticate with Google +happy connect gemini +``` + ## Commands -- `happy auth` – Manage authentication +### Main Commands + +- `happy` – Start Claude Code session (default) +- `happy gemini` – Start Gemini CLI session - `happy codex` – Start Codex mode + +### Utility Commands + +- `happy auth` – Manage authentication - `happy connect` – Store AI vendor API keys in Happy cloud - `happy notify` – Send a push notification to your devices - `happy daemon` – Manage background service - `happy doctor` – System diagnostics & troubleshooting +### Connect Subcommands + +```bash +happy connect gemini # Authenticate with Google for Gemini +happy connect claude # Authenticate with Anthropic +happy connect codex # Authenticate with OpenAI +happy connect status # Show connection status for all vendors +``` + +### Gemini Subcommands + +```bash +happy gemini # Start Gemini session +happy gemini model set # Set default model +happy gemini model get # Show current model +happy gemini project set # Set Google Cloud Project ID (for Workspace accounts) +happy gemini project get # Show current Google Cloud Project ID +``` + +**Available models:** `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` + ## Options -- `-h, --help` - Show help -- `-v, --version` - Show version +### Claude Options + - `-m, --model ` - Claude model to use (default: sonnet) - `-p, --permission-mode ` - Permission mode: auto, default, or plan -- `--claude-env KEY=VALUE` - Set environment variable for Claude Code (e.g., for [claude-code-router](https://github.com/musistudio/claude-code-router)) +- `--claude-env KEY=VALUE` - Set environment variable for Claude Code - `--claude-arg ARG` - Pass additional argument to Claude CLI +### Global Options + +- `-h, --help` - Show help +- `-v, --version` - Show version + ## Environment Variables +### Happy Configuration + - `HAPPY_SERVER_URL` - Custom server URL (default: https://api.cluster-fluster.com) - `HAPPY_WEBAPP_URL` - Custom web app URL (default: https://app.happy.engineering) - `HAPPY_HOME_DIR` - Custom home directory for Happy data (default: ~/.happy) - `HAPPY_DISABLE_CAFFEINATE` - Disable macOS sleep prevention (set to `true`, `1`, or `yes`) - `HAPPY_EXPERIMENTAL` - Enable experimental features (set to `true`, `1`, or `yes`) +### Gemini Configuration + +- `GEMINI_MODEL` - Override default Gemini model +- `GOOGLE_CLOUD_PROJECT` - Google Cloud Project ID (required for Workspace accounts) + +## Gemini Authentication + +### Personal Google Account + +Personal Gmail accounts work out of the box: + +```bash +happy connect gemini +happy gemini +``` + +### Google Workspace Account + +Google Workspace (organization) accounts require a Google Cloud Project: + +1. Create a project in [Google Cloud Console](https://console.cloud.google.com/) +2. Enable the Gemini API +3. Set the project ID: + +```bash +happy gemini project set your-project-id +``` + +Or use environment variable: +```bash +GOOGLE_CLOUD_PROJECT=your-project-id happy gemini +``` + +**Guide:** https://goo.gle/gemini-cli-auth-docs#workspace-gca + ## Contributing Interested in contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. @@ -54,11 +140,16 @@ Interested in contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for developme ## Requirements - Node.js >= 20.0.0 - - Required by `eventsource-parser@3.0.5`, which is required by - `@modelcontextprotocol/sdk`, which we used to implement permission forwarding - to mobile app + +### For Claude + - Claude CLI installed & logged in (`claude` command available in PATH) +### For Gemini + +- Gemini CLI installed (`npm install -g @google/gemini-cli`) +- Google account authenticated via `happy connect gemini` + ## License MIT diff --git a/src/agent/acp/AcpSdkBackend.ts b/src/agent/acp/AcpBackend.ts similarity index 71% rename from src/agent/acp/AcpSdkBackend.ts rename to src/agent/acp/AcpBackend.ts index 8a88fd69..c2dbc645 100644 --- a/src/agent/acp/AcpSdkBackend.ts +++ b/src/agent/acp/AcpBackend.ts @@ -1,14 +1,15 @@ /** - * AcpSdkBackend - Agent Client Protocol backend using official SDK - * - * This module provides a backend implementation using the official - * @agentclientprotocol/sdk for direct control over the ACP protocol. + * AcpBackend - Agent Client Protocol backend using official SDK + * + * This module provides a universal backend implementation using the official + * @agentclientprotocol/sdk. Agent-specific behavior (timeouts, filtering, + * error handling) is delegated to TransportHandler implementations. */ import { spawn, type ChildProcess } from 'node:child_process'; import { Readable, Writable } from 'node:stream'; -import { - ClientSideConnection, +import { + ClientSideConnection, ndJsonStream, type Client, type Agent, @@ -21,23 +22,22 @@ import { type ContentBlock, } from '@agentclientprotocol/sdk'; import { randomUUID } from 'node:crypto'; -import type { - AgentBackend, - AgentMessage, - AgentMessageHandler, +import type { + AgentBackend, + AgentMessage, + AgentMessageHandler, SessionId, StartSessionResult, McpServerConfig, -} from '../AgentBackend'; +} from '../core'; import { logger } from '@/ui/logger'; import packageJson from '../../../package.json'; import { - isInvestigationTool, - determineToolName, - getRealToolName, - getToolCallTimeout, -} from './utils'; -import { hasChangeTitleInstruction } from '@/gemini/utils/promptUtils'; + type TransportHandler, + type StderrContext, + type ToolNameContext, + DefaultTransport, +} from '../transport'; /** * Extended RequestPermissionRequest with additional fields that may be present @@ -86,9 +86,6 @@ type ExtendedSessionNotification = SessionNotification & { }; } -/** Timeout for ACP initialization in milliseconds (2 minutes - Gemini CLI can be slow on first start) */ -const ACP_INIT_TIMEOUT_MS = 120000; - /** * Permission handler interface for ACP backends */ @@ -108,29 +105,35 @@ export interface AcpPermissionHandler { } /** - * Configuration for AcpSdkBackend + * Configuration for AcpBackend */ -export interface AcpSdkBackendOptions { +export interface AcpBackendOptions { /** Agent name for identification */ agentName: string; - + /** Working directory for the agent */ cwd: string; - + /** Command to spawn the ACP agent */ command: string; - + /** Arguments for the agent command */ args?: string[]; - + /** Environment variables to pass to the agent */ env?: Record; - + /** MCP servers to make available to the agent */ mcpServers?: Record; - + /** Optional permission handler for tool approval */ permissionHandler?: AcpPermissionHandler; + + /** Transport handler for agent-specific behavior (timeouts, filtering, etc.) */ + transportHandler?: TransportHandler; + + /** Optional callback to check if prompt has change_title instruction */ + hasChangeTitleInstruction?: (prompt: string) => boolean; } /** @@ -149,7 +152,7 @@ function nodeToWebStreams( return new Promise((resolve, reject) => { const ok = stdin.write(chunk, (err) => { if (err) { - logger.debug(`[AcpSdkBackend] Error writing to stdin:`, err); + logger.debug(`[AcpBackend] Error writing to stdin:`, err); reject(err); } }); @@ -181,7 +184,7 @@ function nodeToWebStreams( controller.close(); }); stdout.on('error', (err) => { - logger.debug(`[AcpSdkBackend] Stdout error:`, err); + logger.debug(`[AcpBackend] Stdout error:`, err); controller.error(err); }); }, @@ -196,7 +199,7 @@ function nodeToWebStreams( /** * ACP backend using the official @agentclientprotocol/sdk */ -export class AcpSdkBackend implements AgentBackend { +export class AcpBackend implements AgentBackend { private listeners: AgentMessageHandler[] = []; private process: ChildProcess | null = null; private connection: ClientSideConnection | null = null; @@ -209,22 +212,26 @@ export class AcpSdkBackend implements AgentBackend { private toolCallStartTimes = new Map(); /** Pending permission requests that need response */ private pendingPermissions = new Map void>(); - + /** Map from permission request ID to real tool call ID for tracking */ private permissionToToolCallMap = new Map(); - + /** Map from real tool call ID to tool name for auto-approval */ private toolCallIdToNameMap = new Map(); - + /** Track if we just sent a prompt with change_title instruction */ private recentPromptHadChangeTitle = false; - + /** Track tool calls count since last prompt (to identify first tool call) */ private toolCallCountSincePrompt = 0; /** Timeout for emitting 'idle' status after last message chunk */ private idleTimeout: NodeJS.Timeout | null = null; - constructor(private options: AcpSdkBackendOptions) { + /** Transport handler for agent-specific behavior */ + private readonly transport: TransportHandler; + + constructor(private options: AcpBackendOptions) { + this.transport = options.transportHandler ?? new DefaultTransport(options.agentName); } onMessage(handler: AgentMessageHandler): void { @@ -244,7 +251,7 @@ export class AcpSdkBackend implements AgentBackend { try { listener(msg); } catch (error) { - logger.warn('[AcpSdkBackend] Error in message handler:', error); + logger.warn('[AcpBackend] Error in message handler:', error); } } } @@ -258,7 +265,7 @@ export class AcpSdkBackend implements AgentBackend { this.emit({ type: 'status', status: 'starting' }); try { - logger.debug(`[AcpSdkBackend] Starting session: ${sessionId}`); + logger.debug(`[AcpBackend] Starting session: ${sessionId}`); // Spawn the ACP agent process const args = this.options.args || []; @@ -293,61 +300,46 @@ export class AcpSdkBackend implements AgentBackend { throw new Error('Failed to create stdio pipes'); } - // Suppress stderr output - redirect to logger only (not console) - // Gemini CLI may output debug info to stderr, but we don't want it in console - // However, we need to detect API errors (like 429 rate limit) and notify the user + // Handle stderr output via transport handler this.process.stderr.on('data', (data: Buffer) => { const text = data.toString(); - // Only log to file, never to console - if (text.trim()) { - // Check if we have active investigation tools - log stderr more verbosely for them - const hasActiveInvestigation = Array.from(this.activeToolCalls).some(id => - isInvestigationTool(id) - ); - - if (hasActiveInvestigation) { - logger.debug(`[AcpSdkBackend] 🔍 Agent stderr (during investigation): ${text.trim()}`); - } else { - logger.debug(`[AcpSdkBackend] Agent stderr: ${text.trim()}`); - } - - // Detect API errors in stderr (429 rate limit, 404 not found, etc.) - // These errors come from gemini-cli when API calls fail - // Note: gemini-cli handles retries internally, we don't need to track or show these - if (text.includes('status 429') || text.includes('code":429') || - text.includes('rateLimitExceeded') || text.includes('RESOURCE_EXHAUSTED')) { - // Rate limit error - log for debugging but don't show to user - // Gemini CLI will handle retries internally - logger.debug('[AcpSdkBackend] ⚠️ Detected rate limit error (429) in stderr - gemini-cli will handle retry'); - } else if (text.includes('status 404') || text.includes('code":404')) { - // Not found error - emit error status - logger.debug('[AcpSdkBackend] ⚠️ Detected 404 error in stderr'); - this.emit({ - type: 'status', - status: 'error', - detail: 'Model not found. Available models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite' - }); - } else if (hasActiveInvestigation && ( - text.includes('timeout') || text.includes('Timeout') || - text.includes('failed') || text.includes('Failed') || - text.includes('error') || text.includes('Error') - )) { - // Log any errors/timeouts during investigation tools for debugging - logger.debug(`[AcpSdkBackend] 🔍 Investigation tool stderr error/timeout: ${text.trim()}`); + if (!text.trim()) return; + + // Build context for transport handler + const hasActiveInvestigation = this.transport.isInvestigationTool + ? Array.from(this.activeToolCalls).some(id => this.transport.isInvestigationTool!(id)) + : false; + + const context: StderrContext = { + activeToolCalls: this.activeToolCalls, + hasActiveInvestigation, + }; + + // Log to file (not console) + if (hasActiveInvestigation) { + logger.debug(`[AcpBackend] 🔍 Agent stderr (during investigation): ${text.trim()}`); + } else { + logger.debug(`[AcpBackend] Agent stderr: ${text.trim()}`); + } + + // Let transport handler process stderr and optionally emit messages + if (this.transport.handleStderr) { + const result = this.transport.handleStderr(text, context); + if (result.message) { + this.emit(result.message); } - } }); this.process.on('error', (err) => { // Log to file only, not console - logger.debug(`[AcpSdkBackend] Process error:`, err); + logger.debug(`[AcpBackend] Process error:`, err); this.emit({ type: 'status', status: 'error', detail: err.message }); }); this.process.on('exit', (code, signal) => { if (!this.disposed && code !== 0 && code !== null) { - logger.debug(`[AcpSdkBackend] Process exited with code ${code}, signal ${signal}`); + logger.debug(`[AcpBackend] Process exited with code ${code}, signal ${signal}`); this.emit({ type: 'status', status: 'stopped', detail: `Exit code: ${code}` }); } }); @@ -360,9 +352,9 @@ export class AcpSdkBackend implements AgentBackend { const writable = streams.writable; const readable = streams.readable; - // Filter out non-JSON data before it reaches ndJsonStream - // Gemini CLI outputs debug info (experiments, flags, etc.) to stdout - // which breaks ACP protocol parsing. We filter it silently. + // Filter stdout via transport handler before ACP parsing + // Some agents output debug info that breaks JSON-RPC parsing + const transport = this.transport; const filteredReadable = new ReadableStream({ async start(controller) { const reader = readable.getReader(); @@ -370,67 +362,57 @@ export class AcpSdkBackend implements AgentBackend { const encoder = new TextEncoder(); let buffer = ''; let filteredCount = 0; - - // Helper function to check if a string is valid JSON - const isValidJSON = (str: string): boolean => { - const trimmed = str.trim(); - if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) { - return false; - } - try { - JSON.parse(trimmed); - return true; - } catch { - return false; - } - }; - + try { while (true) { const { done, value } = await reader.read(); if (done) { // Flush any remaining buffer if (buffer.trim()) { - if (isValidJSON(buffer)) { + const filtered = transport.filterStdoutLine?.(buffer); + if (filtered === undefined) { controller.enqueue(encoder.encode(buffer)); + } else if (filtered !== null) { + controller.enqueue(encoder.encode(filtered)); } else { filteredCount++; } } if (filteredCount > 0) { - logger.debug(`[AcpSdkBackend] Filtered out ${filteredCount} non-JSON lines from gemini CLI stdout`); + logger.debug(`[AcpBackend] Filtered out ${filteredCount} non-JSON lines from ${transport.agentName} stdout`); } controller.close(); break; } - + // Decode and accumulate data buffer += decoder.decode(value, { stream: true }); - + // Process line by line (ndJSON is line-delimited) const lines = buffer.split('\n'); buffer = lines.pop() || ''; // Keep last incomplete line in buffer - + for (const line of lines) { - const trimmed = line.trim(); - - // Skip empty lines - if (!trimmed) { - continue; - } - - // Validate JSON before passing through - if (isValidJSON(trimmed)) { - // Valid JSON line - pass it through + if (!line.trim()) continue; + + // Use transport handler to filter lines + // Note: filterStdoutLine returns null to filter out, string to keep + // If method not implemented (undefined), pass through original line + const filtered = transport.filterStdoutLine?.(line); + if (filtered === undefined) { + // Method not implemented, pass through controller.enqueue(encoder.encode(line + '\n')); + } else if (filtered !== null) { + // Method returned transformed line + controller.enqueue(encoder.encode(filtered + '\n')); } else { - // Non-JSON or invalid JSON - silently skip it + // Method returned null, filter out filteredCount++; } } } } catch (error) { - logger.debug(`[AcpSdkBackend] Error filtering stdout stream:`, error); + logger.debug(`[AcpBackend] Error filtering stdout stream:`, error); controller.error(error); } finally { reader.releaseLock(); @@ -448,11 +430,13 @@ export class AcpSdkBackend implements AgentBackend { }, requestPermission: async (params: RequestPermissionRequest): Promise => { - const permissionId = randomUUID(); const extendedParams = params as ExtendedRequestPermissionRequest; const toolCall = extendedParams.toolCall; let toolName = toolCall?.kind || toolCall?.toolName || extendedParams.kind || 'Unknown tool'; - const toolCallId = toolCall?.id || permissionId; + // Use toolCallId as the single source of truth for permission ID + // This ensures mobile app sends back the same ID that we use to store pending requests + const toolCallId = toolCall?.id || randomUUID(); + const permissionId = toolCallId; // Use same ID for consistency! // Extract input/arguments from various possible locations FIRST (before checking toolName) let input: Record = {}; @@ -464,19 +448,14 @@ export class AcpSdkBackend implements AgentBackend { } // If toolName is "other" or "Unknown tool", try to determine real tool name - toolName = determineToolName( - toolName, - toolCallId, - input, - params, - { - recentPromptHadChangeTitle: this.recentPromptHadChangeTitle, - toolCallCountSincePrompt: this.toolCallCountSincePrompt, - } - ); + const context: ToolNameContext = { + recentPromptHadChangeTitle: this.recentPromptHadChangeTitle, + toolCallCountSincePrompt: this.toolCallCountSincePrompt, + }; + toolName = this.transport.determineToolName?.(toolName, toolCallId, input, context) ?? toolName; if (toolName !== (toolCall?.kind || toolCall?.toolName || extendedParams.kind || 'Unknown tool')) { - logger.debug(`[AcpSdkBackend] Detected tool name: ${toolName} from toolCallId: ${toolCallId}`); + logger.debug(`[AcpBackend] Detected tool name: ${toolName} from toolCallId: ${toolCallId}`); } // Increment tool call counter for context tracking @@ -485,8 +464,8 @@ export class AcpSdkBackend implements AgentBackend { const options = extendedParams.options || []; // Log permission request for debugging (include full params to understand structure) - logger.debug(`[AcpSdkBackend] Permission request: tool=${toolName}, toolCallId=${toolCallId}, input=`, JSON.stringify(input)); - logger.debug(`[AcpSdkBackend] Permission request params structure:`, JSON.stringify({ + logger.debug(`[AcpBackend] Permission request: tool=${toolName}, toolCallId=${toolCallId}, input=`, JSON.stringify(input)); + logger.debug(`[AcpBackend] Permission request params structure:`, JSON.stringify({ hasToolCall: !!toolCall, toolCallKind: toolCall?.kind, toolCallId: toolCall?.id, @@ -544,6 +523,15 @@ export class AcpSdkBackend implements AgentBackend { // Fallback to first option if no specific match optionId = options[0].optionId || 'proceed_once'; } + + // Emit tool-result with permissionId so UI can close the timer + // This is needed because tool_call_update comes with a different ID + this.emit({ + type: 'tool-result', + toolName, + result: { status: 'approved', decision: result.decision }, + callId: permissionId, + }); } else { // Denied or aborted - find cancel option const cancelOption = options.find((opt: any) => @@ -552,12 +540,20 @@ export class AcpSdkBackend implements AgentBackend { if (cancelOption) { optionId = cancelOption.optionId || 'cancel'; } + + // Emit tool-result for denied/aborted + this.emit({ + type: 'tool-result', + toolName, + result: { status: 'denied', decision: result.decision }, + callId: permissionId, + }); } return { outcome: { outcome: 'selected', optionId } }; } catch (error) { // Log to file only, not console - logger.debug('[AcpSdkBackend] Error in permission handler:', error); + logger.debug('[AcpBackend] Error in permission handler:', error); // Fallback to deny on error return { outcome: { outcome: 'selected', optionId: 'cancel' } }; } @@ -594,7 +590,7 @@ export class AcpSdkBackend implements AgentBackend { }, }; - logger.debug(`[AcpSdkBackend] Initializing connection...`); + logger.debug(`[AcpBackend] Initializing connection...`); let initTimeout: NodeJS.Timeout | null = null; const initResponse = await Promise.race([ this.connection.initialize(initRequest).then((result) => { @@ -606,13 +602,14 @@ export class AcpSdkBackend implements AgentBackend { return result; }), new Promise((_, reject) => { + const timeout = this.transport.getInitTimeout(); initTimeout = setTimeout(() => { - logger.debug(`[AcpSdkBackend] Initialize timeout after ${ACP_INIT_TIMEOUT_MS}ms`); - reject(new Error(`Initialize timeout after ${ACP_INIT_TIMEOUT_MS}ms - Gemini CLI did not respond`)); - }, ACP_INIT_TIMEOUT_MS); + logger.debug(`[AcpBackend] Initialize timeout after ${timeout}ms`); + reject(new Error(`Initialize timeout after ${timeout}ms - ${this.transport.agentName} did not respond`)); + }, timeout); }), ]); - logger.debug(`[AcpSdkBackend] Initialize completed`); + logger.debug(`[AcpBackend] Initialize completed`); // Create a new session const mcpServers = this.options.mcpServers @@ -631,7 +628,7 @@ export class AcpSdkBackend implements AgentBackend { mcpServers: mcpServers as unknown as NewSessionRequest['mcpServers'], }; - logger.debug(`[AcpSdkBackend] Creating new session...`); + logger.debug(`[AcpBackend] Creating new session...`); let newSessionTimeout: NodeJS.Timeout | null = null; const sessionResponse = await Promise.race([ this.connection.newSession(newSessionRequest).then((result) => { @@ -643,22 +640,23 @@ export class AcpSdkBackend implements AgentBackend { return result; }), new Promise((_, reject) => { + const timeout = this.transport.getInitTimeout(); newSessionTimeout = setTimeout(() => { - logger.debug(`[AcpSdkBackend] NewSession timeout after ${ACP_INIT_TIMEOUT_MS}ms`); - reject(new Error('New session timeout')); - }, ACP_INIT_TIMEOUT_MS); + logger.debug(`[AcpBackend] NewSession timeout after ${timeout}ms`); + reject(new Error(`New session timeout after ${timeout}ms - ${this.transport.agentName} did not respond`)); + }, timeout); }), ]); this.acpSessionId = sessionResponse.sessionId; - logger.debug(`[AcpSdkBackend] Session created: ${this.acpSessionId}`); + logger.debug(`[AcpBackend] Session created: ${this.acpSessionId}`); - this.emit({ type: 'status', status: 'idle' }); + this.emitIdleStatus(); // Send initial prompt if provided if (initialPrompt) { this.sendPrompt(sessionId, initialPrompt).catch((error) => { // Log to file only, not console - logger.debug('[AcpSdkBackend] Error sending initial prompt:', error); + logger.debug('[AcpBackend] Error sending initial prompt:', error); this.emit({ type: 'status', status: 'error', detail: String(error) }); }); } @@ -667,7 +665,7 @@ export class AcpSdkBackend implements AgentBackend { } catch (error) { // Log to file only, not console - logger.debug('[AcpSdkBackend] Error starting session:', error); + logger.debug('[AcpBackend] Error starting session:', error); this.emit({ type: 'status', status: 'error', @@ -683,7 +681,7 @@ export class AcpSdkBackend implements AgentBackend { const update = notification.update; if (!update) { - logger.debug('[AcpSdkBackend] Received session update without update field:', params); + logger.debug('[AcpBackend] Received session update without update field:', params); return; } @@ -691,7 +689,7 @@ export class AcpSdkBackend implements AgentBackend { // Log session updates for debugging (but not every chunk to avoid log spam) if (sessionUpdateType !== 'agent_message_chunk') { - logger.debug(`[AcpSdkBackend] Received session update: ${sessionUpdateType}`, JSON.stringify({ + logger.debug(`[AcpBackend] Received session update: ${sessionUpdateType}`, JSON.stringify({ sessionUpdate: sessionUpdateType, toolCallId: update.toolCallId, status: update.status, @@ -720,7 +718,7 @@ export class AcpSdkBackend implements AgentBackend { payload: { text }, }); } else { - logger.debug(`[AcpSdkBackend] Received message chunk (length: ${text.length}): ${text.substring(0, 50)}...`); + logger.debug(`[AcpBackend] Received message chunk (length: ${text.length}): ${text.substring(0, 50)}...`); this.emit({ type: 'model-output', textDelta: text, @@ -737,10 +735,10 @@ export class AcpSdkBackend implements AgentBackend { this.idleTimeout = setTimeout(() => { // Only emit idle if no active tool calls if (this.activeToolCalls.size === 0) { - logger.debug('[AcpSdkBackend] No more chunks received, emitting idle status'); - this.emit({ type: 'status', status: 'idle' }); + logger.debug('[AcpBackend] No more chunks received, emitting idle status'); + this.emitIdleStatus(); } else { - logger.debug(`[AcpSdkBackend] Delaying idle status - ${this.activeToolCalls.size} active tool calls`); + logger.debug(`[AcpBackend] Delaying idle status - ${this.activeToolCalls.size} active tool calls`); } this.idleTimeout = null; }, 500); // 500ms delay to batch chunks (reduced from 500ms, but still enough for options) @@ -754,7 +752,7 @@ export class AcpSdkBackend implements AgentBackend { const toolCallId = update.toolCallId; if (!toolCallId) { - logger.debug('[AcpSdkBackend] Tool call update without toolCallId:', update); + logger.debug('[AcpBackend] Tool call update without toolCallId:', update); return; } @@ -763,29 +761,30 @@ export class AcpSdkBackend implements AgentBackend { if (!this.activeToolCalls.has(toolCallId)) { const startTime = Date.now(); const toolKind = update.kind || 'unknown'; - const isInvestigation = isInvestigationTool(toolCallId, toolKind); + const isInvestigation = this.transport.isInvestigationTool?.(toolCallId, typeof toolKind === 'string' ? toolKind : undefined) ?? false; // Determine real tool name from toolCallId (e.g., "change_title-1765385846663" -> "change_title") - const realToolName = getRealToolName(toolCallId, toolKind); + const extractedName = this.transport.extractToolNameFromId?.(toolCallId); + const realToolName = extractedName ?? (typeof toolKind === 'string' ? toolKind : 'unknown'); // Store mapping for permission requests this.toolCallIdToNameMap.set(toolCallId, realToolName); this.activeToolCalls.add(toolCallId); this.toolCallStartTimes.set(toolCallId, startTime); - logger.debug(`[AcpSdkBackend] ⏱️ Set startTime for ${toolCallId} at ${new Date(startTime).toISOString()} (from tool_call_update)`); + logger.debug(`[AcpBackend] ⏱️ Set startTime for ${toolCallId} at ${new Date(startTime).toISOString()} (from tool_call_update)`); // Increment tool call counter for context tracking this.toolCallCountSincePrompt++; - logger.debug(`[AcpSdkBackend] 🔧 Tool call START: ${toolCallId} (${toolKind} -> ${realToolName})${isInvestigation ? ' [INVESTIGATION TOOL]' : ''}`); + logger.debug(`[AcpBackend] 🔧 Tool call START: ${toolCallId} (${toolKind} -> ${realToolName})${isInvestigation ? ' [INVESTIGATION TOOL]' : ''}`); if (isInvestigation) { - logger.debug(`[AcpSdkBackend] 🔍 Investigation tool detected (by toolCallId) - extended timeout (10min) will be used`); + logger.debug(`[AcpBackend] 🔍 Investigation tool detected (by toolCallId) - extended timeout (10min) will be used`); } // Set timeout for tool call completion (especially important for investigation tools) // This ensures timeout is set even if tool_call event doesn't arrive - const timeoutMs = getToolCallTimeout(toolCallId, toolKind); + const timeoutMs = this.transport.getToolCallTimeout?.(toolCallId, typeof toolKind === 'string' ? toolKind : undefined) ?? 120000; // Only set timeout if not already set (from tool_call event) if (!this.toolCallTimeouts.has(toolCallId)) { @@ -794,22 +793,22 @@ export class AcpSdkBackend implements AgentBackend { const duration = startTime ? Date.now() - startTime : null; const durationStr = duration ? `${(duration / 1000).toFixed(2)}s` : 'unknown'; - logger.debug(`[AcpSdkBackend] ⏱️ Tool call TIMEOUT (from tool_call_update): ${toolCallId} (${toolKind}) after ${(timeoutMs / 1000).toFixed(0)}s - Duration: ${durationStr}, removing from active set`); + logger.debug(`[AcpBackend] ⏱️ Tool call TIMEOUT (from tool_call_update): ${toolCallId} (${toolKind}) after ${(timeoutMs / 1000).toFixed(0)}s - Duration: ${durationStr}, removing from active set`); this.activeToolCalls.delete(toolCallId); this.toolCallStartTimes.delete(toolCallId); this.toolCallTimeouts.delete(toolCallId); // Check if we should emit idle status if (this.activeToolCalls.size === 0) { - logger.debug('[AcpSdkBackend] No more active tool calls after timeout, emitting idle status'); - this.emit({ type: 'status', status: 'idle' }); + logger.debug('[AcpBackend] No more active tool calls after timeout, emitting idle status'); + this.emitIdleStatus(); } }, timeoutMs); this.toolCallTimeouts.set(toolCallId, timeout); - logger.debug(`[AcpSdkBackend] ⏱️ Set timeout for ${toolCallId}: ${(timeoutMs / 1000).toFixed(0)}s${isInvestigation ? ' (investigation tool)' : ''}`); + logger.debug(`[AcpBackend] ⏱️ Set timeout for ${toolCallId}: ${(timeoutMs / 1000).toFixed(0)}s${isInvestigation ? ' (investigation tool)' : ''}`); } else { - logger.debug(`[AcpSdkBackend] Timeout already set for ${toolCallId}, skipping`); + logger.debug(`[AcpBackend] Timeout already set for ${toolCallId}, skipping`); } // Clear idle timeout - tool call is starting, agent is working @@ -832,7 +831,7 @@ export class AcpSdkBackend implements AgentBackend { // Log tool call details for investigation tools if (isInvestigation && args.objective) { - logger.debug(`[AcpSdkBackend] 🔍 Investigation tool objective: ${String(args.objective).substring(0, 100)}...`); + logger.debug(`[AcpBackend] 🔍 Investigation tool objective: ${String(args.objective).substring(0, 100)}...`); } this.emit({ @@ -843,7 +842,7 @@ export class AcpSdkBackend implements AgentBackend { }); } else { // Tool call already tracked - might be an update - logger.debug(`[AcpSdkBackend] Tool call ${toolCallId} already tracked, status: ${status}`); + logger.debug(`[AcpBackend] Tool call ${toolCallId} already tracked, status: ${status}`); } } else if (status === 'completed') { // Tool call finished - remove from active set and clear timeout @@ -861,7 +860,7 @@ export class AcpSdkBackend implements AgentBackend { } const durationStr = duration ? `${(duration / 1000).toFixed(2)}s` : 'unknown'; - logger.debug(`[AcpSdkBackend] ✅ Tool call COMPLETED: ${toolCallId} (${toolKind}) - Duration: ${durationStr}. Active tool calls: ${this.activeToolCalls.size}`); + logger.debug(`[AcpBackend] ✅ Tool call COMPLETED: ${toolCallId} (${toolKind}) - Duration: ${durationStr}. Active tool calls: ${this.activeToolCalls.size}`); this.emit({ type: 'tool-result', @@ -877,8 +876,8 @@ export class AcpSdkBackend implements AgentBackend { clearTimeout(this.idleTimeout); this.idleTimeout = null; } - logger.debug('[AcpSdkBackend] All tool calls completed, emitting idle status'); - this.emit({ type: 'status', status: 'idle' }); + logger.debug('[AcpBackend] All tool calls completed, emitting idle status'); + this.emitIdleStatus(); } } else if (status === 'failed' || status === 'cancelled') { // Tool call failed or was cancelled - remove from active set and clear timeout @@ -886,27 +885,27 @@ export class AcpSdkBackend implements AgentBackend { const startTime = this.toolCallStartTimes.get(toolCallId); const duration = startTime ? Date.now() - startTime : null; const toolKind = update.kind || 'unknown'; - const isInvestigation = isInvestigationTool(toolCallId, toolKind); + const isInvestigation = this.transport.isInvestigationTool?.(toolCallId, typeof toolKind === 'string' ? toolKind : undefined) ?? false; const hadTimeout = this.toolCallTimeouts.has(toolCallId); // Log detailed timing information for investigation tools BEFORE cleanup if (isInvestigation) { const durationStr = duration ? `${(duration / 1000).toFixed(2)}s` : 'unknown'; const durationMinutes = duration ? (duration / 1000 / 60).toFixed(2) : 'unknown'; - logger.debug(`[AcpSdkBackend] 🔍 Investigation tool ${status.toUpperCase()} after ${durationMinutes} minutes (${durationStr})`); + logger.debug(`[AcpBackend] 🔍 Investigation tool ${status.toUpperCase()} after ${durationMinutes} minutes (${durationStr})`); // Check if this matches a 3-minute timeout pattern if (duration) { const threeMinutes = 3 * 60 * 1000; const tolerance = 5000; // 5 second tolerance if (Math.abs(duration - threeMinutes) < tolerance) { - logger.debug(`[AcpSdkBackend] 🔍 ⚠️ Investigation tool failed at ~3 minutes - likely Gemini CLI timeout, not our timeout`); + logger.debug(`[AcpBackend] 🔍 ⚠️ Investigation tool failed at ~3 minutes - likely Gemini CLI timeout, not our timeout`); } } - logger.debug(`[AcpSdkBackend] 🔍 Investigation tool FAILED - full update.content:`, JSON.stringify(update.content, null, 2)); - logger.debug(`[AcpSdkBackend] 🔍 Investigation tool timeout status BEFORE cleanup: ${hadTimeout ? 'timeout was set' : 'no timeout was set'}`); - logger.debug(`[AcpSdkBackend] 🔍 Investigation tool startTime status BEFORE cleanup: ${startTime ? `set at ${new Date(startTime).toISOString()}` : 'not set'}`); + logger.debug(`[AcpBackend] 🔍 Investigation tool FAILED - full update.content:`, JSON.stringify(update.content, null, 2)); + logger.debug(`[AcpBackend] 🔍 Investigation tool timeout status BEFORE cleanup: ${hadTimeout ? 'timeout was set' : 'no timeout was set'}`); + logger.debug(`[AcpBackend] 🔍 Investigation tool startTime status BEFORE cleanup: ${startTime ? `set at ${new Date(startTime).toISOString()}` : 'not set'}`); } // Now cleanup - remove from active set and clear timeout @@ -917,13 +916,13 @@ export class AcpSdkBackend implements AgentBackend { if (timeout) { clearTimeout(timeout); this.toolCallTimeouts.delete(toolCallId); - logger.debug(`[AcpSdkBackend] Cleared timeout for ${toolCallId} (tool call ${status})`); + logger.debug(`[AcpBackend] Cleared timeout for ${toolCallId} (tool call ${status})`); } else { - logger.debug(`[AcpSdkBackend] No timeout found for ${toolCallId} (tool call ${status}) - timeout may not have been set`); + logger.debug(`[AcpBackend] No timeout found for ${toolCallId} (tool call ${status}) - timeout may not have been set`); } const durationStr = duration ? `${(duration / 1000).toFixed(2)}s` : 'unknown'; - logger.debug(`[AcpSdkBackend] ❌ Tool call ${status.toUpperCase()}: ${toolCallId} (${toolKind}) - Duration: ${durationStr}. Active tool calls: ${this.activeToolCalls.size}`); + logger.debug(`[AcpBackend] ❌ Tool call ${status.toUpperCase()}: ${toolCallId} (${toolKind}) - Duration: ${durationStr}. Active tool calls: ${this.activeToolCalls.size}`); // Extract error information from update.content if available let errorDetail: string | undefined; @@ -952,9 +951,9 @@ export class AcpSdkBackend implements AgentBackend { } if (errorDetail) { - logger.debug(`[AcpSdkBackend] ❌ Tool call error details: ${errorDetail.substring(0, 500)}`); + logger.debug(`[AcpBackend] ❌ Tool call error details: ${errorDetail.substring(0, 500)}`); } else { - logger.debug(`[AcpSdkBackend] ❌ Tool call ${status} but no error details in update.content`); + logger.debug(`[AcpBackend] ❌ Tool call ${status} but no error details in update.content`); } // Emit tool-result with error information so user can see what went wrong @@ -973,8 +972,8 @@ export class AcpSdkBackend implements AgentBackend { clearTimeout(this.idleTimeout); this.idleTimeout = null; } - logger.debug('[AcpSdkBackend] All tool calls completed/failed, emitting idle status'); - this.emit({ type: 'status', status: 'idle' }); + logger.debug('[AcpBackend] All tool calls completed/failed, emitting idle status'); + this.emitIdleStatus(); } } } @@ -1013,7 +1012,7 @@ export class AcpSdkBackend implements AgentBackend { if (hasActiveInvestigation && this.activeToolCalls.size > 0) { const activeToolCallsList = Array.from(this.activeToolCalls); - logger.debug(`[AcpSdkBackend] 💭 Thinking chunk received (${text.length} chars) during active tool calls: ${activeToolCallsList.join(', ')}`); + logger.debug(`[AcpBackend] 💭 Thinking chunk received (${text.length} chars) during active tool calls: ${activeToolCallsList.join(', ')}`); } // Emit as thinking event - don't show as regular message @@ -1030,7 +1029,7 @@ export class AcpSdkBackend implements AgentBackend { const toolCallId = update.toolCallId; const status = update.status; - logger.debug(`[AcpSdkBackend] Received tool_call: toolCallId=${toolCallId}, status=${status}, kind=${update.kind}`); + logger.debug(`[AcpBackend] Received tool_call: toolCallId=${toolCallId}, status=${status}, kind=${update.kind}`); // tool_call can come without explicit status, assume 'in_progress' if status is missing const isInProgress = !status || status === 'in_progress' || status === 'pending'; @@ -1042,8 +1041,8 @@ export class AcpSdkBackend implements AgentBackend { const startTime = Date.now(); this.activeToolCalls.add(toolCallId); this.toolCallStartTimes.set(toolCallId, startTime); - logger.debug(`[AcpSdkBackend] Added tool call ${toolCallId} to active set. Total active: ${this.activeToolCalls.size}`); - logger.debug(`[AcpSdkBackend] ⏱️ Set startTime for ${toolCallId} at ${new Date(startTime).toISOString()}`); + logger.debug(`[AcpBackend] Added tool call ${toolCallId} to active set. Total active: ${this.activeToolCalls.size}`); + logger.debug(`[AcpBackend] ⏱️ Set startTime for ${toolCallId} at ${new Date(startTime).toISOString()}`); // Clear idle timeout - tool call is starting, agent is working if (this.idleTimeout) { @@ -1055,13 +1054,14 @@ export class AcpSdkBackend implements AgentBackend { // Think tools typically complete quickly, but we set a longer timeout for other tools // codebase_investigator and similar investigation tools can take 5+ minutes, so we use a much longer timeout // NOTE: update.kind may be "think" even for codebase_investigator, so we check toolCallId instead - const isInvestigation = isInvestigationTool(toolCallId, update.kind); - + const toolKindStr = typeof update.kind === 'string' ? update.kind : undefined; + const isInvestigation = this.transport.isInvestigationTool?.(toolCallId, toolKindStr) ?? false; + if (isInvestigation) { - logger.debug(`[AcpSdkBackend] 🔍 Investigation tool detected (toolCallId: ${toolCallId}, kind: ${update.kind}) - using extended timeout (10min)`); + logger.debug(`[AcpBackend] 🔍 Investigation tool detected (toolCallId: ${toolCallId}, kind: ${update.kind}) - using extended timeout (10min)`); } - - const timeoutMs = getToolCallTimeout(toolCallId, update.kind); + + const timeoutMs = this.transport.getToolCallTimeout?.(toolCallId, toolKindStr) ?? 120000; // Only set timeout if not already set (from tool_call_update) if (!this.toolCallTimeouts.has(toolCallId)) { @@ -1070,22 +1070,22 @@ export class AcpSdkBackend implements AgentBackend { const duration = startTime ? Date.now() - startTime : null; const durationStr = duration ? `${(duration / 1000).toFixed(2)}s` : 'unknown'; - logger.debug(`[AcpSdkBackend] ⏱️ Tool call TIMEOUT (from tool_call): ${toolCallId} (${update.kind}) after ${(timeoutMs / 1000).toFixed(0)}s - Duration: ${durationStr}, removing from active set`); + logger.debug(`[AcpBackend] ⏱️ Tool call TIMEOUT (from tool_call): ${toolCallId} (${update.kind}) after ${(timeoutMs / 1000).toFixed(0)}s - Duration: ${durationStr}, removing from active set`); this.activeToolCalls.delete(toolCallId); this.toolCallStartTimes.delete(toolCallId); this.toolCallTimeouts.delete(toolCallId); // Check if we should emit idle status if (this.activeToolCalls.size === 0) { - logger.debug('[AcpSdkBackend] No more active tool calls after timeout, emitting idle status'); - this.emit({ type: 'status', status: 'idle' }); + logger.debug('[AcpBackend] No more active tool calls after timeout, emitting idle status'); + this.emitIdleStatus(); } }, timeoutMs); this.toolCallTimeouts.set(toolCallId, timeout); - logger.debug(`[AcpSdkBackend] ⏱️ Set timeout for ${toolCallId}: ${(timeoutMs / 1000).toFixed(0)}s${isInvestigation ? ' (investigation tool)' : ''}`); + logger.debug(`[AcpBackend] ⏱️ Set timeout for ${toolCallId}: ${(timeoutMs / 1000).toFixed(0)}s${isInvestigation ? ' (investigation tool)' : ''}`); } else { - logger.debug(`[AcpSdkBackend] Timeout already set for ${toolCallId}, skipping`); + logger.debug(`[AcpBackend] Timeout already set for ${toolCallId}, skipping`); } // Emit running status when tool call starts @@ -1104,7 +1104,7 @@ export class AcpSdkBackend implements AgentBackend { args.locations = update.locations; } - logger.debug(`[AcpSdkBackend] Emitting tool-call event: toolName=${update.kind}, toolCallId=${toolCallId}, args=`, JSON.stringify(args)); + logger.debug(`[AcpBackend] Emitting tool-call event: toolName=${update.kind}, toolCallId=${toolCallId}, args=`, JSON.stringify(args)); this.emit({ type: 'tool-call', @@ -1113,10 +1113,10 @@ export class AcpSdkBackend implements AgentBackend { callId: toolCallId, }); } else { - logger.debug(`[AcpSdkBackend] Tool call ${toolCallId} already in active set, skipping`); + logger.debug(`[AcpBackend] Tool call ${toolCallId} already in active set, skipping`); } } else { - logger.debug(`[AcpSdkBackend] Tool call ${toolCallId} not in progress (status: ${status}), skipping`); + logger.debug(`[AcpBackend] Tool call ${toolCallId} not in progress (status: ${status}), skipping`); } } @@ -1139,20 +1139,24 @@ export class AcpSdkBackend implements AgentBackend { !update.messageChunk && !update.plan && !update.thinking) { - logger.debug(`[AcpSdkBackend] Unhandled session update type: ${sessionUpdateType}`, JSON.stringify(update, null, 2)); + logger.debug(`[AcpBackend] Unhandled session update type: ${sessionUpdateType}`, JSON.stringify(update, null, 2)); } } + // Promise resolver for waitForIdle - set when waiting for response to complete + private idleResolver: (() => void) | null = null; + private waitingForResponse = false; + async sendPrompt(sessionId: SessionId, prompt: string): Promise { - // Check if prompt contains change_title instruction - const promptHasChangeTitle = hasChangeTitleInstruction(prompt); - + // Check if prompt contains change_title instruction (via optional callback) + const promptHasChangeTitle = this.options.hasChangeTitleInstruction?.(prompt) ?? false; + // Reset tool call counter and set flag this.toolCallCountSincePrompt = 0; this.recentPromptHadChangeTitle = promptHasChangeTitle; if (promptHasChangeTitle) { - logger.debug('[AcpSdkBackend] Prompt contains change_title instruction - will auto-approve first "other" tool call if it matches pattern'); + logger.debug('[AcpBackend] Prompt contains change_title instruction - will auto-approve first "other" tool call if it matches pattern'); } if (this.disposed) { throw new Error('Backend has been disposed'); @@ -1163,10 +1167,11 @@ export class AcpSdkBackend implements AgentBackend { } this.emit({ type: 'status', status: 'running' }); + this.waitingForResponse = true; try { - logger.debug(`[AcpSdkBackend] Sending prompt (length: ${prompt.length}): ${prompt.substring(0, 100)}...`); - logger.debug(`[AcpSdkBackend] Full prompt: ${prompt}`); + logger.debug(`[AcpBackend] Sending prompt (length: ${prompt.length}): ${prompt.substring(0, 100)}...`); + logger.debug(`[AcpBackend] Full prompt: ${prompt}`); const contentBlock: ContentBlock = { type: 'text', @@ -1178,15 +1183,16 @@ export class AcpSdkBackend implements AgentBackend { prompt: [contentBlock], }; - logger.debug(`[AcpSdkBackend] Prompt request:`, JSON.stringify(promptRequest, null, 2)); + logger.debug(`[AcpBackend] Prompt request:`, JSON.stringify(promptRequest, null, 2)); await this.connection.prompt(promptRequest); - logger.debug('[AcpSdkBackend] Prompt request sent to ACP connection'); + logger.debug('[AcpBackend] Prompt request sent to ACP connection'); // Don't emit 'idle' here - it will be emitted after all message chunks are received // The idle timeout in handleSessionUpdate will emit 'idle' after the last chunk } catch (error) { - logger.debug('[AcpSdkBackend] Error sending prompt:', error); + logger.debug('[AcpBackend] Error sending prompt:', error); + this.waitingForResponse = false; // Extract error details for better error handling let errorDetail: string; @@ -1216,6 +1222,43 @@ export class AcpSdkBackend implements AgentBackend { } } + /** + * Wait for the response to complete (idle status after all chunks received) + * Call this after sendPrompt to wait for Gemini to finish responding + */ + async waitForResponseComplete(timeoutMs: number = 120000): Promise { + if (!this.waitingForResponse) { + return; // Already completed or no prompt sent + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.idleResolver = null; + this.waitingForResponse = false; + reject(new Error('Timeout waiting for response to complete')); + }, timeoutMs); + + this.idleResolver = () => { + clearTimeout(timeout); + this.idleResolver = null; + this.waitingForResponse = false; + resolve(); + }; + }); + } + + /** + * Helper to emit idle status and resolve any waiting promises + */ + private emitIdleStatus(): void { + this.emit({ type: 'status', status: 'idle' }); + // Resolve any waiting promises + if (this.idleResolver) { + logger.debug('[AcpBackend] Resolving idle waiter'); + this.idleResolver(); + } + } + async cancel(sessionId: SessionId): Promise { if (!this.connection || !this.acpSessionId) { return; @@ -1226,20 +1269,22 @@ export class AcpSdkBackend implements AgentBackend { this.emit({ type: 'status', status: 'stopped', detail: 'Cancelled by user' }); } catch (error) { // Log to file only, not console - logger.debug('[AcpSdkBackend] Error cancelling:', error); + logger.debug('[AcpBackend] Error cancelling:', error); } } async respondToPermission(requestId: string, approved: boolean): Promise { - logger.debug(`[AcpSdkBackend] Permission response: ${requestId} = ${approved}`); + logger.debug(`[AcpBackend] Permission response: ${requestId} = ${approved}`); this.emit({ type: 'permission-response', id: requestId, approved }); - // TODO: Implement actual permission response when needed + // IMPORTANT: The actual ACP permission response is handled synchronously + // within the `requestPermission` method via `this.options.permissionHandler`. + // This method only emits an internal event for other parts of the CLI to react to. } async dispose(): Promise { if (this.disposed) return; - logger.debug('[AcpSdkBackend] Disposing backend'); + logger.debug('[AcpBackend] Disposing backend'); this.disposed = true; // Try graceful shutdown first @@ -1251,7 +1296,7 @@ export class AcpSdkBackend implements AgentBackend { new Promise((resolve) => setTimeout(resolve, 2000)), // 2s timeout for graceful shutdown ]); } catch (error) { - logger.debug('[AcpSdkBackend] Error during graceful shutdown:', error); + logger.debug('[AcpBackend] Error during graceful shutdown:', error); } } @@ -1264,7 +1309,7 @@ export class AcpSdkBackend implements AgentBackend { await new Promise((resolve) => { const timeout = setTimeout(() => { if (this.process) { - logger.debug('[AcpSdkBackend] Force killing process'); + logger.debug('[AcpBackend] Force killing process'); this.process.kill('SIGKILL'); } resolve(); diff --git a/src/agent/acp/createAcpBackend.ts b/src/agent/acp/createAcpBackend.ts new file mode 100644 index 00000000..a8b00007 --- /dev/null +++ b/src/agent/acp/createAcpBackend.ts @@ -0,0 +1,86 @@ +/** + * ACP Backend Factory Helper + * + * Provides a simplified factory function for creating ACP-based agent backends. + * Use this when you need to create a generic ACP backend without agent-specific + * configuration (timeouts, filtering, etc.). + * + * For agent-specific backends, use the factories in src/agent/factories/: + * - createGeminiBackend() - Gemini CLI with GeminiTransport + * - createCodexBackend() - Codex CLI with CodexTransport + * - createClaudeBackend() - Claude CLI with ClaudeTransport + * + * @module createAcpBackend + */ + +import { AcpBackend, type AcpBackendOptions, type AcpPermissionHandler } from './AcpBackend'; +import type { AgentBackend, McpServerConfig } from '../core'; +import { DefaultTransport, type TransportHandler } from '../transport'; + +/** + * Simplified options for creating an ACP backend + */ +export interface CreateAcpBackendOptions { + /** Agent name for identification */ + agentName: string; + + /** Working directory for the agent */ + cwd: string; + + /** Command to spawn the ACP agent */ + command: string; + + /** Arguments for the agent command */ + args?: string[]; + + /** Environment variables to pass to the agent */ + env?: Record; + + /** MCP servers to make available to the agent */ + mcpServers?: Record; + + /** Optional permission handler for tool approval */ + permissionHandler?: AcpPermissionHandler; + + /** Optional transport handler for agent-specific behavior */ + transportHandler?: TransportHandler; +} + +/** + * Create a generic ACP backend. + * + * This is a low-level factory for creating ACP backends. For most use cases, + * prefer the agent-specific factories that include proper transport handlers: + * + * ```typescript + * // Prefer this: + * import { createGeminiBackend } from '@/agent/factories'; + * const backend = createGeminiBackend({ cwd: '/path/to/project' }); + * + * // Over this: + * import { createAcpBackend } from '@/agent/acp'; + * const backend = createAcpBackend({ + * agentName: 'gemini', + * cwd: '/path/to/project', + * command: 'gemini', + * args: ['--experimental-acp'], + * }); + * ``` + * + * @param options - Configuration options + * @returns AgentBackend instance + */ +export function createAcpBackend(options: CreateAcpBackendOptions): AgentBackend { + const backendOptions: AcpBackendOptions = { + agentName: options.agentName, + cwd: options.cwd, + command: options.command, + args: options.args, + env: options.env, + mcpServers: options.mcpServers, + permissionHandler: options.permissionHandler, + transportHandler: options.transportHandler ?? new DefaultTransport(options.agentName), + }; + + return new AcpBackend(backendOptions); +} diff --git a/src/agent/acp/index.ts b/src/agent/acp/index.ts index 1b44cd26..aeb49b79 100644 --- a/src/agent/acp/index.ts +++ b/src/agent/acp/index.ts @@ -1,12 +1,21 @@ /** * ACP Module - Agent Client Protocol implementations - * + * * This module exports all ACP-related functionality including - * the base AcpSdkBackend and agent-specific implementations. - * + * the base AcpBackend and factory helpers. + * * Uses the official @agentclientprotocol/sdk from Zed Industries. + * + * For agent-specific backends, use the factories in src/agent/factories/. */ -export { AcpSdkBackend, type AcpSdkBackendOptions } from './AcpSdkBackend'; -export { createGeminiBackend, registerGeminiAgent, type GeminiBackendOptions } from './gemini'; +// Core ACP backend +export { AcpBackend, type AcpBackendOptions, type AcpPermissionHandler } from './AcpBackend'; + +// Factory helper for generic ACP backends +export { createAcpBackend, type CreateAcpBackendOptions } from './createAcpBackend'; + +// Legacy aliases for backwards compatibility +export { AcpBackend as AcpSdkBackend } from './AcpBackend'; +export type { AcpBackendOptions as AcpSdkBackendOptions } from './AcpBackend'; diff --git a/src/agent/acp/utils.ts b/src/agent/acp/utils.ts deleted file mode 100644 index aa466e3c..00000000 --- a/src/agent/acp/utils.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * ACP Backend Utilities - * - * Utility functions for working with ACP tool calls, tool names, and timeouts. - */ - -/** - * Known tool name patterns that can be extracted from toolCallId - */ -const KNOWN_TOOL_PATTERNS = { - change_title: ['change_title', 'change-title', 'happy__change_title'], - save_memory: ['save_memory', 'save-memory'], - think: ['think'], -} as const; - -/** - * Check if a tool is an investigation tool based on toolCallId and toolKind - * - * @param toolCallId - The tool call ID - * @param toolKind - The tool kind/type - * @returns true if this is an investigation tool - */ -export function isInvestigationTool(toolCallId: string, toolKind?: string | unknown): boolean { - return toolCallId.includes('codebase_investigator') || - toolCallId.includes('investigator') || - (typeof toolKind === 'string' && toolKind.includes('investigator')); -} - -/** - * Extract tool name from toolCallId - * - * Tool IDs often contain the tool name as a prefix (e.g., "change_title-1765385846663") - * - * @param toolCallId - The tool call ID - * @returns The extracted tool name, or null if not found - */ -export function extractToolNameFromId(toolCallId: string): string | null { - const lowerId = toolCallId.toLowerCase(); - - for (const [toolName, patterns] of Object.entries(KNOWN_TOOL_PATTERNS)) { - for (const pattern of patterns) { - if (lowerId.includes(pattern.toLowerCase())) { - return toolName; - } - } - } - - return null; -} - -/** - * Determine the real tool name from various sources - * - * When ACP sends "other" or "Unknown tool", we try to determine the real name from: - * 1. toolCallId (most reliable) - * 2. input parameters - * 3. params structure - * 4. Context (first tool call after change_title instruction) - * - * @param toolName - The initial tool name (may be "other" or "Unknown tool") - * @param toolCallId - The tool call ID - * @param input - The input parameters - * @param params - The full params object - * @param context - Context information (recent prompt had change_title, tool call count) - * @returns The determined tool name - */ -export function determineToolName( - toolName: string, - toolCallId: string, - input: Record, - params: unknown, - context?: { - recentPromptHadChangeTitle?: boolean; - toolCallCountSincePrompt?: number; - } -): string { - // If tool name is already known, return it - if (toolName !== 'other' && toolName !== 'Unknown tool') { - return toolName; - } - - // 1. Check toolCallId for known tool names (most reliable) - const idToolName = extractToolNameFromId(toolCallId); - if (idToolName) { - return idToolName; - } - - // 2. Check input for function names or tool identifiers - if (input && typeof input === 'object') { - const inputStr = JSON.stringify(input).toLowerCase(); - for (const [toolName, patterns] of Object.entries(KNOWN_TOOL_PATTERNS)) { - for (const pattern of patterns) { - if (inputStr.includes(pattern.toLowerCase())) { - return toolName; - } - } - } - } - - // 3. Check params for additional clues - const paramsStr = JSON.stringify(params).toLowerCase(); - for (const [toolName, patterns] of Object.entries(KNOWN_TOOL_PATTERNS)) { - for (const pattern of patterns) { - if (paramsStr.includes(pattern.toLowerCase())) { - return toolName; - } - } - } - - // 4. Context-based heuristic: if this is the first tool call after a prompt with change_title instruction - // AND input is empty/minimal, it's likely change_title - if (context?.recentPromptHadChangeTitle && context.toolCallCountSincePrompt === 0) { - const isEmptyInput = !input || - (Array.isArray(input) && input.length === 0) || - (typeof input === 'object' && Object.keys(input).length === 0); - - if (isEmptyInput && toolName === 'other') { - return 'change_title'; - } - } - - // Return original tool name if we couldn't determine it - return toolName; -} - -/** - * Get the real tool name from toolCallId, falling back to toolKind - * - * @param toolCallId - The tool call ID - * @param toolKind - The tool kind/type - * @returns The real tool name - */ -export function getRealToolName(toolCallId: string, toolKind: string | unknown): string { - const extracted = extractToolNameFromId(toolCallId); - if (extracted) { - return extracted; - } - return typeof toolKind === 'string' ? toolKind : 'unknown'; -} - -/** - * Get timeout for a tool call based on its type - * - * @param toolCallId - The tool call ID - * @param toolKind - The tool kind/type - * @returns Timeout in milliseconds - */ -export function getToolCallTimeout(toolCallId: string, toolKind: string | unknown): number { - const isInvestigation = isInvestigationTool(toolCallId, toolKind); - const isThinkTool = toolKind === 'think'; - - if (isInvestigation) { - return 600000; // 10 minutes for investigation tools (like codebase_investigator) - } else if (isThinkTool) { - return 30000; // 30s for regular think tools - } else { - return 120000; // 2min for other tools - } -} - diff --git a/src/agent/adapters/MessageAdapter.ts b/src/agent/adapters/MessageAdapter.ts new file mode 100644 index 00000000..17928c2e --- /dev/null +++ b/src/agent/adapters/MessageAdapter.ts @@ -0,0 +1,284 @@ +/** + * MessageAdapter - Transforms agent messages to mobile format + * + * This module provides the transformation layer between internal + * AgentMessage format and the format expected by the mobile app. + * + * The adapter: + * - Normalizes messages from different agents into a consistent format + * - Handles agent-specific message variations + * - Provides type-safe transformations + * + * @module MessageAdapter + */ + +import type { + AgentMessage, + ModelOutputMessage, + StatusMessage, + ToolCallMessage, + ToolResultMessage, + PermissionRequestMessage, + PermissionResponseMessage, + FsEditMessage, + TerminalOutputMessage, + EventMessage, +} from '../core/AgentMessage'; + +import type { + MobileAgentType, + MobileAgentMessage, + MobileMessageMeta, + NormalizedMobilePayload, +} from './MobileMessageFormat'; + +/** + * Configuration for MessageAdapter + */ +export interface MessageAdapterConfig { + /** Agent type for message wrapping */ + agentType: MobileAgentType; + /** Include raw message in payload for debugging */ + includeRaw?: boolean; +} + +/** + * MessageAdapter - Transforms AgentMessage to mobile format + * + * Usage: + * ```typescript + * const adapter = new MessageAdapter({ agentType: 'gemini' }); + * const mobileMsg = adapter.toMobile(agentMessage); + * apiSession.sendAgentMessage(adapter.agentType, mobileMsg.content.data); + * ``` + */ +export class MessageAdapter { + private readonly config: MessageAdapterConfig; + + constructor(config: MessageAdapterConfig) { + this.config = config; + } + + get agentType(): MobileAgentType { + return this.config.agentType; + } + + /** + * Transform an AgentMessage to mobile format + */ + toMobile(msg: AgentMessage): MobileAgentMessage { + const payload = this.normalize(msg); + + return { + role: 'agent', + content: { + type: this.config.agentType, + data: payload, + }, + meta: this.createMeta(), + }; + } + + /** + * Normalize an AgentMessage to a consistent payload format + */ + normalize(msg: AgentMessage): NormalizedMobilePayload { + const base: NormalizedMobilePayload = { + type: msg.type, + ...(this.config.includeRaw ? { _raw: msg } : {}), + }; + + switch (msg.type) { + case 'model-output': + return this.normalizeModelOutput(msg, base); + + case 'status': + return this.normalizeStatus(msg, base); + + case 'tool-call': + return this.normalizeToolCall(msg, base); + + case 'tool-result': + return this.normalizeToolResult(msg, base); + + case 'permission-request': + return this.normalizePermissionRequest(msg, base); + + case 'permission-response': + return this.normalizePermissionResponse(msg, base); + + case 'fs-edit': + return this.normalizeFsEdit(msg, base); + + case 'terminal-output': + return this.normalizeTerminalOutput(msg, base); + + case 'event': + return this.normalizeEvent(msg, base); + + case 'token-count': + return { ...base, tokenCount: msg }; + + case 'exec-approval-request': + return { + ...base, + toolCallId: msg.call_id, + toolName: 'exec', + toolArgs: msg as Record, + }; + + case 'patch-apply-begin': + return { + ...base, + toolCallId: msg.call_id, + toolName: 'patch', + toolArgs: { changes: msg.changes, autoApproved: msg.auto_approved }, + }; + + case 'patch-apply-end': + return { + ...base, + toolCallId: msg.call_id, + toolResult: { + success: msg.success, + stdout: msg.stdout, + stderr: msg.stderr, + }, + }; + + default: + // Forward unknown types as-is + return { ...base, eventPayload: msg }; + } + } + + private normalizeModelOutput( + msg: ModelOutputMessage, + base: NormalizedMobilePayload + ): NormalizedMobilePayload { + return { + ...base, + text: msg.textDelta ?? msg.fullText, + }; + } + + private normalizeStatus( + msg: StatusMessage, + base: NormalizedMobilePayload + ): NormalizedMobilePayload { + return { + ...base, + status: msg.status, + statusDetail: msg.detail, + }; + } + + private normalizeToolCall( + msg: ToolCallMessage, + base: NormalizedMobilePayload + ): NormalizedMobilePayload { + return { + ...base, + toolName: msg.toolName, + toolArgs: msg.args, + toolCallId: msg.callId, + }; + } + + private normalizeToolResult( + msg: ToolResultMessage, + base: NormalizedMobilePayload + ): NormalizedMobilePayload { + return { + ...base, + toolName: msg.toolName, + toolResult: msg.result, + toolCallId: msg.callId, + }; + } + + private normalizePermissionRequest( + msg: PermissionRequestMessage, + base: NormalizedMobilePayload + ): NormalizedMobilePayload { + return { + ...base, + permissionId: msg.id, + permissionReason: msg.reason, + permissionPayload: msg.payload, + }; + } + + private normalizePermissionResponse( + msg: PermissionResponseMessage, + base: NormalizedMobilePayload + ): NormalizedMobilePayload { + return { + ...base, + permissionId: msg.id, + permissionApproved: msg.approved, + }; + } + + private normalizeFsEdit( + msg: FsEditMessage, + base: NormalizedMobilePayload + ): NormalizedMobilePayload { + return { + ...base, + editDescription: msg.description, + editDiff: msg.diff, + editPath: msg.path, + }; + } + + private normalizeTerminalOutput( + msg: TerminalOutputMessage, + base: NormalizedMobilePayload + ): NormalizedMobilePayload { + return { + ...base, + terminalData: msg.data, + }; + } + + private normalizeEvent( + msg: EventMessage, + base: NormalizedMobilePayload + ): NormalizedMobilePayload { + return { + ...base, + eventName: msg.name, + eventPayload: msg.payload, + }; + } + + private createMeta(): MobileMessageMeta { + return { + sentFrom: 'cli', + }; + } +} + +/** + * Create a MessageAdapter for a specific agent type + */ +export function createMessageAdapter( + agentType: MobileAgentType, + options?: Partial> +): MessageAdapter { + return new MessageAdapter({ + agentType, + ...options, + }); +} + +/** + * Pre-configured adapters for common agents + */ +export const adapters = { + gemini: new MessageAdapter({ agentType: 'gemini' }), + codex: new MessageAdapter({ agentType: 'codex' }), + claude: new MessageAdapter({ agentType: 'claude' }), + opencode: new MessageAdapter({ agentType: 'opencode' }), +} as const; diff --git a/src/agent/adapters/MobileMessageFormat.ts b/src/agent/adapters/MobileMessageFormat.ts new file mode 100644 index 00000000..b24ee04d --- /dev/null +++ b/src/agent/adapters/MobileMessageFormat.ts @@ -0,0 +1,150 @@ +/** + * MobileMessageFormat - Types for messages sent to the mobile app + * + * This module defines the message format expected by the Happy mobile app. + * Messages from any agent (Gemini, Codex, Claude, etc.) are transformed + * to this format before being sent through the Happy server. + * + * @module MobileMessageFormat + */ + +/** + * Supported agent types for the mobile app + */ +export type MobileAgentType = 'gemini' | 'codex' | 'claude' | 'opencode'; + +/** + * Message roles for the mobile app + */ +export type MobileMessageRole = 'user' | 'agent'; + +/** + * Message metadata sent with each message + */ +export interface MobileMessageMeta { + /** Source of the message (usually 'cli') */ + sentFrom: string; + /** Permission mode context */ + permissionMode?: string; + /** Model name if applicable */ + model?: string | null; +} + +/** + * User message content (from mobile app to CLI) + */ +export interface MobileUserContent { + type: 'text'; + text: string; +} + +/** + * User message format + */ +export interface MobileUserMessage { + role: 'user'; + content: MobileUserContent; + localKey?: string; + meta?: MobileMessageMeta; +} + +/** + * Agent content types for different agents + */ +export interface MobileAgentContent { + /** Agent type identifier */ + type: MobileAgentType; + /** The actual message payload */ + data: T; +} + +/** + * Agent message format (from CLI to mobile app) + */ +export interface MobileAgentMessage { + role: 'agent'; + content: MobileAgentContent; + meta?: MobileMessageMeta; +} + +/** + * Event content for session events + */ +export interface MobileEventContent { + id: string; + type: 'event'; + data: MobileSessionEvent; +} + +/** + * Session event types + */ +export type MobileSessionEvent = + | { type: 'switch'; mode: 'local' | 'remote' } + | { type: 'message'; message: string } + | { type: 'permission-mode-changed'; mode: string } + | { type: 'ready' }; + +/** + * Event message format + */ +export interface MobileEventMessage { + role: 'agent'; + content: MobileEventContent; +} + +/** + * Union of all mobile message types + */ +export type MobileMessage = + | MobileUserMessage + | MobileAgentMessage + | MobileEventMessage; + +/** + * Normalized payload format for mobile app + * + * This is the standardized format that all agent messages + * are transformed into before sending to the mobile app. + */ +export interface NormalizedMobilePayload { + /** Message type (matches AgentMessage.type) */ + type: string; + + /** Text content for model output */ + text?: string; + + /** Status value for status messages */ + status?: string; + statusDetail?: string; + + /** Tool information for tool calls/results */ + toolName?: string; + toolArgs?: Record; + toolCallId?: string; + toolResult?: unknown; + + /** Permission information */ + permissionId?: string; + permissionReason?: string; + permissionPayload?: unknown; + permissionApproved?: boolean; + + /** File edit information */ + editDescription?: string; + editDiff?: string; + editPath?: string; + + /** Terminal output */ + terminalData?: string; + + /** Generic event data */ + eventName?: string; + eventPayload?: unknown; + + /** Token count data */ + tokenCount?: Record; + + /** Raw original message for debugging */ + _raw?: unknown; +} diff --git a/src/agent/adapters/index.ts b/src/agent/adapters/index.ts new file mode 100644 index 00000000..c710c38d --- /dev/null +++ b/src/agent/adapters/index.ts @@ -0,0 +1,27 @@ +/** + * Message Adapters + * + * Transforms agent messages to different formats. + * + * @module adapters + */ + +// Mobile format types +export type { + MobileAgentType, + MobileMessageRole, + MobileMessageMeta, + MobileUserContent, + MobileUserMessage, + MobileAgentContent, + MobileAgentMessage, + MobileEventContent, + MobileSessionEvent, + MobileEventMessage, + MobileMessage, + NormalizedMobilePayload, +} from './MobileMessageFormat'; + +// Message adapter +export type { MessageAdapterConfig } from './MessageAdapter'; +export { MessageAdapter, createMessageAdapter, adapters } from './MessageAdapter'; diff --git a/src/agent/AgentBackend.ts b/src/agent/core/AgentBackend.ts similarity index 94% rename from src/agent/AgentBackend.ts rename to src/agent/core/AgentBackend.ts index 33a32814..f71868a7 100644 --- a/src/agent/AgentBackend.ts +++ b/src/agent/core/AgentBackend.ts @@ -147,6 +147,14 @@ export interface AgentBackend { */ respondToPermission?(requestId: string, approved: boolean): Promise; + /** + * Wait for the current response to complete. + * Call this after sendPrompt to wait for all chunks to be received. + * + * @param timeoutMs - Maximum time to wait in milliseconds (default: 120000) + */ + waitForResponseComplete?(timeoutMs?: number): Promise; + /** * Clean up resources and close the backend. */ diff --git a/src/agent/core/AgentMessage.ts b/src/agent/core/AgentMessage.ts new file mode 100644 index 00000000..473ac831 --- /dev/null +++ b/src/agent/core/AgentMessage.ts @@ -0,0 +1,217 @@ +/** + * AgentMessage - Universal message types for agent communication + * + * This module defines the message types that flow between: + * - Agent backends (Gemini, Codex, Claude, etc.) + * - Happy CLI + * - Mobile app (via Happy server) + * + * These types are backend-agnostic and work with any agent that + * implements the AgentBackend interface. + * + * @module AgentMessage + */ + +/** Unique identifier for an agent session */ +export type SessionId = string; + +/** Unique identifier for a tool call */ +export type ToolCallId = string; + +/** + * Agent status values + */ +export type AgentStatus = 'starting' | 'running' | 'idle' | 'stopped' | 'error'; + +/** + * Model output message - text chunks from the agent + */ +export interface ModelOutputMessage { + type: 'model-output'; + /** Incremental text delta (streaming) */ + textDelta?: string; + /** Full text (when not streaming) */ + fullText?: string; +} + +/** + * Status message - agent lifecycle state + */ +export interface StatusMessage { + type: 'status'; + status: AgentStatus; + /** Additional details (e.g., error message) */ + detail?: string; +} + +/** + * Tool call message - agent is calling a tool + */ +export interface ToolCallMessage { + type: 'tool-call'; + toolName: string; + args: Record; + callId: ToolCallId; +} + +/** + * Tool result message - result from a tool call + */ +export interface ToolResultMessage { + type: 'tool-result'; + toolName: string; + result: unknown; + callId: ToolCallId; +} + +/** + * Permission request message - agent needs user approval + */ +export interface PermissionRequestMessage { + type: 'permission-request'; + id: string; + reason: string; + payload: unknown; +} + +/** + * Permission response message - user's decision + */ +export interface PermissionResponseMessage { + type: 'permission-response'; + id: string; + approved: boolean; +} + +/** + * File system edit message - agent modified a file + */ +export interface FsEditMessage { + type: 'fs-edit'; + description: string; + diff?: string; + path?: string; +} + +/** + * Terminal output message - output from terminal commands + */ +export interface TerminalOutputMessage { + type: 'terminal-output'; + data: string; +} + +/** + * Generic event message - extensible for agent-specific events + */ +export interface EventMessage { + type: 'event'; + name: string; + payload: unknown; +} + +/** + * Token count message - usage information + */ +export interface TokenCountMessage { + type: 'token-count'; + [key: string]: unknown; +} + +/** + * Exec approval request message (Codex-style) + */ +export interface ExecApprovalRequestMessage { + type: 'exec-approval-request'; + call_id: string; + [key: string]: unknown; +} + +/** + * Patch apply begin message (Codex-style) + */ +export interface PatchApplyBeginMessage { + type: 'patch-apply-begin'; + call_id: string; + auto_approved?: boolean; + changes: Record; +} + +/** + * Patch apply end message (Codex-style) + */ +export interface PatchApplyEndMessage { + type: 'patch-apply-end'; + call_id: string; + stdout?: string; + stderr?: string; + success: boolean; +} + +/** + * Union type of all agent messages. + * + * These messages are emitted by agent backends and forwarded + * to the Happy server and mobile app. + */ +export type AgentMessage = + | ModelOutputMessage + | StatusMessage + | ToolCallMessage + | ToolResultMessage + | PermissionRequestMessage + | PermissionResponseMessage + | FsEditMessage + | TerminalOutputMessage + | EventMessage + | TokenCountMessage + | ExecApprovalRequestMessage + | PatchApplyBeginMessage + | PatchApplyEndMessage; + +/** + * Handler function type for agent messages + */ +export type AgentMessageHandler = (msg: AgentMessage) => void; + +/** + * Type guard for model output messages + */ +export function isModelOutputMessage(msg: AgentMessage): msg is ModelOutputMessage { + return msg.type === 'model-output'; +} + +/** + * Type guard for status messages + */ +export function isStatusMessage(msg: AgentMessage): msg is StatusMessage { + return msg.type === 'status'; +} + +/** + * Type guard for tool call messages + */ +export function isToolCallMessage(msg: AgentMessage): msg is ToolCallMessage { + return msg.type === 'tool-call'; +} + +/** + * Type guard for tool result messages + */ +export function isToolResultMessage(msg: AgentMessage): msg is ToolResultMessage { + return msg.type === 'tool-result'; +} + +/** + * Type guard for permission request messages + */ +export function isPermissionRequestMessage(msg: AgentMessage): msg is PermissionRequestMessage { + return msg.type === 'permission-request'; +} + +/** + * Extract text content from a model output message + */ +export function getMessageText(msg: ModelOutputMessage): string { + return msg.textDelta ?? msg.fullText ?? ''; +} diff --git a/src/agent/AgentRegistry.ts b/src/agent/core/AgentRegistry.ts similarity index 100% rename from src/agent/AgentRegistry.ts rename to src/agent/core/AgentRegistry.ts diff --git a/src/agent/core/index.ts b/src/agent/core/index.ts new file mode 100644 index 00000000..434764d6 --- /dev/null +++ b/src/agent/core/index.ts @@ -0,0 +1,69 @@ +/** + * Core Agent Types and Interfaces + * + * Re-exports all core agent abstractions. + * + * @module core + */ + +// ============================================================================ +// AgentBackend - Core interface and types +// ============================================================================ + +export type { + SessionId, + ToolCallId, + AgentMessage, + AgentMessageHandler, + AgentBackend, + AgentBackendConfig, + AcpAgentConfig, + McpServerConfig, + AgentTransport, + AgentId, + StartSessionResult, +} from './AgentBackend'; + +// ============================================================================ +// AgentRegistry - Factory registry +// ============================================================================ + +export { + AgentRegistry, + agentRegistry, +} from './AgentRegistry'; + +export type { + AgentFactory, + AgentFactoryOptions, +} from './AgentRegistry'; + +// ============================================================================ +// AgentMessage - Detailed message types with type guards +// ============================================================================ + +export type { + AgentStatus, + ModelOutputMessage, + StatusMessage, + ToolCallMessage, + ToolResultMessage, + PermissionRequestMessage, + PermissionResponseMessage, + FsEditMessage, + TerminalOutputMessage, + EventMessage, + TokenCountMessage, + ExecApprovalRequestMessage, + PatchApplyBeginMessage, + PatchApplyEndMessage, +} from './AgentMessage'; + +export { + isModelOutputMessage, + isStatusMessage, + isToolCallMessage, + isToolResultMessage, + isPermissionRequestMessage, + getMessageText, +} from './AgentMessage'; diff --git a/src/agent/acp/gemini.ts b/src/agent/factories/gemini.ts similarity index 70% rename from src/agent/acp/gemini.ts rename to src/agent/factories/gemini.ts index 2b662ead..a7be3599 100644 --- a/src/agent/acp/gemini.ts +++ b/src/agent/factories/gemini.ts @@ -8,9 +8,10 @@ * the --experimental-acp flag for ACP mode. */ -import { AcpSdkBackend, type AcpSdkBackendOptions, type AcpPermissionHandler } from './AcpSdkBackend'; -import type { AgentBackend, McpServerConfig } from '../AgentBackend'; -import { agentRegistry, type AgentFactoryOptions } from '../AgentRegistry'; +import { AcpBackend, type AcpBackendOptions, type AcpPermissionHandler } from '../acp/AcpBackend'; +import type { AgentBackend, McpServerConfig, AgentFactoryOptions } from '../core'; +import { agentRegistry } from '../core'; +import { geminiTransport } from '../transport'; import { logger } from '@/ui/logger'; import { GEMINI_API_KEY_ENV, @@ -34,6 +35,9 @@ export interface GeminiBackendOptions extends AgentFactoryOptions { /** OAuth token from Happy cloud (via 'happy connect gemini') - highest priority */ cloudToken?: string; + /** Current user email (from OAuth id_token) - used to match per-account project ID */ + currentUserEmail?: string; + /** Model to use. If undefined, will use local config, env var, or default. * If explicitly set to null, will use default (skip local config). * (defaults to GEMINI_MODEL env var or 'gemini-2.5-pro') */ @@ -91,7 +95,23 @@ export function createGeminiBackend(options: GeminiBackendOptions): AgentBackend // We don't use --model flag to avoid potential stdout conflicts with ACP protocol const geminiArgs = ['--experimental-acp']; - const backendOptions: AcpSdkBackendOptions = { + // Get Google Cloud Project from local config (for Workspace accounts) + // Only use if: no email stored (global), or email matches current user + let googleCloudProject: string | null = null; + if (localConfig.googleCloudProject) { + const storedEmail = localConfig.googleCloudProjectEmail; + const currentEmail = options.currentUserEmail; + + // Use project if: no email stored (applies to all), or emails match + if (!storedEmail || storedEmail === currentEmail) { + googleCloudProject = localConfig.googleCloudProject; + logger.debug(`[Gemini] Using Google Cloud Project: ${googleCloudProject}${storedEmail ? ` (for ${storedEmail})` : ' (global)'}`); + } else { + logger.debug(`[Gemini] Skipping stored Google Cloud Project (stored for ${storedEmail}, current user is ${currentEmail || 'unknown'})`); + } + } + + const backendOptions: AcpBackendOptions = { agentName: 'gemini', cwd: options.cwd, command: geminiCommand, @@ -101,12 +121,26 @@ export function createGeminiBackend(options: GeminiBackendOptions): AgentBackend ...(apiKey ? { [GEMINI_API_KEY_ENV]: apiKey, [GOOGLE_API_KEY_ENV]: apiKey } : {}), // Pass model via env var - gemini CLI reads GEMINI_MODEL automatically [GEMINI_MODEL_ENV]: model, + // Pass Google Cloud Project for Workspace accounts + ...(googleCloudProject ? { + GOOGLE_CLOUD_PROJECT: googleCloudProject, + GOOGLE_CLOUD_PROJECT_ID: googleCloudProject, + } : {}), // Suppress debug output from gemini CLI to avoid stdout pollution NODE_ENV: 'production', DEBUG: '', }, mcpServers: options.mcpServers, permissionHandler: options.permissionHandler, + transportHandler: geminiTransport, + // Check if prompt instructs the agent to change title (for auto-approval of change_title tool) + hasChangeTitleInstruction: (prompt: string) => { + const lower = prompt.toLowerCase(); + return lower.includes('change_title') || + lower.includes('change title') || + lower.includes('set title') || + lower.includes('mcp__happy__change_title'); + }, }; // Determine model source for logging @@ -122,7 +156,7 @@ export function createGeminiBackend(options: GeminiBackendOptions): AgentBackend mcpServerCount: options.mcpServers ? Object.keys(options.mcpServers).length : 0, }); - return new AcpSdkBackend(backendOptions); + return new AcpBackend(backendOptions); } /** diff --git a/src/agent/factories/index.ts b/src/agent/factories/index.ts new file mode 100644 index 00000000..4b2af74a --- /dev/null +++ b/src/agent/factories/index.ts @@ -0,0 +1,20 @@ +/** + * Agent Factories + * + * Factory functions for creating agent backends with proper configuration. + * Each factory includes the appropriate transport handler for the agent. + * + * @module factories + */ + +// Gemini factory +export { + createGeminiBackend, + registerGeminiAgent, + type GeminiBackendOptions, +} from './gemini'; + +// Future factories: +// export { createCodexBackend, registerCodexAgent, type CodexBackendOptions } from './codex'; +// export { createClaudeBackend, registerClaudeAgent, type ClaudeBackendOptions } from './claude'; +// export { createOpenCodeBackend, registerOpenCodeAgent, type OpenCodeBackendOptions } from './opencode'; diff --git a/src/agent/index.ts b/src/agent/index.ts index d3f3f9bc..e350ad4f 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -1,12 +1,12 @@ /** * Agent Module - Universal agent backend abstraction - * + * * This module provides the core abstraction layer for different AI agents * (Claude, Codex, Gemini, OpenCode, etc.) that can be controlled through * the Happy CLI and mobile app. */ -// Core types and interfaces +// Core types, interfaces, and registry - re-export from core/ export type { AgentMessage, AgentMessageHandler, @@ -19,22 +19,26 @@ export type { SessionId, ToolCallId, StartSessionResult, -} from './AgentBackend'; + AgentFactory, + AgentFactoryOptions, +} from './core'; -// Registry -export { AgentRegistry, agentRegistry, type AgentFactory, type AgentFactoryOptions } from './AgentRegistry'; +export { AgentRegistry, agentRegistry } from './core'; -// ACP implementations +// ACP backend (low-level) export * from './acp'; +// Agent factories (high-level, recommended) +export * from './factories'; + /** * Initialize all agent backends and register them with the global registry. - * + * * Call this function during application startup to make all agents available. */ export function initializeAgents(): void { - // Import and register agents - const { registerGeminiAgent } = require('./acp/gemini'); + // Import and register agents from factories + const { registerGeminiAgent } = require('./factories/gemini'); registerGeminiAgent(); } diff --git a/src/agent/transport/DefaultTransport.ts b/src/agent/transport/DefaultTransport.ts new file mode 100644 index 00000000..a12dd0b6 --- /dev/null +++ b/src/agent/transport/DefaultTransport.ts @@ -0,0 +1,134 @@ +/** + * Default Transport Handler + * + * Basic implementation of TransportHandler with reasonable defaults. + * Use this for agents that don't need special filtering or error handling. + * + * @module DefaultTransport + */ + +import type { + TransportHandler, + ToolPattern, + StderrContext, + StderrResult, + ToolNameContext, +} from './TransportHandler'; + +/** + * Default timeout values (in milliseconds) + */ +const DEFAULT_TIMEOUTS = { + /** Default initialization timeout: 60 seconds */ + init: 60_000, + /** Default tool call timeout: 2 minutes */ + toolCall: 120_000, + /** Investigation tool timeout: 10 minutes */ + investigation: 600_000, + /** Think tool timeout: 30 seconds */ + think: 30_000, +} as const; + +/** + * Default transport handler implementation. + * + * Provides: + * - 60s init timeout + * - No stdout filtering (pass through all lines) + * - Basic stderr logging (no special error detection) + * - Empty tool patterns (no special tool name extraction) + * - Standard tool call timeouts + */ +export class DefaultTransport implements TransportHandler { + readonly agentName: string; + + constructor(agentName: string = 'generic-acp') { + this.agentName = agentName; + } + + /** + * Default init timeout: 60 seconds + */ + getInitTimeout(): number { + return DEFAULT_TIMEOUTS.init; + } + + /** + * Default: pass through all lines that are valid JSON objects/arrays + */ + filterStdoutLine(line: string): string | null { + const trimmed = line.trim(); + if (!trimmed) { + return null; + } + // Only pass through lines that start with { or [ (JSON) + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + return null; + } + // Validate it's actually parseable JSON and is an object/array + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + return line; + } catch { + return null; + } + } + + /** + * Default: no special stderr handling + */ + handleStderr(_text: string, _context: StderrContext): StderrResult { + return { message: null }; + } + + /** + * Default: no special tool patterns + */ + getToolPatterns(): ToolPattern[] { + return []; + } + + /** + * Default: no investigation tools + */ + isInvestigationTool(_toolCallId: string, _toolKind?: string): boolean { + return false; + } + + /** + * Default tool call timeout based on tool kind + */ + getToolCallTimeout(_toolCallId: string, toolKind?: string): number { + if (toolKind === 'think') { + return DEFAULT_TIMEOUTS.think; + } + return DEFAULT_TIMEOUTS.toolCall; + } + + /** + * Default: no tool name extraction (return null) + */ + extractToolNameFromId(_toolCallId: string): string | null { + return null; + } + + /** + * Default: return original tool name (no special detection) + */ + determineToolName( + toolName: string, + _toolCallId: string, + _input: Record, + _context: ToolNameContext + ): string { + return toolName; + } +} + +/** + * Singleton instance for convenience + */ +export const defaultTransport = new DefaultTransport(); diff --git a/src/agent/transport/TransportHandler.ts b/src/agent/transport/TransportHandler.ts new file mode 100644 index 00000000..68860fef --- /dev/null +++ b/src/agent/transport/TransportHandler.ts @@ -0,0 +1,165 @@ +/** + * TransportHandler Interface + * + * Abstraction layer for agent-specific transport logic. + * Allows different ACP agents (Gemini, Codex, Claude, etc.) to customize: + * - Initialization timeouts + * - Stdout filtering (for debug output removal) + * - Stderr handling (for error detection) + * - Tool name patterns + * + * @module TransportHandler + */ + +import type { AgentMessage } from '../core'; + +/** + * Tool name pattern for extraction from toolCallId + */ +export interface ToolPattern { + /** Canonical tool name */ + name: string; + /** Patterns to match in toolCallId (case-insensitive) */ + patterns: string[]; +} + +/** + * Context passed to stderr handler + */ +export interface StderrContext { + /** Currently active tool calls */ + activeToolCalls: Set; + /** Whether any active tool is an investigation tool */ + hasActiveInvestigation: boolean; +} + +/** + * Context for tool name detection heuristics + */ +export interface ToolNameContext { + /** Whether the recent prompt contained change_title instruction */ + recentPromptHadChangeTitle: boolean; + /** Number of tool calls since last prompt */ + toolCallCountSincePrompt: number; +} + +/** + * Result of stderr processing + */ +export interface StderrResult { + /** Message to emit (null = don't emit anything) */ + message: AgentMessage | null; + /** Whether to suppress this stderr line from logs */ + suppress?: boolean; +} + +/** + * Transport handler interface for ACP backends. + * + * Implement this interface to customize behavior for specific agents. + * Use DefaultTransport as a base or reference implementation. + */ +export interface TransportHandler { + /** + * Agent identifier for logging + */ + readonly agentName: string; + + /** + * Get initialization timeout in milliseconds. + * + * Different agents have different startup times: + * - Gemini CLI: 120s (slow on first start, downloads models) + * - Codex: ~30s + * - Claude: ~10s + * + * @returns Timeout in milliseconds + */ + getInitTimeout(): number; + + /** + * Filter a line from stdout before ACP parsing. + * + * Some agents output debug info to stdout that breaks JSON-RPC parsing. + * Return null to drop the line, or the (possibly modified) line to keep it. + * + * @param line - Raw line from stdout + * @returns Filtered line or null to drop + */ + filterStdoutLine?(line: string): string | null; + + /** + * Handle stderr output from the agent process. + * + * Used to detect errors (rate limits, auth failures, etc.) and + * optionally emit status messages to the UI. + * + * @param text - Stderr text + * @param context - Context about current state + * @returns Result with optional message to emit + */ + handleStderr?(text: string, context: StderrContext): StderrResult; + + /** + * Get tool name patterns for this agent. + * + * Used to extract real tool names from toolCallId when the agent + * sends "other" or "unknown" as the tool name. + * + * @returns Array of tool patterns + */ + getToolPatterns(): ToolPattern[]; + + /** + * Check if a tool is an "investigation" tool that needs longer timeout. + * + * Investigation tools (like codebase_investigator) can run for minutes + * and need special timeout handling. + * + * @param toolCallId - The tool call ID + * @param toolKind - The tool kind/type + * @returns true if this is an investigation tool + */ + isInvestigationTool?(toolCallId: string, toolKind?: string): boolean; + + /** + * Get timeout for a specific tool call. + * + * @param toolCallId - The tool call ID + * @param toolKind - The tool kind/type + * @returns Timeout in milliseconds + */ + getToolCallTimeout?(toolCallId: string, toolKind?: string): number; + + /** + * Extract tool name from toolCallId. + * + * Tool IDs often contain the tool name as a prefix (e.g., "change_title-123" -> "change_title"). + * Uses getToolPatterns() to match known patterns. + * + * @param toolCallId - The tool call ID + * @returns The extracted tool name, or null if not found + */ + extractToolNameFromId?(toolCallId: string): string | null; + + /** + * Determine the real tool name from various sources. + * + * When the agent sends "other" or "Unknown tool", tries to determine the real name from: + * 1. toolCallId patterns + * 2. input parameters + * 3. Context (first tool call after change_title instruction) + * + * @param toolName - The initial tool name (may be "other" or "Unknown tool") + * @param toolCallId - The tool call ID + * @param input - The input parameters + * @param context - Context information + * @returns The determined tool name + */ + determineToolName?( + toolName: string, + toolCallId: string, + input: Record, + context: ToolNameContext + ): string; +} diff --git a/src/agent/transport/handlers/GeminiTransport.ts b/src/agent/transport/handlers/GeminiTransport.ts new file mode 100644 index 00000000..e8bf735b --- /dev/null +++ b/src/agent/transport/handlers/GeminiTransport.ts @@ -0,0 +1,300 @@ +/** + * Gemini Transport Handler + * + * Gemini CLI-specific implementation of TransportHandler. + * Handles: + * - Long init timeout (Gemini CLI is slow on first start) + * - Stdout filtering (removes debug output that breaks JSON-RPC) + * - Stderr parsing (detects rate limits, 404 errors) + * - Tool name patterns (change_title, save_memory, think) + * - Investigation tool detection (codebase_investigator) + * + * @module GeminiTransport + */ + +import type { + TransportHandler, + ToolPattern, + StderrContext, + StderrResult, + ToolNameContext, +} from '../TransportHandler'; +import type { AgentMessage } from '../../core'; + +/** + * Gemini-specific timeout values (in milliseconds) + */ +const GEMINI_TIMEOUTS = { + /** Gemini CLI can be slow on first start (downloading models, etc.) */ + init: 120_000, + /** Standard tool call timeout */ + toolCall: 120_000, + /** Investigation tools (codebase_investigator) can run for a long time */ + investigation: 600_000, + /** Think tools are usually quick */ + think: 30_000, +} as const; + +/** + * Known tool name patterns for Gemini CLI. + * Used to extract real tool names from toolCallId when Gemini sends "other". + */ +const GEMINI_TOOL_PATTERNS: ToolPattern[] = [ + { + name: 'change_title', + patterns: ['change_title', 'change-title', 'happy__change_title'], + }, + { + name: 'save_memory', + patterns: ['save_memory', 'save-memory'], + }, + { + name: 'think', + patterns: ['think'], + }, +]; + +/** + * Available Gemini models for error messages + */ +const AVAILABLE_MODELS = [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', +]; + +/** + * Gemini CLI transport handler. + * + * Handles all Gemini-specific quirks: + * - Debug output filtering from stdout + * - Rate limit and error detection in stderr + * - Tool name extraction from toolCallId + */ +export class GeminiTransport implements TransportHandler { + readonly agentName = 'gemini'; + + /** + * Gemini CLI needs 2 minutes for first start (model download, warm-up) + */ + getInitTimeout(): number { + return GEMINI_TIMEOUTS.init; + } + + /** + * Filter Gemini CLI debug output from stdout. + * + * Gemini CLI outputs various debug info (experiments, flags, etc.) to stdout + * that breaks ACP JSON-RPC parsing. We only keep valid JSON lines. + */ + filterStdoutLine(line: string): string | null { + const trimmed = line.trim(); + + // Empty lines - skip + if (!trimmed) { + return null; + } + + // Must start with { or [ to be valid JSON-RPC + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + return null; + } + + // Validate it's actually parseable JSON and is an object (not a primitive) + // JSON-RPC messages are always objects, but numbers like "105887304" parse as valid JSON + try { + const parsed = JSON.parse(trimmed); + // Must be an object or array (for batched requests), not a primitive + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + return line; + } catch { + return null; + } + } + + /** + * Handle Gemini CLI stderr output. + * + * Detects: + * - Rate limit errors (429) - logged but not shown (CLI handles retries) + * - Model not found (404) - emit error with available models + * - Other errors during investigation - logged for debugging + */ + handleStderr(text: string, context: StderrContext): StderrResult { + const trimmed = text.trim(); + if (!trimmed) { + return { message: null, suppress: true }; + } + + // Rate limit error (429) - Gemini CLI handles retries internally + if ( + trimmed.includes('status 429') || + trimmed.includes('code":429') || + trimmed.includes('rateLimitExceeded') || + trimmed.includes('RESOURCE_EXHAUSTED') + ) { + return { + message: null, + suppress: false, // Log for debugging but don't show to user + }; + } + + // Model not found (404) - show error with available models + if (trimmed.includes('status 404') || trimmed.includes('code":404')) { + const errorMessage: AgentMessage = { + type: 'status', + status: 'error', + detail: `Model not found. Available models: ${AVAILABLE_MODELS.join(', ')}`, + }; + return { message: errorMessage }; + } + + // During investigation tools, log any errors/timeouts for debugging + if (context.hasActiveInvestigation) { + const hasError = + trimmed.includes('timeout') || + trimmed.includes('Timeout') || + trimmed.includes('failed') || + trimmed.includes('Failed') || + trimmed.includes('error') || + trimmed.includes('Error'); + + if (hasError) { + // Just log, don't emit - investigation might recover + return { message: null, suppress: false }; + } + } + + return { message: null }; + } + + /** + * Gemini-specific tool patterns + */ + getToolPatterns(): ToolPattern[] { + return GEMINI_TOOL_PATTERNS; + } + + /** + * Check if tool is an investigation tool (needs longer timeout) + */ + isInvestigationTool(toolCallId: string, toolKind?: string): boolean { + const lowerId = toolCallId.toLowerCase(); + return ( + lowerId.includes('codebase_investigator') || + lowerId.includes('investigator') || + (typeof toolKind === 'string' && toolKind.includes('investigator')) + ); + } + + /** + * Get timeout for a tool call + */ + getToolCallTimeout(toolCallId: string, toolKind?: string): number { + if (this.isInvestigationTool(toolCallId, toolKind)) { + return GEMINI_TIMEOUTS.investigation; + } + if (toolKind === 'think') { + return GEMINI_TIMEOUTS.think; + } + return GEMINI_TIMEOUTS.toolCall; + } + + /** + * Extract tool name from toolCallId using Gemini patterns. + * + * Tool IDs often contain the tool name as a prefix (e.g., "change_title-1765385846663" -> "change_title") + */ + extractToolNameFromId(toolCallId: string): string | null { + const lowerId = toolCallId.toLowerCase(); + + for (const toolPattern of GEMINI_TOOL_PATTERNS) { + for (const pattern of toolPattern.patterns) { + if (lowerId.includes(pattern.toLowerCase())) { + return toolPattern.name; + } + } + } + + return null; + } + + /** + * Determine the real tool name from various sources. + * + * When Gemini sends "other" or "Unknown tool", tries to determine the real name from: + * 1. toolCallId patterns (most reliable) + * 2. input parameters + * 3. Context (first tool call after change_title instruction) + */ + determineToolName( + toolName: string, + toolCallId: string, + input: Record, + context: ToolNameContext + ): string { + // If tool name is already known, return it + if (toolName !== 'other' && toolName !== 'Unknown tool') { + return toolName; + } + + // 1. Check toolCallId for known tool names (most reliable) + const idToolName = this.extractToolNameFromId(toolCallId); + if (idToolName) { + return idToolName; + } + + // 2. Check input for function names or tool identifiers + if (input && typeof input === 'object') { + const inputStr = JSON.stringify(input).toLowerCase(); + for (const toolPattern of GEMINI_TOOL_PATTERNS) { + for (const pattern of toolPattern.patterns) { + if (inputStr.includes(pattern.toLowerCase())) { + return toolPattern.name; + } + } + } + } + + // 3. Check if input contains 'title' field - likely change_title + if (input && typeof input === 'object' && 'title' in input) { + return 'change_title'; + } + + // 4. Context-based heuristic: if prompt had change_title instruction + // and tool is "other" with empty input, it's likely change_title + if (context.recentPromptHadChangeTitle) { + const isEmptyInput = + !input || + (Array.isArray(input) && input.length === 0) || + (typeof input === 'object' && Object.keys(input).length === 0); + + if (isEmptyInput && toolName === 'other') { + return 'change_title'; + } + } + + // 5. Fallback: if toolName is "other" with empty input, it's most likely change_title + // This is because change_title is the only MCP tool that: + // - Gets reported as "other" by Gemini ACP + // - Has empty input (title is extracted from context, not passed as input) + const isEmptyInput = + !input || + (Array.isArray(input) && input.length === 0) || + (typeof input === 'object' && Object.keys(input).length === 0); + + if (isEmptyInput && toolName === 'other') { + return 'change_title'; + } + + // Return original tool name if we couldn't determine it + return toolName; + } +} + +/** + * Singleton instance for convenience + */ +export const geminiTransport = new GeminiTransport(); diff --git a/src/agent/transport/handlers/index.ts b/src/agent/transport/handlers/index.ts new file mode 100644 index 00000000..8c75763d --- /dev/null +++ b/src/agent/transport/handlers/index.ts @@ -0,0 +1,14 @@ +/** + * Transport Handler Implementations + * + * Agent-specific transport handlers for different CLI agents. + * + * @module handlers + */ + +export { GeminiTransport, geminiTransport } from './GeminiTransport'; + +// Future handlers: +// export { CodexTransport, codexTransport } from './CodexTransport'; +// export { ClaudeTransport, claudeTransport } from './ClaudeTransport'; +// export { OpenCodeTransport, openCodeTransport } from './OpenCodeTransport'; diff --git a/src/agent/transport/index.ts b/src/agent/transport/index.ts new file mode 100644 index 00000000..0e485ece --- /dev/null +++ b/src/agent/transport/index.ts @@ -0,0 +1,27 @@ +/** + * Transport Handlers + * + * Agent-specific transport logic for ACP backends. + * + * @module transport + */ + +// Core types and interfaces +export type { + TransportHandler, + ToolPattern, + StderrContext, + StderrResult, + ToolNameContext, +} from './TransportHandler'; + +// Default implementation +export { DefaultTransport, defaultTransport } from './DefaultTransport'; + +// Agent-specific handlers +export { GeminiTransport, geminiTransport } from './handlers'; + +// Future handlers will be exported from ./handlers: +// export { CodexTransport, codexTransport } from './handlers'; +// export { ClaudeTransport, claudeTransport } from './handlers'; +// export { OpenCodeTransport, openCodeTransport } from './handlers'; diff --git a/src/api/apiSession.ts b/src/api/apiSession.ts index e4dd4253..187ce5b8 100644 --- a/src/api/apiSession.ts +++ b/src/api/apiSession.ts @@ -11,6 +11,33 @@ import { AsyncLock } from '@/utils/lock'; import { RpcHandlerManager } from './rpc/RpcHandlerManager'; import { registerCommonHandlers } from '../modules/common/registerCommonHandlers'; +/** + * ACP (Agent Communication Protocol) message data types. + * This is the unified format for all agent messages - CLI adapts each provider's format to ACP. + */ +export type ACPMessageData = + // Core message types + | { type: 'message'; message: string } + | { type: 'reasoning'; message: string } + | { type: 'thinking'; text: string } + // Tool interactions + | { type: 'tool-call'; callId: string; name: string; input: unknown; id: string } + | { type: 'tool-result'; callId: string; output: unknown; id: string; isError?: boolean } + // File operations + | { type: 'file-edit'; description: string; filePath: string; diff?: string; oldContent?: string; newContent?: string; id: string } + // Terminal/command output + | { type: 'terminal-output'; data: string; callId: string } + // Task lifecycle events + | { type: 'task_started'; id: string } + | { type: 'task_complete'; id: string } + | { type: 'turn_aborted'; id: string } + // Permissions + | { type: 'permission-request'; permissionId: string; toolName: string; description: string; options?: unknown } + // Usage/metrics + | { type: 'token_count'; [key: string]: unknown }; + +export type ACPProvider = 'gemini' | 'codex' | 'claude' | 'opencode'; + export class ApiSessionClient extends EventEmitter { private readonly token: string; readonly sessionId: string; @@ -253,17 +280,18 @@ export class ApiSessionClient extends EventEmitter { } /** - * Send a generic agent message to the session. - * Works for any agent type (Gemini, Codex, Claude, etc.) + * Send a generic agent message to the session using ACP (Agent Communication Protocol) format. + * Works for any agent type (Gemini, Codex, Claude, etc.) - CLI normalizes to unified ACP format. * - * @param agentType - The type of agent sending the message (e.g., 'gemini', 'codex', 'claude') - * @param body - The message payload + * @param provider - The agent provider sending the message (e.g., 'gemini', 'codex', 'claude') + * @param body - The message payload (type: 'message' | 'reasoning' | 'tool-call' | 'tool-result') */ - sendAgentMessage(agentType: 'gemini' | 'codex' | 'claude' | 'opencode', body: any) { + sendAgentMessage(provider: 'gemini' | 'codex' | 'claude' | 'opencode', body: ACPMessageData) { let content = { role: 'agent', content: { - type: agentType, + type: 'acp', + provider, data: body }, meta: { @@ -271,7 +299,7 @@ export class ApiSessionClient extends EventEmitter { } }; - logger.debug(`[SOCKET] Sending ${agentType} message:`, { type: body.type, hasMessage: !!body.message }); + logger.debug(`[SOCKET] Sending ACP message from ${provider}:`, { type: body.type, hasMessage: 'message' in body }); const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); this.socket.emit('message', { diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 676b484e..ac9311a4 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -1,9 +1,13 @@ import chalk from 'chalk'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; import { readCredentials } from '@/persistence'; import { ApiClient } from '@/api/api'; import { authenticateCodex } from './connect/authenticateCodex'; import { authenticateClaude } from './connect/authenticateClaude'; import { authenticateGemini } from './connect/authenticateGemini'; +import { decodeJwtPayload } from './connect/utils'; /** * Handle connect subcommand @@ -32,6 +36,9 @@ export async function handleConnectCommand(args: string[]): Promise { case 'gemini': await handleConnectVendor('gemini', 'Gemini'); break; + case 'status': + await handleConnectStatus(); + break; default: console.error(chalk.red(`Unknown connect target: ${subcommand}`)); showConnectHelp(); @@ -47,6 +54,7 @@ ${chalk.bold('Usage:')} happy connect codex Store your Codex API key in Happy cloud happy connect claude Store your Anthropic API key in Happy cloud happy connect gemini Store your Gemini API key in Happy cloud + happy connect status Show connection status for all vendors happy connect help Show this help message ${chalk.bold('Description:')} @@ -58,6 +66,7 @@ ${chalk.bold('Examples:')} happy connect codex happy connect claude happy connect gemini + happy connect status ${chalk.bold('Notes:')} • You must be authenticated with Happy first (run 'happy auth login') @@ -98,8 +107,113 @@ async function handleConnectVendor(vendor: 'codex' | 'claude' | 'gemini', displa const geminiAuthTokens = await authenticateGemini(); await api.registerVendorToken('gemini', { oauth: geminiAuthTokens }); console.log('✅ Gemini token registered with server'); + + // Also update local Gemini config to keep tokens in sync + updateLocalGeminiCredentials(geminiAuthTokens); + process.exit(0); } else { throw new Error(`Unsupported vendor: ${vendor}`); } +} + +/** + * Show connection status for all vendors + */ +async function handleConnectStatus(): Promise { + console.log(chalk.bold('\n🔌 Connection Status\n')); + + // Check if authenticated + const credentials = await readCredentials(); + if (!credentials) { + console.log(chalk.yellow('⚠️ Not authenticated with Happy')); + console.log(chalk.gray(' Please run "happy auth login" first')); + process.exit(1); + } + + // Create API client + const api = await ApiClient.create(credentials); + + // Check each vendor + const vendors: Array<{ key: 'openai' | 'anthropic' | 'gemini'; name: string; display: string }> = [ + { key: 'gemini', name: 'Gemini', display: 'Google Gemini' }, + { key: 'openai', name: 'Codex', display: 'OpenAI Codex' }, + { key: 'anthropic', name: 'Claude', display: 'Anthropic Claude' }, + ]; + + for (const vendor of vendors) { + try { + const token = await api.getVendorToken(vendor.key); + + if (token?.oauth) { + // Try to extract user info from id_token (JWT) + let userInfo = ''; + + if (token.oauth.id_token) { + const payload = decodeJwtPayload(token.oauth.id_token); + if (payload?.email) { + userInfo = chalk.gray(` (${payload.email})`); + } + } + + // Check if token might be expired + const expiresAt = token.oauth.expires_at || (token.oauth.expires_in ? Date.now() + token.oauth.expires_in * 1000 : null); + const isExpired = expiresAt && expiresAt < Date.now(); + + if (isExpired) { + console.log(` ${chalk.yellow('⚠️')} ${vendor.display}: ${chalk.yellow('expired')}${userInfo}`); + } else { + console.log(` ${chalk.green('✓')} ${vendor.display}: ${chalk.green('connected')}${userInfo}`); + } + } else { + console.log(` ${chalk.gray('○')} ${vendor.display}: ${chalk.gray('not connected')}`); + } + } catch { + console.log(` ${chalk.gray('○')} ${vendor.display}: ${chalk.gray('not connected')}`); + } + } + + console.log(''); + console.log(chalk.gray('To connect a vendor, run: happy connect ')); + console.log(chalk.gray('Example: happy connect gemini')); + console.log(''); +} + +/** + * Update local Gemini credentials file to keep in sync with Happy cloud + * This ensures the Gemini SDK uses the same account as Happy + */ +function updateLocalGeminiCredentials(tokens: { + access_token: string; + refresh_token?: string; + id_token?: string; + expires_in?: number; + token_type?: string; + scope?: string; +}): void { + try { + const geminiDir = join(homedir(), '.gemini'); + const credentialsPath = join(geminiDir, 'oauth_creds.json'); + + // Create directory if it doesn't exist + if (!existsSync(geminiDir)) { + mkdirSync(geminiDir, { recursive: true }); + } + + // Write credentials in the format Gemini CLI expects + const credentials = { + access_token: tokens.access_token, + token_type: tokens.token_type || 'Bearer', + scope: tokens.scope || 'https://www.googleapis.com/auth/cloud-platform', + ...(tokens.refresh_token && { refresh_token: tokens.refresh_token }), + ...(tokens.id_token && { id_token: tokens.id_token }), + ...(tokens.expires_in && { expires_in: tokens.expires_in }), + }; + + writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2), 'utf-8'); + console.log(chalk.gray(` Updated local credentials: ${credentialsPath}`)); + } catch (error) { + // Non-critical error - server tokens will still work + console.log(chalk.yellow(` ⚠️ Could not update local credentials: ${error}`)); + } } \ No newline at end of file diff --git a/src/commands/connect/utils.ts b/src/commands/connect/utils.ts new file mode 100644 index 00000000..cc13cf30 --- /dev/null +++ b/src/commands/connect/utils.ts @@ -0,0 +1,29 @@ +/** + * Utility functions for connect commands + */ + +/** + * Decode JWT payload without verification + * Used to extract user info (email) from id_token + * + * @param token - JWT token string + * @returns Decoded payload or null if invalid + */ +export function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) { + return null; + } + + // JWT payload is the second part, base64url encoded + const payload = parts[1]; + + // Decode base64url to JSON + const decoded = Buffer.from(payload, 'base64url').toString('utf-8'); + return JSON.parse(decoded); + } catch { + return null; + } +} + diff --git a/src/gemini/runGemini.ts b/src/gemini/runGemini.ts index 474bad2e..71689ea9 100644 --- a/src/gemini/runGemini.ts +++ b/src/gemini/runGemini.ts @@ -31,8 +31,8 @@ import { connectionState } from '@/utils/serverConnectionErrors'; import { setupOfflineReconnection } from '@/utils/setupOfflineReconnection'; import type { ApiSessionClient } from '@/api/apiSession'; -import { createGeminiBackend } from '@/agent/acp/gemini'; -import type { AgentBackend, AgentMessage } from '@/agent/AgentBackend'; +import { createGeminiBackend } from '@/agent/factories/gemini'; +import type { AgentBackend, AgentMessage } from '@/agent'; import { GeminiDisplay } from '@/ui/ink/GeminiDisplay'; import { GeminiPermissionHandler } from '@/gemini/utils/permissionHandler'; import { GeminiReasoningProcessor } from '@/gemini/utils/reasoningProcessor'; @@ -51,6 +51,7 @@ import { hasIncompleteOptions, formatOptionsXml, } from '@/gemini/utils/optionsParser'; +import { ConversationHistory } from '@/gemini/utils/conversationHistory'; /** @@ -93,11 +94,28 @@ export async function runGemini(opts: { // Fetch Gemini cloud token (from 'happy connect gemini') // let cloudToken: string | undefined = undefined; + let currentUserEmail: string | undefined = undefined; try { const vendorToken = await api.getVendorToken('gemini'); if (vendorToken?.oauth?.access_token) { cloudToken = vendorToken.oauth.access_token; logger.debug('[Gemini] Using OAuth token from Happy cloud'); + + // Extract email from id_token for per-account project matching + if (vendorToken.oauth.id_token) { + try { + const parts = vendorToken.oauth.id_token.split('.'); + if (parts.length === 3) { + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + if (payload.email) { + currentUserEmail = payload.email; + logger.debug(`[Gemini] Current user email: ${currentUserEmail}`); + } + } + } catch { + logger.debug('[Gemini] Failed to decode id_token for email'); + } + } } } catch (error) { logger.debug('[Gemini] Failed to fetch cloud token:', error); @@ -155,6 +173,9 @@ export async function runGemini(opts: { model: mode.model, })); + // Conversation history for context preservation across model changes + const conversationHistory = new ConversationHistory({ maxMessages: 20, maxCharacters: 50000 }); + // Track current overrides to apply per message let currentPermissionMode: PermissionMode | undefined = undefined; let currentModel: string | undefined = undefined; @@ -195,12 +216,17 @@ export async function runGemini(opts: { // Don't call updateDisplayedModel here - keep current displayed model // The backend will use the correct model from env/config/default } else if (message.meta.model) { + const previousModel = currentModel; messageModel = message.meta.model; currentModel = messageModel; - // Save model to config file so it persists across sessions - updateDisplayedModel(messageModel, true); // Update UI and save to config - // Show model change message in UI (this will trigger UI re-render) - messageBuffer.addMessage(`Model changed to: ${messageModel}`, 'system'); + // Only update UI and show message if model actually changed + if (previousModel !== messageModel) { + // Save model to config file so it persists across sessions + updateDisplayedModel(messageModel, true); // Update UI and save to config + // Show model change message in UI (this will trigger UI re-render) + messageBuffer.addMessage(`Model changed to: ${messageModel}`, 'system'); + logger.debug(`[Gemini] Model changed from ${previousModel} to ${messageModel}`); + } } // If message.meta.model is undefined, keep currentModel } @@ -225,6 +251,9 @@ export async function runGemini(opts: { originalUserMessage, // Store original message separately }; messageQueue.push(fullPrompt, mode); + + // Record user message in conversation history for context preservation + conversationHistory.addUserMessage(originalUserMessage); }); let thinking = false; @@ -285,7 +314,7 @@ export async function runGemini(opts: { logger.debug('[Gemini] Abort requested - stopping current task'); // Send turn_aborted event (like Codex) when abort is requested - session.sendCodexMessage({ + session.sendAgentMessage('gemini', { type: 'turn_aborted', id: randomUUID(), }); @@ -450,13 +479,13 @@ export async function runGemini(opts: { // Create reasoning processor for handling thinking/reasoning chunks const reasoningProcessor = new GeminiReasoningProcessor((message) => { // Callback to send messages directly from the processor - session.sendCodexMessage(message); + session.sendAgentMessage('gemini', message); }); // Create diff processor for handling file edit events and diff tracking const diffProcessor = new GeminiDiffProcessor((message) => { // Callback to send messages directly from the processor - session.sendCodexMessage(message); + session.sendAgentMessage('gemini', message); }); // Update permission handler when permission mode changes @@ -468,6 +497,10 @@ export async function runGemini(opts: { let accumulatedResponse = ''; let isResponseInProgress = false; let currentResponseMessageId: string | null = null; // Track the message ID for current response + let hadToolCallInTurn = false; // Track if any tool calls happened in this turn (for task_complete) + let pendingChangeTitle = false; // Track if we're waiting for change_title to complete + let changeTitleCompleted = false; // Track if change_title was completed in this turn + let taskStartedSent = false; // Track if task_started was sent this turn (prevent duplicates) /** * Set up message handler for Gemini backend @@ -498,15 +531,18 @@ export async function runGemini(opts: { break; case 'status': - // Log status changes for debugging - logger.debug(`[gemini] Status changed: ${msg.status}${msg.detail ? ` - ${msg.detail}` : ''}`); + // Log status changes for debugging - stringify object details + const statusDetail = msg.detail + ? (typeof msg.detail === 'object' ? JSON.stringify(msg.detail) : String(msg.detail)) + : ''; + logger.debug(`[gemini] Status changed: ${msg.status}${statusDetail ? ` - ${statusDetail}` : ''}`); // Log error status with details if (msg.status === 'error') { - logger.debug(`[gemini] ⚠️ Error status received: ${msg.detail || 'Unknown error'}`); + logger.debug(`[gemini] ⚠️ Error status received: ${statusDetail || 'Unknown error'}`); // Send turn_aborted event (like Codex) when error occurs - session.sendCodexMessage({ + session.sendAgentMessage('gemini', { type: 'turn_aborted', id: randomUUID(), }); @@ -516,11 +552,15 @@ export async function runGemini(opts: { thinking = true; session.keepAlive(thinking, 'remote'); - // Send task_started event (like Codex) when agent starts working - session.sendCodexMessage({ - type: 'task_started', - id: randomUUID(), - }); + // Send task_started event ONCE per turn (like Codex) when agent starts working + // Gemini may go running -> idle -> running multiple times during a turn + if (!taskStartedSent) { + session.sendAgentMessage('gemini', { + type: 'task_started', + id: randomUUID(), + }); + taskStartedSent = true; + } // Show thinking indicator in UI when agent starts working (like Codex) // This will be updated with actual thinking text when agent_thought_chunk events arrive @@ -531,68 +571,15 @@ export async function runGemini(opts: { // Don't reset accumulator here - tool calls can happen during a response // Accumulator will be reset when a new prompt is sent (in the main loop) } else if (msg.status === 'idle' || msg.status === 'stopped') { - if (thinking) { - // Clear thinking indicator when agent finishes - thinking = false; - // Remove thinking message from UI when agent finishes (like Codex) - // The thinking messages will be replaced by actual response - } - thinking = false; - session.keepAlive(thinking, 'remote'); + // DON'T change thinking state here - Gemini makes pauses between chunks + // which causes multiple idle events. thinking will be set to false ONCE + // in the finally block when the turn is complete. + // This prevents UI status flickering between "working" and "online" // Complete reasoning processor when status becomes idle (like Codex) // Only complete if there's actually reasoning content to complete // Skip if this is just the initial idle status after session creation - const reasoningCompleted = reasoningProcessor.complete(); - - // Send task_complete event (like Codex) when agent finishes - // Only send if this is a real task completion (not initial idle) - if (reasoningCompleted || isResponseInProgress) { - session.sendCodexMessage({ - type: 'task_complete', - id: randomUUID(), - }); - } - - // Send accumulated response to mobile app when response is complete - // Status 'idle' indicates task completion (similar to Codex's task_complete) - if (isResponseInProgress && accumulatedResponse.trim()) { - // Parse options from response text (for logging/debugging) - // But keep options IN the text - mobile app's parseMarkdown will extract them - const { text: messageText, options } = parseOptionsFromText(accumulatedResponse); - - // Mobile app parses options from text via parseMarkdown, so we need to keep them in the message - // Re-add options XML block to the message text if options were found - let finalMessageText = messageText; - if (options.length > 0) { - const optionsXml = formatOptionsXml(options); - finalMessageText = messageText + optionsXml; - logger.debug(`[gemini] Found ${options.length} options in response:`, options); - logger.debug(`[gemini] Keeping options in message text for mobile app parsing`); - } else if (hasIncompleteOptions(accumulatedResponse)) { - // If we have incomplete options block, still send the message - // The mobile app will handle incomplete blocks gracefully - logger.debug(`[gemini] Warning: Incomplete options block detected but sending message anyway`); - } - - const messageId = randomUUID(); - - const messagePayload: CodexMessagePayload = { - type: 'message', - message: finalMessageText, // Include options XML in text for mobile app - id: messageId, - ...(options.length > 0 && { options }), - }; - - logger.debug(`[gemini] Sending complete message to mobile (length: ${finalMessageText.length}): ${finalMessageText.substring(0, 100)}...`); - logger.debug(`[gemini] Full message payload:`, JSON.stringify(messagePayload, null, 2)); - // Use sendCodexMessage - mobile app parses options from message text via parseMarkdown - session.sendCodexMessage(messagePayload); - accumulatedResponse = ''; - isResponseInProgress = false; - } - // Note: sendReady() is called via emitReadyIfIdle() in the finally block after prompt completes - // Don't call it here to avoid duplicates + reasoningProcessor.complete(); } else if (msg.status === 'error') { thinking = false; session.keepAlive(thinking, 'remote'); @@ -600,20 +587,42 @@ export async function runGemini(opts: { isResponseInProgress = false; currentResponseMessageId = null; - // Show error in CLI UI - const errorMessage = msg.detail || 'Unknown error'; + // Show error in CLI UI - handle object errors properly + let errorMessage = 'Unknown error'; + if (msg.detail) { + if (typeof msg.detail === 'object') { + // Extract message from error object + const detailObj = msg.detail as Record; + errorMessage = (detailObj.message as string) || + (detailObj.details as string) || + JSON.stringify(detailObj); + } else { + errorMessage = String(msg.detail); + } + } + + // Check for authentication error and provide helpful message + if (errorMessage.includes('Authentication required')) { + errorMessage = `Authentication required.\n` + + `For Google Workspace accounts, run: happy gemini project set \n` + + `Or use a different Google account: happy connect gemini\n` + + `Guide: https://goo.gle/gemini-cli-auth-docs#workspace-gca`; + } + messageBuffer.addMessage(`Error: ${errorMessage}`, 'status'); - // Use sendCodexMessage for consistency with codex format - session.sendCodexMessage({ + // Use sendAgentMessage for consistency with ACP format + session.sendAgentMessage('gemini', { type: 'message', message: `Error: ${errorMessage}`, - id: randomUUID(), }); } break; case 'tool-call': + // Track that we had tool calls in this turn (for task_complete) + hadToolCallInTurn = true; + // Show tool call in UI like Codex does const toolArgs = msg.args ? JSON.stringify(msg.args).substring(0, 100) : ''; const isInvestigationTool = msg.toolName === 'codebase_investigator' || @@ -625,7 +634,7 @@ export async function runGemini(opts: { } messageBuffer.addMessage(`Executing: ${msg.toolName}${toolArgs ? ` ${toolArgs}${toolArgs.length >= 100 ? '...' : ''}` : ''}`, 'tool'); - session.sendCodexMessage({ + session.sendAgentMessage('gemini', { type: 'tool-call', name: msg.toolName, callId: msg.callId, @@ -635,6 +644,14 @@ export async function runGemini(opts: { break; case 'tool-result': + // Track change_title completion + if (msg.toolName === 'change_title' || + msg.callId?.includes('change_title') || + msg.toolName === 'happy__change_title') { + changeTitleCompleted = true; + logger.debug('[gemini] change_title completed'); + } + // Show tool result in UI like Codex does // Check if result contains error information const isError = msg.result && typeof msg.result === 'object' && 'error' in msg.result; @@ -666,8 +683,8 @@ export async function runGemini(opts: { messageBuffer.addMessage(`Result: ${truncatedResult}`, 'result'); } - session.sendCodexMessage({ - type: 'tool-call-result', + session.sendAgentMessage('gemini', { + type: 'tool-result', callId: msg.callId, output: msg.result, id: randomUUID(), @@ -681,11 +698,11 @@ export async function runGemini(opts: { // msg.diff is optional (diff?: string), so it can be undefined diffProcessor.processFsEdit(msg.path || '', msg.description, msg.diff); - session.sendCodexMessage({ + session.sendAgentMessage('gemini', { type: 'file-edit', description: msg.description, diff: msg.diff, - path: msg.path, + filePath: msg.path || 'unknown', id: randomUUID(), }); break; @@ -696,7 +713,7 @@ export async function runGemini(opts: { // Forward token count to mobile app (like Codex) // Note: Gemini ACP may not provide token_count events directly, // but we handle them if they come from the backend - session.sendCodexMessage({ + session.sendAgentMessage('gemini', { type: 'token_count', ...(msg as any), id: randomUUID(), @@ -706,21 +723,24 @@ export async function runGemini(opts: { case 'terminal-output': messageBuffer.addMessage(msg.data, 'result'); - session.sendCodexMessage({ + session.sendAgentMessage('gemini', { type: 'terminal-output', data: msg.data, - id: randomUUID(), + callId: (msg as any).callId || randomUUID(), }); break; case 'permission-request': // Forward permission request to mobile app - session.sendCodexMessage({ + // Note: toolName is in msg.payload.toolName (from AcpBackend), + // msg.reason also contains the tool name + const payload = (msg as any).payload || {}; + session.sendAgentMessage('gemini', { type: 'permission-request', permissionId: msg.id, - reason: msg.reason, - payload: msg.payload, - id: randomUUID(), + toolName: payload.toolName || (msg as any).reason || 'unknown', + description: (msg as any).reason || payload.toolName || '', + options: payload, }); break; @@ -734,7 +754,7 @@ export async function runGemini(opts: { logger.debug(`[gemini] Exec approval request received: ${callId}`); messageBuffer.addMessage(`Exec approval requested: ${callId}`, 'tool'); - session.sendCodexMessage({ + session.sendAgentMessage('gemini', { type: 'tool-call', name: 'GeminiBash', // Similar to Codex's CodexBash callId: callId, @@ -755,7 +775,7 @@ export async function runGemini(opts: { messageBuffer.addMessage(`Modifying ${filesMsg}...`, 'tool'); logger.debug(`[gemini] Patch apply begin: ${patchCallId}, files: ${changeCount}`); - session.sendCodexMessage({ + session.sendAgentMessage('gemini', { type: 'tool-call', name: 'GeminiPatch', // Similar to Codex's CodexPatch callId: patchCallId, @@ -783,8 +803,8 @@ export async function runGemini(opts: { } logger.debug(`[gemini] Patch apply end: ${patchEndCallId}, success: ${success}`); - session.sendCodexMessage({ - type: 'tool-call-result', + session.sendAgentMessage('gemini', { + type: 'tool-result', callId: patchEndCallId, output: { stdout, @@ -822,10 +842,9 @@ export async function runGemini(opts: { // This ensures user sees progress during long reasoning operations } // Also forward to mobile for UI feedback - session.sendCodexMessage({ + session.sendAgentMessage('gemini', { type: 'thinking', text: thinkingText, - id: randomUUID(), }); } break; @@ -866,11 +885,22 @@ export async function runGemini(opts: { break; } + // Track if we need to inject conversation history (after model change) + let injectHistoryContext = false; + // Handle mode change (like Codex) - restart session if permission mode or model changed if (wasSessionCreated && currentModeHash && message.hash !== currentModeHash) { logger.debug('[Gemini] Mode changed – restarting Gemini session'); messageBuffer.addMessage('═'.repeat(40), 'status'); - messageBuffer.addMessage('Starting new Gemini session (mode changed)...', 'status'); + + // Check if we have conversation history to preserve + if (conversationHistory.hasHistory()) { + messageBuffer.addMessage(`Switching model (preserving ${conversationHistory.size()} messages of context)...`, 'status'); + injectHistoryContext = true; + logger.debug(`[Gemini] Will inject conversation history: ${conversationHistory.getSummary()}`); + } else { + messageBuffer.addMessage('Starting new Gemini session (mode changed)...', 'status'); + } // Reset permission handler and reasoning processor on mode change (like Codex) permissionHandler.reset(); @@ -889,6 +919,7 @@ export async function runGemini(opts: { mcpServers, permissionHandler, cloudToken, + currentUserEmail, // Pass model from message - if undefined, will use local config/env/default // If explicitly null, will skip local config and use env/default model: modelToUse, @@ -904,6 +935,9 @@ export async function runGemini(opts: { const actualModel = determineGeminiModel(modelToUse, localConfigForModel); logger.debug(`[gemini] Model change - modelToUse=${modelToUse}, actualModel=${actualModel}`); + // Update conversation history with new model + conversationHistory.setCurrentModel(actualModel); + logger.debug('[gemini] Starting new ACP session with model:', actualModel); const { sessionId } = await geminiBackend.startSession(); acpSessionId = sessionId; @@ -937,6 +971,7 @@ export async function runGemini(opts: { mcpServers, permissionHandler, cloudToken, + currentUserEmail, // Pass model from message - if undefined, will use local config/env/default // If explicitly null, will skip local config and use env/default model: modelToUse, @@ -962,6 +997,9 @@ export async function runGemini(opts: { logger.debug(`[gemini] Backend created, model will be: ${actualModel} (from ${modelSource})`); logger.debug(`[gemini] Calling updateDisplayedModel with: ${actualModel}`); updateDisplayedModel(actualModel, false); // Don't save - this is backend initialization + + // Track current model in conversation history + conversationHistory.setCurrentModel(actualModel); } // Start session if not started @@ -983,12 +1021,20 @@ export async function runGemini(opts: { if (!acpSessionId) { throw new Error('ACP session not started'); } - + // Reset accumulator when sending a new prompt (not when tool calls start) // Reset accumulated response for new prompt // This ensures a new assistant message will be created (not updating previous one) accumulatedResponse = ''; isResponseInProgress = false; + hadToolCallInTurn = false; + taskStartedSent = false; // Reset so new turn can send task_started + + // Track if this prompt contains change_title instruction + // If so, don't send task_complete until change_title is completed + pendingChangeTitle = message.message.includes('change_title') || + message.message.includes('happy__change_title'); + changeTitleCompleted = false; if (!geminiBackend || !acpSessionId) { throw new Error('Gemini backend or session not initialized'); @@ -996,12 +1042,83 @@ export async function runGemini(opts: { // The prompt already includes system prompt and change_title instruction (added in onUserMessage handler) // This is done in the message queue, so message.message already contains everything - const promptToSend = message.message; + let promptToSend = message.message; + + // Inject conversation history context if model was just changed + if (injectHistoryContext && conversationHistory.hasHistory()) { + const historyContext = conversationHistory.getContextForNewSession(); + promptToSend = historyContext + promptToSend; + logger.debug(`[gemini] Injected conversation history context (${historyContext.length} chars)`); + // Don't clear history - keep accumulating for future model changes + } logger.debug(`[gemini] Sending prompt to Gemini (length: ${promptToSend.length}): ${promptToSend.substring(0, 100)}...`); logger.debug(`[gemini] Full prompt: ${promptToSend}`); - await geminiBackend.sendPrompt(acpSessionId, promptToSend); - logger.debug('[gemini] Prompt sent successfully'); + + // Retry logic for transient Gemini API errors (empty response, internal errors) + const MAX_RETRIES = 3; + const RETRY_DELAY_MS = 2000; + let lastError: unknown = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + await geminiBackend.sendPrompt(acpSessionId, promptToSend); + logger.debug('[gemini] Prompt sent successfully'); + + // Wait for Gemini to finish responding (all chunks received + final idle) + // This ensures we don't send task_complete until response is truly done + if (geminiBackend.waitForResponseComplete) { + await geminiBackend.waitForResponseComplete(120000); + logger.debug('[gemini] Response complete'); + } + + break; // Success, exit retry loop + } catch (promptError) { + lastError = promptError; + const errObj = promptError as any; + const errorDetails = errObj?.data?.details || errObj?.details || errObj?.message || ''; + const errorCode = errObj?.code; + + // Check for quota exhausted - this is NOT retryable + const isQuotaError = errorDetails.includes('exhausted') || + errorDetails.includes('quota') || + errorDetails.includes('capacity'); + if (isQuotaError) { + // Extract reset time from error message like "Your quota will reset after 3h20m35s." + const resetTimeMatch = errorDetails.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i); + let resetTimeMsg = ''; + if (resetTimeMatch) { + const parts = resetTimeMatch.slice(1).filter(Boolean).join(''); + resetTimeMsg = ` Quota resets in ${parts}.`; + } + const quotaMsg = `Gemini quota exceeded.${resetTimeMsg} Try using a different model (gemini-2.5-flash-lite) or wait for quota reset.`; + messageBuffer.addMessage(quotaMsg, 'status'); + session.sendAgentMessage('gemini', { type: 'message', message: quotaMsg }); + throw promptError; // Don't retry quota errors + } + + // Check if this is a retryable error (empty response, internal error -32603) + const isEmptyResponseError = errorDetails.includes('empty response') || + errorDetails.includes('Model stream ended'); + const isInternalError = errorCode === -32603; + const isRetryable = isEmptyResponseError || isInternalError; + + if (isRetryable && attempt < MAX_RETRIES) { + logger.debug(`[gemini] Retryable error on attempt ${attempt}/${MAX_RETRIES}: ${errorDetails}`); + messageBuffer.addMessage(`Gemini returned empty response, retrying (${attempt}/${MAX_RETRIES})...`, 'status'); + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS * attempt)); + continue; + } + + // Not retryable or max retries reached + throw promptError; + } + } + + if (lastError && MAX_RETRIES > 1) { + // If we had errors but eventually succeeded, log it + logger.debug('[gemini] Prompt succeeded after retries'); + } // Mark as not first message after sending prompt if (first) { @@ -1033,6 +1150,11 @@ export async function runGemini(opts: { const currentModel = displayedModel || 'gemini-2.5-pro'; errorMsg = `Model "${currentModel}" not found. Available models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite`; } + // Check for empty response / internal error after retries exhausted + else if (errorCode === -32603 || + errorDetails.includes('empty response') || errorDetails.includes('Model stream ended')) { + errorMsg = 'Gemini API returned empty response after retries. This is a temporary issue - please try again.'; + } // Check for rate limit error (429) - multiple possible formats else if (errorCode === 429 || errorDetails.includes('429') || errorMessage.includes('429') || errorString.includes('429') || @@ -1041,9 +1163,26 @@ export async function runGemini(opts: { errorString.includes('rateLimitExceeded') || errorString.includes('RESOURCE_EXHAUSTED')) { errorMsg = 'Gemini API rate limit exceeded. Please wait a moment and try again. The API will retry automatically.'; } - // Check for quota exceeded error - else if (errorDetails.includes('quota') || errorMessage.includes('quota') || errorString.includes('quota')) { - errorMsg = 'Gemini API daily quota exceeded. Please wait until quota resets or use a paid API key.'; + // Check for quota/capacity exceeded error + else if (errorDetails.includes('quota') || errorMessage.includes('quota') || errorString.includes('quota') || + errorDetails.includes('exhausted') || errorDetails.includes('capacity')) { + // Extract reset time from error message like "Your quota will reset after 3h20m35s." + const resetTimeMatch = (errorDetails + errorMessage + errorString).match(/reset after (\d+h)?(\d+m)?(\d+s)?/i); + let resetTimeMsg = ''; + if (resetTimeMatch) { + const parts = resetTimeMatch.slice(1).filter(Boolean).join(''); + resetTimeMsg = ` Quota resets in ${parts}.`; + } + errorMsg = `Gemini quota exceeded.${resetTimeMsg} Try using a different model (gemini-2.5-flash-lite) or wait for quota reset.`; + } + // Check for authentication error (Google Workspace accounts need project ID) + else if (errorMessage.includes('Authentication required') || + errorDetails.includes('Authentication required') || + errorCode === -32000) { + errorMsg = `Authentication required. For Google Workspace accounts, you need to set a Google Cloud Project:\n` + + ` happy gemini project set \n` + + `Or use a different Google account: happy connect gemini\n` + + `Guide: https://goo.gle/gemini-cli-auth-docs#workspace-gca`; } // Check for empty error (command not found) else if (Object.keys(error).length === 0) { @@ -1058,11 +1197,10 @@ export async function runGemini(opts: { } messageBuffer.addMessage(errorMsg, 'status'); - // Use sendCodexMessage for consistency with codex format - session.sendCodexMessage({ + // Use sendAgentMessage for consistency with ACP format + session.sendAgentMessage('gemini', { type: 'message', message: errorMsg, - id: randomUUID(), }); } } finally { @@ -1071,6 +1209,50 @@ export async function runGemini(opts: { reasoningProcessor.abort(); // Use abort to properly finish any in-progress tool calls diffProcessor.reset(); // Reset diff processor on turn completion + // Send accumulated response to mobile app ONLY when turn is complete + // This prevents message fragmentation from Gemini's chunked responses + if (accumulatedResponse.trim()) { + const { text: messageText, options } = parseOptionsFromText(accumulatedResponse); + + // Record assistant response in conversation history for context preservation + conversationHistory.addAssistantMessage(messageText); + + // Mobile app parses options from text via parseMarkdown + let finalMessageText = messageText; + if (options.length > 0) { + const optionsXml = formatOptionsXml(options); + finalMessageText = messageText + optionsXml; + logger.debug(`[gemini] Found ${options.length} options in response:`, options); + } else if (hasIncompleteOptions(accumulatedResponse)) { + logger.debug(`[gemini] Warning: Incomplete options block detected`); + } + + const messagePayload: CodexMessagePayload = { + type: 'message', + message: finalMessageText, + id: randomUUID(), + ...(options.length > 0 && { options }), + }; + + logger.debug(`[gemini] Sending complete message to mobile (length: ${finalMessageText.length}): ${finalMessageText.substring(0, 100)}...`); + session.sendAgentMessage('gemini', messagePayload); + accumulatedResponse = ''; + isResponseInProgress = false; + } + + // Send task_complete ONCE at the end of turn (not on every idle) + // This signals to the UI that the agent has finished processing + session.sendAgentMessage('gemini', { + type: 'task_complete', + id: randomUUID(), + }); + + // Reset tracking flags + hadToolCallInTurn = false; + pendingChangeTitle = false; + changeTitleCompleted = false; + taskStartedSent = false; + thinking = false; session.keepAlive(thinking, 'remote'); diff --git a/src/gemini/utils/config.ts b/src/gemini/utils/config.ts index c1e5344c..112954ef 100644 --- a/src/gemini/utils/config.ts +++ b/src/gemini/utils/config.ts @@ -18,6 +18,9 @@ import { GEMINI_MODEL_ENV, DEFAULT_GEMINI_MODEL } from '../constants'; export interface GeminiLocalConfig { token: string | null; model: string | null; + googleCloudProject: string | null; + /** Email associated with the stored Google Cloud Project (for per-account projects) */ + googleCloudProjectEmail: string | null; } /** @@ -27,6 +30,8 @@ export interface GeminiLocalConfig { export function readGeminiLocalConfig(): GeminiLocalConfig { let token: string | null = null; let model: string | null = null; + let googleCloudProject: string | null = null; + let googleCloudProjectEmail: string | null = null; // Try common Gemini CLI config locations // Gemini CLI stores OAuth tokens in ~/.gemini/oauth_creds.json after 'gemini auth' @@ -61,6 +66,19 @@ export function readGeminiLocalConfig(): GeminiLocalConfig { logger.debug(`[Gemini] Found model in ${configPath}: ${model}`); } } + + // Try to read Google Cloud Project from config + if (!googleCloudProject) { + const foundProject = config.googleCloudProject || config.google_cloud_project || config.projectId; + if (foundProject && typeof foundProject === 'string') { + googleCloudProject = foundProject; + // Also get the associated email if stored + if (config.googleCloudProjectEmail && typeof config.googleCloudProjectEmail === 'string') { + googleCloudProjectEmail = config.googleCloudProjectEmail; + } + logger.debug(`[Gemini] Found Google Cloud Project in ${configPath}: ${googleCloudProject}${googleCloudProjectEmail ? ` (for ${googleCloudProjectEmail})` : ''}`); + } + } } catch (error) { logger.debug(`[Gemini] Failed to read config from ${configPath}:`, error); } @@ -86,7 +104,17 @@ export function readGeminiLocalConfig(): GeminiLocalConfig { } } - return { token, model }; + // Also check environment variable for Google Cloud Project + if (!googleCloudProject) { + const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; + if (envProject) { + googleCloudProject = envProject; + googleCloudProjectEmail = null; // Env var applies to all accounts + logger.debug(`[Gemini] Found Google Cloud Project from env: ${googleCloudProject}`); + } + } + + return { token, model, googleCloudProject, googleCloudProjectEmail }; } /** @@ -161,6 +189,49 @@ export function saveGeminiModelToConfig(model: string): void { } } +/** + * Save Google Cloud Project ID to Gemini config file + * + * @param projectId - The Google Cloud Project ID to save + * @param email - Optional email to associate with this project (for per-account projects) + */ +export function saveGoogleCloudProjectToConfig(projectId: string, email?: string): void { + try { + const configDir = join(homedir(), '.gemini'); + const configPath = join(configDir, 'config.json'); + + // Create directory if it doesn't exist + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); + } + + // Read existing config or create new one + let config: Record = {}; + if (existsSync(configPath)) { + try { + config = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + config = {}; + } + } + + // Update project in config + config.googleCloudProject = projectId; + + // Store the associated email if provided + if (email) { + config.googleCloudProjectEmail = email; + } + + // Write config back + writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + logger.debug(`[Gemini] Saved Google Cloud Project "${projectId}"${email ? ` for ${email}` : ''} to ${configPath}`); + } catch (error) { + logger.debug(`[Gemini] Failed to save Google Cloud Project to config:`, error); + throw error; // This is important - let user know if save failed + } +} + /** * Get the initial model value for UI display * Priority: env var > local config > default diff --git a/src/gemini/utils/conversationHistory.ts b/src/gemini/utils/conversationHistory.ts new file mode 100644 index 00000000..859c705e --- /dev/null +++ b/src/gemini/utils/conversationHistory.ts @@ -0,0 +1,163 @@ +/** + * Conversation History + * + * Tracks user messages and agent responses to preserve context + * when switching Gemini models. This allows seamless model changes + * without losing conversation context. + */ + +import { logger } from '@/ui/logger'; + +export interface ConversationMessage { + role: 'user' | 'assistant'; + content: string; + timestamp: number; + model?: string; // Track which model generated this response +} + +export interface ConversationHistoryOptions { + /** Maximum number of messages to keep (default: 20) */ + maxMessages?: number; + /** Maximum total characters to keep (default: 50000) */ + maxCharacters?: number; +} + +/** + * Manages conversation history for context preservation across model changes. + * + * When the user switches models, this class provides the previous conversation + * as context for the new model, ensuring continuity. + */ +export class ConversationHistory { + private messages: ConversationMessage[] = []; + private readonly maxMessages: number; + private readonly maxCharacters: number; + private currentModel: string | undefined; + + constructor(options: ConversationHistoryOptions = {}) { + this.maxMessages = options.maxMessages ?? 20; + this.maxCharacters = options.maxCharacters ?? 50000; + } + + /** + * Set the current model being used + */ + setCurrentModel(model: string | undefined): void { + this.currentModel = model; + } + + /** + * Add a user message to history + */ + addUserMessage(content: string): void { + if (!content.trim()) return; + + this.messages.push({ + role: 'user', + content: content.trim(), + timestamp: Date.now(), + }); + + this.trimHistory(); + logger.debug(`[ConversationHistory] Added user message (${content.length} chars), total: ${this.messages.length}`); + } + + /** + * Add an assistant response to history + */ + addAssistantMessage(content: string): void { + if (!content.trim()) return; + + this.messages.push({ + role: 'assistant', + content: content.trim(), + timestamp: Date.now(), + model: this.currentModel, + }); + + this.trimHistory(); + logger.debug(`[ConversationHistory] Added assistant message (${content.length} chars), total: ${this.messages.length}`); + } + + /** + * Get the number of messages in history + */ + size(): number { + return this.messages.length; + } + + /** + * Check if there's any history to preserve + */ + hasHistory(): boolean { + return this.messages.length > 0; + } + + /** + * Clear all history + */ + clear(): void { + this.messages = []; + logger.debug('[ConversationHistory] History cleared'); + } + + /** + * Get formatted context for injecting into a new session. + * This is used when the model changes to preserve conversation context. + * + * @returns Formatted string with previous conversation context, or empty string if no history + */ + getContextForNewSession(): string { + if (this.messages.length === 0) { + return ''; + } + + const formattedMessages = this.messages.map(msg => { + const role = msg.role === 'user' ? 'User' : 'Assistant'; + // Truncate very long messages to avoid token limits + const content = msg.content.length > 2000 + ? msg.content.substring(0, 2000) + '... [truncated]' + : msg.content; + return `${role}: ${content}`; + }).join('\n\n'); + + return `[PREVIOUS CONVERSATION CONTEXT] +The following is our previous conversation. Continue from where we left off: + +${formattedMessages} + +[END OF PREVIOUS CONTEXT] + +`; + } + + /** + * Trim history to stay within limits + */ + private trimHistory(): void { + // Trim by message count + while (this.messages.length > this.maxMessages) { + this.messages.shift(); + } + + // Trim by total character count + let totalChars = this.messages.reduce((sum, msg) => sum + msg.content.length, 0); + while (totalChars > this.maxCharacters && this.messages.length > 1) { + const removed = this.messages.shift(); + if (removed) { + totalChars -= removed.content.length; + } + } + } + + /** + * Get a summary of the conversation for logging/debugging + */ + getSummary(): string { + const totalChars = this.messages.reduce((sum, msg) => sum + msg.content.length, 0); + const userCount = this.messages.filter(m => m.role === 'user').length; + const assistantCount = this.messages.filter(m => m.role === 'assistant').length; + return `${this.messages.length} messages (${userCount} user, ${assistantCount} assistant), ${totalChars} chars`; + } +} + diff --git a/src/index.ts b/src/index.ts index 1c4a8b65..c7ec6b15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -200,6 +200,93 @@ import { execFileSync } from 'node:child_process' } } + // Handle "happy gemini project set " command + if (geminiSubcommand === 'project' && args[2] === 'set' && args[3]) { + const projectId = args[3]; + + try { + const { saveGoogleCloudProjectToConfig } = await import('@/gemini/utils/config'); + const { readCredentials } = await import('@/persistence'); + const { ApiClient } = await import('@/api/api'); + + // Try to get current user email from Happy cloud token + let userEmail: string | undefined = undefined; + try { + const credentials = await readCredentials(); + if (credentials) { + const api = await ApiClient.create(credentials); + const vendorToken = await api.getVendorToken('gemini'); + if (vendorToken?.oauth?.id_token) { + const parts = vendorToken.oauth.id_token.split('.'); + if (parts.length === 3) { + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + userEmail = payload.email; + } + } + } + } catch { + // If we can't get email, project will be saved globally + } + + saveGoogleCloudProjectToConfig(projectId, userEmail); + console.log(`✓ Google Cloud Project set to: ${projectId}`); + if (userEmail) { + console.log(` Linked to account: ${userEmail}`); + } + console.log(` This project will be used for Google Workspace accounts.`); + process.exit(0); + } catch (error) { + console.error('Failed to save project configuration:', error); + process.exit(1); + } + } + + // Handle "happy gemini project get" command + if (geminiSubcommand === 'project' && args[2] === 'get') { + try { + const { readGeminiLocalConfig } = await import('@/gemini/utils/config'); + const config = readGeminiLocalConfig(); + + if (config.googleCloudProject) { + console.log(`Current Google Cloud Project: ${config.googleCloudProject}`); + if (config.googleCloudProjectEmail) { + console.log(` Linked to account: ${config.googleCloudProjectEmail}`); + } else { + console.log(` Applies to: all accounts (global)`); + } + } else if (process.env.GOOGLE_CLOUD_PROJECT) { + console.log(`Current Google Cloud Project: ${process.env.GOOGLE_CLOUD_PROJECT} (from env var)`); + } else { + console.log('No Google Cloud Project configured.'); + console.log(''); + console.log('If you see "Authentication required" error, you may need to set a project:'); + console.log(' happy gemini project set '); + console.log(''); + console.log('This is required for Google Workspace accounts.'); + console.log('Guide: https://goo.gle/gemini-cli-auth-docs#workspace-gca'); + } + process.exit(0); + } catch (error) { + console.error('Failed to read project configuration:', error); + process.exit(1); + } + } + + // Handle "happy gemini project" (no subcommand) - show help + if (geminiSubcommand === 'project' && !args[2]) { + console.log('Usage: happy gemini project '); + console.log(''); + console.log('Commands:'); + console.log(' set Set Google Cloud Project ID'); + console.log(' get Show current Google Cloud Project ID'); + console.log(''); + console.log('Google Workspace accounts require a Google Cloud Project.'); + console.log('If you see "Authentication required" error, set your project ID.'); + console.log(''); + console.log('Guide: https://goo.gle/gemini-cli-auth-docs#workspace-gca'); + process.exit(0); + } + // Handle gemini command (ACP-based agent) try { const { runGemini } = await import('@/gemini/runGemini'); diff --git a/src/ui/ink/GeminiDisplay.tsx b/src/ui/ink/GeminiDisplay.tsx index cd8e65f5..a54631fd 100644 --- a/src/ui/ink/GeminiDisplay.tsx +++ b/src/ui/ink/GeminiDisplay.tsx @@ -40,7 +40,8 @@ export const GeminiDisplay: React.FC = ({ messageBuffer, log setMessages(newMessages); // Extract model from [MODEL:...] messages when messages update - const modelMessage = newMessages.find(msg => + // Use reverse + find to get the LATEST model message (in case model was changed) + const modelMessage = [...newMessages].reverse().find(msg => msg.type === 'system' && msg.content.startsWith('[MODEL:') ); diff --git a/src/utils/offlineSessionStub.ts b/src/utils/offlineSessionStub.ts index 54f51a6e..50d67ab6 100644 --- a/src/utils/offlineSessionStub.ts +++ b/src/utils/offlineSessionStub.ts @@ -35,6 +35,7 @@ export function createOfflineSessionStub(sessionTag: string): ApiSessionClient { return { sessionId: `offline-${sessionTag}`, sendCodexMessage: () => {}, + sendAgentMessage: () => {}, sendClaudeSessionMessage: () => {}, keepAlive: () => {}, sendSessionEvent: () => {},