diff --git a/src/main/conductor/AgentProcessManager.ts b/src/main/conductor/AgentProcessManager.ts new file mode 100644 index 0000000000..b80a1c22b8 --- /dev/null +++ b/src/main/conductor/AgentProcessManager.ts @@ -0,0 +1,181 @@ +/** + * AgentProcessManager - Manages agent process lifecycle + * + * Responsibilities: + * - Starting agent subprocesses for sessions + * - Managing agent process pool (sessions Map) + * - Stopping individual or all agent processes + * - Tracking running sessions + */ +import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { AgentProcess } from './AgentProcess' +import { createAcpClient } from './AcpClientFactory' +import type { AgentConfig } from '../../shared/types' +import type { + IAgentProcessManager, + AgentProcessManagerOptions, + SessionAgent, + AgentStartResult, + ISessionStore +} from './types' + +export class AgentProcessManager implements IAgentProcessManager { + private sessionStore: ISessionStore | null + private events: AgentProcessManagerOptions['events'] + + /** + * Map of Multica sessionId -> agent state (each session has its own process) + */ + private sessions: Map = new Map() + + constructor(options: AgentProcessManagerOptions) { + this.sessionStore = options.sessionStore + this.events = options.events + } + + /** + * Start an agent process for a session + * @param sessionId - Multica session ID + * @param config - Agent configuration + * @param cwd - Working directory + * @param isResumed - Whether this is a resumed session (needs history replay) + */ + async start( + sessionId: string, + config: AgentConfig, + cwd: string, + isResumed: boolean = false + ): Promise { + console.log(`[AgentProcessManager] Starting agent for session ${sessionId}: ${config.name}`) + + // Start the agent subprocess + const agentProcess = new AgentProcess(config) + await agentProcess.start() + + // Create ACP connection using the SDK + const stream = ndJsonStream(agentProcess.getStdinWeb(), agentProcess.getStdoutWeb()) + + // Create client-side connection with our Client implementation + const connection = new ClientSideConnection( + (_agent) => + createAcpClient(sessionId, { + sessionStore: this.sessionStore as any, // Cast to SessionStore for compatibility + callbacks: { + onSessionUpdate: this.events.onSessionUpdate, + onPermissionRequest: this.events.onPermissionRequest + } + }), + stream + ) + + // Initialize the ACP connection + const initResult = await connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { + readTextFile: false, + writeTextFile: false + }, + terminal: false + } + }) + + console.log( + `[AgentProcessManager] ACP connected to ${config.name} (protocol v${initResult.protocolVersion})` + ) + + // Create ACP session + const acpResult = await connection.newSession({ + cwd, + mcpServers: [] + }) + + // Handle agent process exit + agentProcess.onExit((code, signal) => { + console.log( + `[AgentProcessManager] Agent for session ${sessionId} exited (code: ${code}, signal: ${signal})` + ) + this.sessions.delete(sessionId) + }) + + // Store in sessions map + const sessionAgent: SessionAgent = { + agentProcess, + connection, + agentConfig: config, + agentSessionId: acpResult.sessionId, + needsHistoryReplay: isResumed // True when resuming, agent needs conversation context + } + this.sessions.set(sessionId, sessionAgent) + + return { connection, agentSessionId: acpResult.sessionId } + } + + /** + * Stop a session's agent process + */ + async stop(sessionId: string): Promise { + const sessionAgent = this.sessions.get(sessionId) + if (sessionAgent) { + console.log( + `[AgentProcessManager] Stopping session ${sessionId} agent: ${sessionAgent.agentConfig.name}` + ) + await sessionAgent.agentProcess.stop() + this.sessions.delete(sessionId) + console.log(`[AgentProcessManager] Session ${sessionId} agent stopped`) + } + } + + /** + * Stop all running agent processes + */ + async stopAll(): Promise { + const sessionIds = Array.from(this.sessions.keys()) + for (const sessionId of sessionIds) { + await this.stop(sessionId) + } + } + + /** + * Get session agent state by session ID + */ + get(sessionId: string): SessionAgent | undefined { + return this.sessions.get(sessionId) + } + + /** + * Set session agent state (used by other modules when needed) + */ + set(sessionId: string, sessionAgent: SessionAgent): void { + this.sessions.set(sessionId, sessionAgent) + } + + /** + * Remove session agent state + */ + remove(sessionId: string): void { + this.sessions.delete(sessionId) + } + + /** + * Check if a session has a running agent + */ + isRunning(sessionId: string): boolean { + const sessionAgent = this.sessions.get(sessionId) + return sessionAgent?.agentProcess.isRunning() ?? false + } + + /** + * Get all running session IDs + */ + getRunningSessionIds(): string[] { + return Array.from(this.sessions.keys()) + } + + /** + * Get agent config for a session + */ + getAgentConfig(sessionId: string): AgentConfig | null { + return this.sessions.get(sessionId)?.agentConfig ?? null + } +} diff --git a/src/main/conductor/Conductor.ts b/src/main/conductor/Conductor.ts index 56d0cd3875..505cbb4871 100644 --- a/src/main/conductor/Conductor.ts +++ b/src/main/conductor/Conductor.ts @@ -1,23 +1,15 @@ /** - * Conductor - Central orchestrator for ACP agent communication + * Conductor - Central orchestrator for ACP agent communication (Facade) * - * Responsibilities: - * - Session lifecycle management (create, resume, load, delete) - * - Agent process orchestration - * - Prompt handling with history replay + * This class acts as a facade that coordinates the following modules: + * - SessionLifecycle: Session CRUD operations + * - AgentProcessManager: Agent process lifecycle + * - PromptHandler: Prompt sending with history replay + * - G3Workaround: AskUserQuestion answer injection + * + * The public API remains unchanged for backward compatibility with callers. */ -import { - ClientSideConnection, - ndJsonStream, - PROTOCOL_VERSION, - type SessionNotification, - type RequestPermissionRequest, - type RequestPermissionResponse -} from '@agentclientprotocol/sdk' -import { AgentProcess } from './AgentProcess' import { SessionStore } from '../session/SessionStore' -import { DEFAULT_AGENTS } from '../config/defaults' -import { createAcpClient } from './AcpClientFactory' import type { AgentConfig, MulticaSession, @@ -25,537 +17,112 @@ import type { ListSessionsOptions, AskUserQuestionResponseData } from '../../shared/types' -import type { MessageContent, MessageContentItem } from '../../shared/types/message' -import { formatHistoryForReplay, hasReplayableHistory } from './historyReplay' +import type { MessageContent } from '../../shared/types/message' -export interface SessionUpdateCallback { - (update: SessionNotification, sequenceNumber?: number): void -} +// Import modules +import { AgentProcessManager } from './AgentProcessManager' +import { PromptHandler } from './PromptHandler' +import { G3Workaround } from './G3Workaround' +import { SessionLifecycle } from './SessionLifecycle' -export interface ConductorEvents { - onSessionUpdate?: SessionUpdateCallback - onPermissionRequest?: (params: RequestPermissionRequest) => Promise - onStatusChange?: () => void - /** Called when session metadata changes (e.g., agentSessionId after lazy start) */ - onSessionMetaUpdated?: (session: MulticaSession) => void -} +// Import types +import type { + ConductorEvents, + ConductorOptions, + SessionUpdateCallback, + ISessionStore +} from './types' -export interface ConductorOptions { - events?: ConductorEvents - /** Skip session persistence (for CLI mode) */ - skipPersistence?: boolean - /** Custom storage path for sessions */ - storagePath?: string -} - -/** Per-session agent state */ -interface SessionAgent { - agentProcess: AgentProcess - connection: ClientSideConnection - agentConfig: AgentConfig - agentSessionId: string - /** Whether the first prompt needs history prepended (for resumed sessions) */ - needsHistoryReplay: boolean -} +// Re-export types for backward compatibility +export type { SessionUpdateCallback, ConductorEvents, ConductorOptions } export class Conductor { private events: ConductorEvents - private sessionStore: SessionStore | null = null + private sessionStore: ISessionStore | null = null private skipPersistence: boolean - // Map of Multica sessionId -> agent state (each session has its own process) - private sessions: Map = new Map() - // Set of session IDs currently processing a request - private processingSessions: Set = new Set() - // In-memory session for CLI mode (when persistence is skipped) - private inMemorySession: MulticaSession | null = null - - /** - * Pending answers from AskUserQuestion tool (G-3 workaround) - * - * ACP's AskUserQuestion tool has a limitation: it only returns - * "User has answered the question(s)" to the agent, without the actual - * user selection. This Map stores the user's answers so they can be - * injected into the next prompt as context. - * - * Flow: - * 1. User selects an option in AskUserQuestion UI - * 2. Answer is stored here via addPendingAnswer() - * 3. Current agent turn is cancelled (G-3 mechanism) - * 4. Re-prompt is sent, which injects these answers as context - * 5. Agent now sees the actual user selection - */ - private pendingAnswers: Map> = new Map() + // Modules + private agentProcessManager: AgentProcessManager + private g3Workaround: G3Workaround + private promptHandler: PromptHandler + private sessionLifecycle: SessionLifecycle constructor(options: ConductorOptions = {}) { this.events = options.events ?? {} this.skipPersistence = options.skipPersistence ?? false + + // Initialize session store if (!this.skipPersistence) { - this.sessionStore = new SessionStore(options.storagePath) + this.sessionStore = new SessionStore(options.storagePath) as unknown as ISessionStore } + + // Initialize modules with dependencies + this.agentProcessManager = new AgentProcessManager({ + sessionStore: this.sessionStore, + events: this.events + }) + + this.g3Workaround = new G3Workaround({ + sessionStore: this.sessionStore, + events: this.events + }) + + this.sessionLifecycle = new SessionLifecycle({ + sessionStore: this.sessionStore, + agentProcessManager: this.agentProcessManager, + events: this.events + }) + + this.promptHandler = new PromptHandler({ + sessionStore: this.sessionStore, + agentProcessManager: this.agentProcessManager, + g3Workaround: this.g3Workaround, + events: this.events, + ensureAgent: (sessionId: string) => this.sessionLifecycle.ensureAgentForSession(sessionId) + }) } + // ========================================================================== + // Initialization + // ========================================================================== + /** * Initialize the conductor (must be called before use in GUI mode) */ async initialize(): Promise { - if (this.sessionStore) { - await this.sessionStore.initialize() - } + await this.sessionLifecycle.initialize() } - /** - * Start an agent process for a session (internal helper) - * @param isResumed - Whether this is resuming an existing session (needs history replay) - */ - private async startAgentForSession( - sessionId: string, - config: AgentConfig, - cwd: string, - isResumed: boolean = false - ): Promise<{ connection: ClientSideConnection; agentSessionId: string }> { - console.log(`[Conductor] Starting agent for session ${sessionId}: ${config.name}`) - - // Start the agent subprocess - const agentProcess = new AgentProcess(config) - await agentProcess.start() - - // Create ACP connection using the SDK - const stream = ndJsonStream(agentProcess.getStdinWeb(), agentProcess.getStdoutWeb()) - - // Create client-side connection with our Client implementation - const connection = new ClientSideConnection( - (_agent) => - createAcpClient(sessionId, { - sessionStore: this.sessionStore, - callbacks: { - onSessionUpdate: this.events.onSessionUpdate, - onPermissionRequest: this.events.onPermissionRequest - } - }), - stream - ) - - // Initialize the ACP connection - const initResult = await connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: { - fs: { - readTextFile: false, - writeTextFile: false - }, - terminal: false - } - }) - - console.log( - `[Conductor] ACP connected to ${config.name} (protocol v${initResult.protocolVersion})` - ) - - // Create ACP session - const acpResult = await connection.newSession({ - cwd, - mcpServers: [] - }) - - // Handle agent process exit - agentProcess.onExit((code, signal) => { - console.log( - `[Conductor] Agent for session ${sessionId} exited (code: ${code}, signal: ${signal})` - ) - this.sessions.delete(sessionId) - }) - - // Store in sessions map - this.sessions.set(sessionId, { - agentProcess, - connection, - agentConfig: config, - agentSessionId: acpResult.sessionId, - needsHistoryReplay: isResumed // True when resuming, agent needs conversation context - }) - - return { connection, agentSessionId: acpResult.sessionId } - } - - /** - * Stop a session's agent process - */ - async stopSession(sessionId: string): Promise { - const sessionAgent = this.sessions.get(sessionId) - if (sessionAgent) { - console.log( - `[Conductor] Stopping session ${sessionId} agent: ${sessionAgent.agentConfig.name}` - ) - await sessionAgent.agentProcess.stop() - this.sessions.delete(sessionId) - console.log(`[Conductor] Session ${sessionId} agent stopped`) - } - } - - /** - * Stop all session agents - */ - async stopAllSessions(): Promise { - const sessionIds = Array.from(this.sessions.keys()) - for (const sessionId of sessionIds) { - await this.stopSession(sessionId) - } - } + // ========================================================================== + // Session Lifecycle (delegated to SessionLifecycle) + // ========================================================================== /** * Create a new session (agent starts lazily on first prompt) */ async createSession(cwd: string, agentConfig: AgentConfig): Promise { - let session: MulticaSession - - if (this.sessionStore) { - // Create session record - agent will be started lazily via ensureAgentForSession - session = await this.sessionStore.create({ - agentSessionId: '', // Will be filled by ensureAgentForSession on first prompt - agentId: agentConfig.id, - workingDirectory: cwd - }) - } else { - // CLI mode: in-memory session - const { randomUUID } = await import('crypto') - const now = new Date().toISOString() - session = { - id: randomUUID(), - agentSessionId: '', - agentId: agentConfig.id, - workingDirectory: cwd, - createdAt: now, - updatedAt: now, - status: 'active', - messageCount: 0 - } - this.inMemorySession = session - } - - // Agent will be started lazily when user sends first prompt (via ensureAgentForSession) - console.log(`[Conductor] Created session: ${session.id} (agent: pending)`) - - return session + return this.sessionLifecycle.create(cwd, agentConfig) } /** * Resume an existing session (starts a new agent process for it) */ async resumeSession(sessionId: string): Promise { - if (!this.sessionStore) { - throw new Error('Session resumption not available in CLI mode') - } - - const data = await this.sessionStore.get(sessionId) - if (!data) { - throw new Error(`Session not found: ${sessionId}`) - } - - // If session already has a running agent, return as-is - if (this.sessions.has(sessionId)) { - console.log(`[Conductor] Session ${sessionId} already has a running agent`) - return data.session - } - - // Get agent config - const agentConfig = DEFAULT_AGENTS[data.session.agentId] - if (!agentConfig) { - throw new Error(`Unknown agent: ${data.session.agentId}`) - } - - // Start a new agent process for this session (isResumed = true) - const { agentSessionId } = await this.startAgentForSession( - sessionId, - agentConfig, - data.session.workingDirectory, - true // Resumed session needs history replay - ) - - // Update agentSessionId (new ACP session) - const updatedSession = await this.sessionStore.updateMeta(sessionId, { - agentSessionId, - status: 'active' - }) - - console.log(`[Conductor] Resumed session: ${sessionId} (new agent session: ${agentSessionId})`) - - return updatedSession - } - - /** - * Send a prompt to the agent (supports text and images) - * @param sessionId - Multica session ID - * @param content - Message content (text, images) - * @param options - Optional settings - * @param options.internal - If true, message is sent to agent but not displayed in UI - * Used by G-3 mechanism to send user answers without creating visible messages - */ - async sendPrompt( - sessionId: string, - content: MessageContent, - options?: { internal?: boolean } - ): Promise { - // Ensure agent is running (lazy start if needed) - const sessionAgent = await this.ensureAgentForSession(sessionId) - const { connection, agentSessionId } = sessionAgent - - // Convert MessageContent to ACP SDK format - const convertToAcpFormat = ( - items: MessageContent - ): Array<{ type: string; text?: string; data?: string; mimeType?: string }> => { - return items.map((item: MessageContentItem) => { - if (item.type === 'text') { - return { type: 'text', text: item.text } - } else if (item.type === 'image') { - return { type: 'image', data: item.data, mimeType: item.mimeType } - } - return { type: 'text', text: '' } // fallback - }) - } - - // Build prompt content array - let promptContent = convertToAcpFormat(content) - - // If this is a resumed session, prepend conversation history to first prompt - if (sessionAgent.needsHistoryReplay && this.sessionStore) { - try { - const data = await this.sessionStore.get(sessionId) - if (data && hasReplayableHistory(data.updates)) { - const history = formatHistoryForReplay(data.updates) - if (history) { - console.log( - `[Conductor] Prepending conversation history (${data.updates.length} updates)` - ) - // Prepend history as text block before other content - promptContent = [{ type: 'text', text: history }, ...promptContent] - } - } - } catch (error) { - console.error(`[Conductor] Failed to load history for replay:`, error) - // Continue without history - better than blocking the prompt - } finally { - // Always mark as replayed to prevent repeated attempts - sessionAgent.needsHistoryReplay = false - } - } - - // Inject pending user answers from AskUserQuestion (G-3 workaround) - // See pendingAnswers field comment for the full explanation - const pendingAnswers = this.getPendingAnswers(sessionId) - if (pendingAnswers.length > 0) { - const answerContext = pendingAnswers - .map((a) => `[User's answer to "${a.question}"]: ${a.answer}`) - .join('\n') - - console.log( - `[Conductor] Injecting ${pendingAnswers.length} pending answer(s) for session ${sessionId}` - ) - - // Prepend answers as context before user's message - promptContent = [{ type: 'text', text: `---\n${answerContext}\n---\n` }, ...promptContent] - - // Clear pending answers after injection - this.clearPendingAnswers(sessionId) - } - - // Log prompt info - const textContent = content.find((c: MessageContentItem) => c.type === 'text') - const imageCount = content.filter((c: MessageContentItem) => c.type === 'image').length - console.log(`[Conductor] Sending prompt to session ${agentSessionId}`) - if (textContent && textContent.type === 'text') { - console.log( - `[Conductor] Text: ${textContent.text.slice(0, 100)}${textContent.text.length > 100 ? '...' : ''}` - ) - } - if (imageCount > 0) { - console.log(`[Conductor] Images: ${imageCount}`) - } - - // Store user message before sending (so it appears in history) - // Internal messages are stored with _internal flag for filtering in UI - if (this.sessionStore) { - const userUpdate = { - sessionId: agentSessionId, - update: { - sessionUpdate: 'user_message', - content: content, // Store full MessageContent array - _internal: options?.internal ?? false // G-3: internal messages not shown in UI - } - } - await this.sessionStore.appendUpdate(sessionId, userUpdate as any) - } - - // Mark session as processing and broadcast status change - this.processingSessions.add(sessionId) - this.events.onStatusChange?.() - - try { - const result = await connection.prompt({ - sessionId: agentSessionId, - prompt: promptContent as any - }) - - console.log(`[Conductor] Prompt completed with stopReason: ${result.stopReason}`) - - return result.stopReason - } catch (error) { - console.error(`[Conductor] ACP error for session ${sessionId}:`, error) - - // Parse ACP error to user-friendly message - const message = this.parseAcpError(error) - - // DESIGN DECISION: Event-driven error handling instead of throwing - // - // Why not throw? - // - Throwing causes IPC handler to fail, frontend receives an error response - // - This typically triggers a toast/dialog, interrupting user flow - // - // Why emit an event? - // - Error appears inline in the chat as a message, keeping context visible - // - User can read the error and continue the conversation naturally - // - Consistent with how agent responses are already delivered (via events) - // - // This pattern is similar to how OpenWork handles task errors: - // errors are forwarded to the renderer as updates, not thrown as exceptions. - if (this.events.onSessionUpdate) { - this.events.onSessionUpdate({ - sessionId: agentSessionId, - update: { - sessionUpdate: 'agent_message_chunk', - content: { - type: 'text', - text: `\n\n**Error:** ${message}\n` - } - } - } as SessionNotification) - } - - // Return 'error' as stopReason - IPC succeeds, caller knows it was an error - return 'error' - } finally { - // Always remove from processing when done (success or error) - this.processingSessions.delete(sessionId) - this.events.onStatusChange?.() - } - } - - /** - * Cancel an ongoing request - */ - async cancelRequest(sessionId: string): Promise { - const sessionAgent = this.sessions.get(sessionId) - if (!sessionAgent) { - return - } - - const { connection, agentSessionId } = sessionAgent - console.log(`[Conductor] Cancelling request for session ${agentSessionId}`) - await connection.cancel({ sessionId: agentSessionId }) - console.log(`[Conductor] Cancel request sent`) - } - - /** - * Get session list - */ - async listSessions(options?: ListSessionsOptions): Promise { - if (!this.sessionStore) { - return this.inMemorySession ? [this.inMemorySession] : [] - } - return this.sessionStore.list(options) - } - - /** - * Get session complete data (including message history) - */ - async getSessionData(sessionId: string): Promise { - if (!this.sessionStore) { - return null - } - return this.sessionStore.get(sessionId) + return this.sessionLifecycle.resume(sessionId) } /** * Load a session without starting its agent (lazy loading) */ async loadSession(sessionId: string): Promise { - if (!this.sessionStore) { - throw new Error('Session loading not available in CLI mode') - } - - const data = await this.sessionStore.get(sessionId) - if (!data) { - throw new Error(`Session not found: ${sessionId}`) - } - - return data.session - } - - /** - * Ensure an agent is running for a session (start if needed) - */ - private async ensureAgentForSession(sessionId: string): Promise { - // If already running, return existing - const existing = this.sessions.get(sessionId) - if (existing) { - return existing - } - - // Load session data - if (!this.sessionStore) { - throw new Error('Cannot auto-start agent in CLI mode') - } - - const data = await this.sessionStore.get(sessionId) - if (!data) { - throw new Error(`Session not found: ${sessionId}`) - } - - // Get agent config - const agentConfig = DEFAULT_AGENTS[data.session.agentId] - if (!agentConfig) { - throw new Error(`Unknown agent: ${data.session.agentId}`) - } - - console.log(`[Conductor] Lazy-starting agent for session ${sessionId}`) - - // Start agent (isResumed = true for lazy start of existing session) - const { agentSessionId } = await this.startAgentForSession( - sessionId, - agentConfig, - data.session.workingDirectory, - true // Existing session needs history replay - ) - - // Update session with new agentSessionId - const updatedSession = await this.sessionStore.updateMeta(sessionId, { - agentSessionId, - status: 'active' - }) - - // Notify frontend of session metadata change (important for agentSessionId update) - // This must happen BEFORE sending the prompt so frontend can receive messages - if (this.events.onSessionMetaUpdated) { - this.events.onSessionMetaUpdated(updatedSession) - } - - return this.sessions.get(sessionId)! + return this.sessionLifecycle.load(sessionId) } /** * Delete a session */ async deleteSession(sessionId: string): Promise { - // Stop the session's agent process first - await this.stopSession(sessionId) - - // Delete from store - if (this.sessionStore) { - await this.sessionStore.delete(sessionId) - } - if (this.inMemorySession?.id === sessionId) { - this.inMemorySession = null - } + return this.sessionLifecycle.delete(sessionId) } /** @@ -565,142 +132,136 @@ export class Conductor { sessionId: string, updates: Partial ): Promise { - if (!this.sessionStore) { - throw new Error('Session update not available in CLI mode') - } - return this.sessionStore.updateMeta(sessionId, updates) + return this.sessionLifecycle.updateMeta(sessionId, updates) } /** * Switch a session's agent (stops current, updates, starts new) */ async switchSessionAgent(sessionId: string, newAgentId: string): Promise { - if (!this.sessionStore) { - throw new Error('Session agent switch not available in CLI mode') - } + return this.sessionLifecycle.switchAgent(sessionId, newAgentId) + } - const data = await this.sessionStore.get(sessionId) - if (!data) { - throw new Error(`Session not found: ${sessionId}`) - } + /** + * Get session list + */ + async listSessions(options?: ListSessionsOptions): Promise { + return this.sessionLifecycle.list(options) + } - // Get new agent config - const newAgentConfig = DEFAULT_AGENTS[newAgentId] - if (!newAgentConfig) { - throw new Error(`Unknown agent: ${newAgentId}`) - } + /** + * Get session complete data (including message history) + */ + async getSessionData(sessionId: string): Promise { + return this.sessionLifecycle.getData(sessionId) + } - console.log( - `[Conductor] Switching session ${sessionId} from ${data.session.agentId} to ${newAgentId}` - ) + // ========================================================================== + // Agent Process Management (delegated to AgentProcessManager) + // ========================================================================== - // Stop current agent if running - await this.stopSession(sessionId) + /** + * Stop a session's agent process + */ + async stopSession(sessionId: string): Promise { + return this.agentProcessManager.stop(sessionId) + } - // Update session's agentId - let updatedSession = await this.sessionStore.updateMeta(sessionId, { - agentId: newAgentId - }) - - // Start new agent (isResumed = true to replay history) - const { agentSessionId } = await this.startAgentForSession( - sessionId, - newAgentConfig, - data.session.workingDirectory, - true - ) - - // Update agentSessionId - updatedSession = await this.sessionStore.updateMeta(sessionId, { - agentSessionId, - status: 'active' - }) - - // Notify frontend - if (this.events.onSessionMetaUpdated) { - this.events.onSessionMetaUpdated(updatedSession) - } - - console.log( - `[Conductor] Session ${sessionId} switched to ${newAgentId} (agent session: ${agentSessionId})` - ) - - return updatedSession + /** + * Stop all session agents + */ + async stopAllSessions(): Promise { + return this.agentProcessManager.stopAll() } /** * Get agent config for a session */ getSessionAgent(sessionId: string): AgentConfig | null { - return this.sessions.get(sessionId)?.agentConfig ?? null + return this.agentProcessManager.getAgentConfig(sessionId) } /** * Check if a session has a running agent */ isSessionRunning(sessionId: string): boolean { - const sessionAgent = this.sessions.get(sessionId) - return sessionAgent?.agentProcess.isRunning() ?? false + return this.agentProcessManager.isRunning(sessionId) } /** * Get all running session IDs (sessions with agent process running) */ getRunningSessionIds(): string[] { - return Array.from(this.sessions.keys()) + return this.agentProcessManager.getRunningSessionIds() + } + + // ========================================================================== + // Prompt Handling (delegated to PromptHandler) + // ========================================================================== + + /** + * Send a prompt to the agent (supports text and images) + */ + async sendPrompt( + sessionId: string, + content: MessageContent, + options?: { internal?: boolean } + ): Promise { + return this.promptHandler.send(sessionId, content, options) + } + + /** + * Cancel an ongoing request + */ + async cancelRequest(sessionId: string): Promise { + return this.promptHandler.cancel(sessionId) } /** * Get all processing session IDs (sessions currently handling a request) */ getProcessingSessionIds(): string[] { - return Array.from(this.processingSessions) + return this.promptHandler.getProcessingSessionIds() } /** * Check if a session is currently processing a request - * Used by G-3 handler to wait for cancel to complete before re-prompting */ isSessionProcessing(sessionId: string): boolean { - return this.processingSessions.has(sessionId) + return this.promptHandler.isProcessing(sessionId) + } + + // ========================================================================== + // G-3 Workaround (delegated to G3Workaround) + // ========================================================================== + + /** + * Store a user's answer from AskUserQuestion for later injection (G-3 workaround) + */ + addPendingAnswer(sessionId: string, question: string, answer: string): void { + this.g3Workaround.addPendingAnswer(sessionId, question, answer) } /** - * Parse ACP error and return user-friendly message + * Store AskUserQuestion response for persistence */ - private parseAcpError(error: unknown): string { - const errorStr = String(error) - - // MCP server missing environment variables - if (errorStr.includes('Missing environment variables')) { - const match = errorStr.match(/Missing environment variables: ([A-Z_]+)/) - return `MCP server requires environment variable: ${match?.[1] || 'unknown'}` - } - - // File too large to read - if (errorStr.includes('MaxFileReadTokenExceededError')) { - return 'File is too large to read. Try reading smaller portions.' - } - - // MCP configuration invalid - if (errorStr.includes('mcp-config-invalid')) { - return 'MCP server configuration is invalid. Check your settings.' - } - - // Generic fallback - return 'Agent encountered an error. Please try again.' + async storeAskUserQuestionResponse( + sessionId: string, + toolCallId: string, + response: AskUserQuestionResponseData + ): Promise { + return this.g3Workaround.storeResponse(sessionId, toolCallId, response) } + // ========================================================================== + // Session ID Lookup + // ========================================================================== + /** * Find Multica session ID by ACP agent session ID */ getSessionIdByAgentSessionId(agentSessionId: string): string | null { - for (const [sessionId, sessionAgent] of this.sessions) { - if (sessionAgent.agentSessionId === agentSessionId) { - return sessionId - } - } - return null + return this.sessionLifecycle.getSessionIdByAgentSessionId(agentSessionId) } /** @@ -709,65 +270,4 @@ export class Conductor { getMulticaSessionIdByAcp(acpSessionId: string): string | null { return this.getSessionIdByAgentSessionId(acpSessionId) } - - /** - * Store a user's answer from AskUserQuestion for later injection (G-3 workaround) - * The answer will be prepended to the next prompt as context - */ - addPendingAnswer(sessionId: string, question: string, answer: string): void { - if (!this.pendingAnswers.has(sessionId)) { - this.pendingAnswers.set(sessionId, []) - } - this.pendingAnswers.get(sessionId)!.push({ question, answer }) - } - - /** - * Get pending answers for a session (internal use) - */ - private getPendingAnswers(sessionId: string): Array<{ question: string; answer: string }> { - return this.pendingAnswers.get(sessionId) || [] - } - - /** - * Clear pending answers for a session (internal use) - */ - private clearPendingAnswers(sessionId: string): void { - this.pendingAnswers.delete(sessionId) - } - - /** - * Store AskUserQuestion response for persistence - * This allows the completed state to be restored after app restart. - * The response is stored as a session update with type 'askuserquestion_response'. - */ - async storeAskUserQuestionResponse( - sessionId: string, - toolCallId: string, - response: AskUserQuestionResponseData - ): Promise { - if (!this.sessionStore) { - console.log('[Conductor] Skipping AskUserQuestion response storage (no session store)') - return - } - - const update = { - sessionId, - update: { - sessionUpdate: 'askuserquestion_response', - toolCallId, - response - } - } - - console.log(`[Conductor] Storing AskUserQuestion response for toolCallId=${toolCallId}`) - await this.sessionStore.appendUpdate(sessionId, update as any) - - // Also notify frontend so it appears in sessionUpdates immediately - if (this.events.onSessionUpdate) { - this.events.onSessionUpdate({ - sessionId, - update: update.update - } as SessionNotification) - } - } } diff --git a/src/main/conductor/G3Workaround.ts b/src/main/conductor/G3Workaround.ts new file mode 100644 index 0000000000..862dbb8136 --- /dev/null +++ b/src/main/conductor/G3Workaround.ts @@ -0,0 +1,100 @@ +/** + * G3Workaround - Handles AskUserQuestion answer injection + * + * The G-3 mechanism handles a limitation in ACP's AskUserQuestion tool: + * the tool only returns "User has answered the question(s)" to the agent, + * without the actual user selection. + * + * This module stores user answers so they can be injected into the next + * prompt as context, allowing the agent to see the actual user selection. + * + * Flow: + * 1. User selects an option in AskUserQuestion UI + * 2. Answer is stored via addPendingAnswer() + * 3. Current agent turn is cancelled (G-3 mechanism) + * 4. Re-prompt is sent, which injects these answers as context + * 5. Agent now sees the actual user selection + */ +import type { SessionNotification } from '@agentclientprotocol/sdk' +import type { AskUserQuestionResponseData } from '../../shared/types' +import type { IG3Workaround, G3WorkaroundOptions, PendingAnswer, ISessionStore } from './types' + +export class G3Workaround implements IG3Workaround { + private sessionStore: ISessionStore | null + private events: G3WorkaroundOptions['events'] + + /** + * Pending answers from AskUserQuestion tool + * Map of sessionId -> array of {question, answer} + */ + private pendingAnswers: Map = new Map() + + constructor(options: G3WorkaroundOptions) { + this.sessionStore = options.sessionStore + this.events = options.events + } + + /** + * Store a user's answer from AskUserQuestion for later injection + * The answer will be prepended to the next prompt as context + */ + addPendingAnswer(sessionId: string, question: string, answer: string): void { + if (!this.pendingAnswers.has(sessionId)) { + this.pendingAnswers.set(sessionId, []) + } + this.pendingAnswers.get(sessionId)!.push({ question, answer }) + } + + /** + * Get pending answers for a session + */ + getPendingAnswers(sessionId: string): PendingAnswer[] { + return this.pendingAnswers.get(sessionId) || [] + } + + /** + * Clear pending answers for a session (after injection) + */ + clearPendingAnswers(sessionId: string): void { + this.pendingAnswers.delete(sessionId) + } + + /** + * Store AskUserQuestion response for persistence + * This allows the completed state to be restored after app restart. + * The response is stored as a session update with type 'askuserquestion_response'. + */ + async storeResponse( + sessionId: string, + toolCallId: string, + response: AskUserQuestionResponseData + ): Promise { + if (!this.sessionStore) { + console.log('[G3Workaround] Skipping AskUserQuestion response storage (no session store)') + return + } + + const update = { + sessionId, + update: { + sessionUpdate: 'askuserquestion_response', + toolCallId, + response + } + } + + console.log(`[G3Workaround] Storing AskUserQuestion response for toolCallId=${toolCallId}`) + await this.sessionStore.appendUpdate(sessionId, update as any) + + // Also notify frontend so it appears in sessionUpdates immediately + if (this.events.onSessionUpdate) { + this.events.onSessionUpdate( + { + sessionId, + update: update.update + } as SessionNotification, + undefined + ) + } + } +} diff --git a/src/main/conductor/PromptHandler.ts b/src/main/conductor/PromptHandler.ts new file mode 100644 index 0000000000..9667f69812 --- /dev/null +++ b/src/main/conductor/PromptHandler.ts @@ -0,0 +1,279 @@ +/** + * PromptHandler - Handles prompt sending and processing + * + * Responsibilities: + * - Converting message content to ACP format + * - History replay for resumed sessions + * - Injecting G-3 pending answers + * - Tracking processing state + * - Error handling with user-friendly messages + */ +import type { SessionNotification } from '@agentclientprotocol/sdk' +import type { MessageContent, MessageContentItem } from '../../shared/types/message' +import { formatHistoryForReplay, hasReplayableHistory } from './historyReplay' +import type { + IPromptHandler, + PromptHandlerOptions, + SessionAgent, + ISessionStore, + IG3Workaround, + IAgentProcessManager, + ConductorEvents +} from './types' + +/** + * Extended options for PromptHandler including the ensureAgent callback + */ +export interface PromptHandlerFullOptions extends PromptHandlerOptions { + /** + * Callback to ensure an agent is running for a session. + * This is provided by the parent Conductor/SessionLifecycle module. + * Returns the SessionAgent for the session. + */ + ensureAgent: (sessionId: string) => Promise +} + +export class PromptHandler implements IPromptHandler { + private sessionStore: ISessionStore | null + private agentProcessManager: IAgentProcessManager + private g3Workaround: IG3Workaround + private events: ConductorEvents + private ensureAgent: (sessionId: string) => Promise + + /** + * Set of session IDs currently processing a request + */ + private processingSessions: Set = new Set() + + constructor(options: PromptHandlerFullOptions) { + this.sessionStore = options.sessionStore + this.agentProcessManager = options.agentProcessManager + this.g3Workaround = options.g3Workaround + this.events = options.events + this.ensureAgent = options.ensureAgent + } + + /** + * Send a prompt to the agent + * @param sessionId - Multica session ID + * @param content - Message content (text, images) + * @param options.internal - If true, message is not displayed in UI (G-3 mechanism) + * @returns Stop reason from the agent + */ + async send( + sessionId: string, + content: MessageContent, + options?: { internal?: boolean } + ): Promise { + // Mark session as processing immediately (before any await) + // This ensures isProcessing() returns true as soon as send() is called + this.processingSessions.add(sessionId) + this.events.onStatusChange?.() + + try { + return await this.sendInternal(sessionId, content, options) + } finally { + // Always remove from processing when done (success or error) + this.processingSessions.delete(sessionId) + this.events.onStatusChange?.() + } + } + + /** + * Internal implementation of send (separated for cleaner processing state management) + */ + private async sendInternal( + sessionId: string, + content: MessageContent, + options?: { internal?: boolean } + ): Promise { + // Ensure agent is running (lazy start if needed) + const sessionAgent = await this.ensureAgent(sessionId) + const { connection, agentSessionId } = sessionAgent + + // Convert MessageContent to ACP SDK format + const convertToAcpFormat = ( + items: MessageContent + ): Array<{ type: string; text?: string; data?: string; mimeType?: string }> => { + return items.map((item: MessageContentItem) => { + if (item.type === 'text') { + return { type: 'text', text: item.text } + } else if (item.type === 'image') { + return { type: 'image', data: item.data, mimeType: item.mimeType } + } + return { type: 'text', text: '' } // fallback + }) + } + + // Build prompt content array + let promptContent = convertToAcpFormat(content) + + // If this is a resumed session, prepend conversation history to first prompt + if (sessionAgent.needsHistoryReplay && this.sessionStore) { + try { + const data = await this.sessionStore.get(sessionId) + if (data && hasReplayableHistory(data.updates)) { + const history = formatHistoryForReplay(data.updates) + if (history) { + console.log( + `[PromptHandler] Prepending conversation history (${data.updates.length} updates)` + ) + // Prepend history as text block before other content + promptContent = [{ type: 'text', text: history }, ...promptContent] + } + } + } catch (error) { + console.error(`[PromptHandler] Failed to load history for replay:`, error) + // Continue without history - better than blocking the prompt + } finally { + // Always mark as replayed to prevent repeated attempts + sessionAgent.needsHistoryReplay = false + } + } + + // Inject pending user answers from AskUserQuestion (G-3 workaround) + const pendingAnswers = this.g3Workaround.getPendingAnswers(sessionId) + if (pendingAnswers.length > 0) { + const answerContext = pendingAnswers + .map((a) => `[User's answer to "${a.question}"]: ${a.answer}`) + .join('\n') + + console.log( + `[PromptHandler] Injecting ${pendingAnswers.length} pending answer(s) for session ${sessionId}` + ) + + // Prepend answers as context before user's message + promptContent = [{ type: 'text', text: `---\n${answerContext}\n---\n` }, ...promptContent] + + // Clear pending answers after injection + this.g3Workaround.clearPendingAnswers(sessionId) + } + + // Log prompt info + const textContent = content.find((c: MessageContentItem) => c.type === 'text') + const imageCount = content.filter((c: MessageContentItem) => c.type === 'image').length + console.log(`[PromptHandler] Sending prompt to session ${agentSessionId}`) + if (textContent && textContent.type === 'text') { + console.log( + `[PromptHandler] Text: ${textContent.text.slice(0, 100)}${textContent.text.length > 100 ? '...' : ''}` + ) + } + if (imageCount > 0) { + console.log(`[PromptHandler] Images: ${imageCount}`) + } + + // Store user message before sending (so it appears in history) + // Internal messages are stored with _internal flag for filtering in UI + if (this.sessionStore) { + const userUpdate = { + sessionId: agentSessionId, + update: { + sessionUpdate: 'user_message', + content: content, // Store full MessageContent array + _internal: options?.internal ?? false // G-3: internal messages not shown in UI + } + } + await this.sessionStore.appendUpdate(sessionId, userUpdate as any) + } + + try { + const result = await connection.prompt({ + sessionId: agentSessionId, + prompt: promptContent as any + }) + + console.log(`[PromptHandler] Prompt completed with stopReason: ${result.stopReason}`) + + return result.stopReason + } catch (error) { + console.error(`[PromptHandler] ACP error for session ${sessionId}:`, error) + + // Parse ACP error to user-friendly message + const message = this.parseAcpError(error) + + // DESIGN DECISION: Event-driven error handling instead of throwing + // + // Why not throw? + // - Throwing causes IPC handler to fail, frontend receives an error response + // - This typically triggers a toast/dialog, interrupting user flow + // + // Why emit an event? + // - Error appears inline in the chat as a message, keeping context visible + // - User can read the error and continue the conversation naturally + // - Consistent with how agent responses are already delivered (via events) + if (this.events.onSessionUpdate) { + this.events.onSessionUpdate( + { + sessionId: agentSessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: `\n\n**Error:** ${message}\n` + } + } + } as SessionNotification, + undefined + ) + } + + // Return 'error' as stopReason - IPC succeeds, caller knows it was an error + return 'error' + } + } + + /** + * Cancel an ongoing request + */ + async cancel(sessionId: string): Promise { + const sessionAgent = this.agentProcessManager.get(sessionId) + if (!sessionAgent) { + return + } + + const { connection, agentSessionId } = sessionAgent + console.log(`[PromptHandler] Cancelling request for session ${agentSessionId}`) + await connection.cancel({ sessionId: agentSessionId }) + console.log(`[PromptHandler] Cancel request sent`) + } + + /** + * Check if a session is currently processing + */ + isProcessing(sessionId: string): boolean { + return this.processingSessions.has(sessionId) + } + + /** + * Get all session IDs currently processing + */ + getProcessingSessionIds(): string[] { + return Array.from(this.processingSessions) + } + + /** + * Parse ACP error and return user-friendly message + */ + private parseAcpError(error: unknown): string { + const errorStr = String(error) + + // MCP server missing environment variables + if (errorStr.includes('Missing environment variables')) { + const match = errorStr.match(/Missing environment variables: ([A-Z_]+)/) + return `MCP server requires environment variable: ${match?.[1] || 'unknown'}` + } + + // File too large to read + if (errorStr.includes('MaxFileReadTokenExceededError')) { + return 'File is too large to read. Try reading smaller portions.' + } + + // MCP configuration invalid + if (errorStr.includes('mcp-config-invalid')) { + return 'MCP server configuration is invalid. Check your settings.' + } + + // Generic fallback + return 'Agent encountered an error. Please try again.' + } +} diff --git a/src/main/conductor/SessionLifecycle.ts b/src/main/conductor/SessionLifecycle.ts new file mode 100644 index 0000000000..a4785cbc7d --- /dev/null +++ b/src/main/conductor/SessionLifecycle.ts @@ -0,0 +1,333 @@ +/** + * SessionLifecycle - Manages session lifecycle operations + * + * Responsibilities: + * - Creating new sessions + * - Loading and resuming sessions + * - Deleting sessions + * - Switching session agents + * - Ensuring agents are running (lazy-start) + */ +import { DEFAULT_AGENTS } from '../config/defaults' +import type { + AgentConfig, + MulticaSession, + SessionData, + ListSessionsOptions +} from '../../shared/types' +import type { + ISessionLifecycle, + SessionLifecycleOptions, + SessionAgent, + ISessionStore, + IAgentProcessManager, + ConductorEvents +} from './types' + +export class SessionLifecycle implements ISessionLifecycle { + private sessionStore: ISessionStore | null + private agentProcessManager: IAgentProcessManager + private events: ConductorEvents + + /** + * In-memory session for CLI mode (when persistence is skipped) + */ + private inMemorySession: MulticaSession | null = null + + constructor(options: SessionLifecycleOptions) { + this.sessionStore = options.sessionStore + this.agentProcessManager = options.agentProcessManager + this.events = options.events + this.inMemorySession = options.inMemorySession ?? null + } + + /** + * Initialize the lifecycle manager + */ + async initialize(): Promise { + if (this.sessionStore) { + await this.sessionStore.initialize() + } + } + + /** + * Create a new session (agent starts lazily on first prompt) + */ + async create(cwd: string, agentConfig: AgentConfig): Promise { + let session: MulticaSession + + if (this.sessionStore) { + // Create session record - agent will be started lazily via ensureAgentForSession + session = await this.sessionStore.create({ + agentSessionId: '', // Will be filled by ensureAgentForSession on first prompt + agentId: agentConfig.id, + workingDirectory: cwd + }) + } else { + // CLI mode: in-memory session + const { randomUUID } = await import('crypto') + const now = new Date().toISOString() + session = { + id: randomUUID(), + agentSessionId: '', + agentId: agentConfig.id, + workingDirectory: cwd, + createdAt: now, + updatedAt: now, + status: 'active', + messageCount: 0 + } + this.inMemorySession = session + } + + // Agent will be started lazily when user sends first prompt + console.log(`[SessionLifecycle] Created session: ${session.id} (agent: pending)`) + + return session + } + + /** + * Load a session without starting its agent (lazy loading) + */ + async load(sessionId: string): Promise { + if (!this.sessionStore) { + throw new Error('Session loading not available in CLI mode') + } + + const data = await this.sessionStore.get(sessionId) + if (!data) { + throw new Error(`Session not found: ${sessionId}`) + } + + return data.session + } + + /** + * Resume an existing session (starts a new agent process for it) + */ + async resume(sessionId: string): Promise { + if (!this.sessionStore) { + throw new Error('Session resumption not available in CLI mode') + } + + const data = await this.sessionStore.get(sessionId) + if (!data) { + throw new Error(`Session not found: ${sessionId}`) + } + + // If session already has a running agent, return as-is + if (this.agentProcessManager.get(sessionId)) { + console.log(`[SessionLifecycle] Session ${sessionId} already has a running agent`) + return data.session + } + + // Get agent config + const agentConfig = DEFAULT_AGENTS[data.session.agentId] + if (!agentConfig) { + throw new Error(`Unknown agent: ${data.session.agentId}`) + } + + // Start a new agent process for this session (isResumed = true) + const { agentSessionId } = await this.agentProcessManager.start( + sessionId, + agentConfig, + data.session.workingDirectory, + true // Resumed session needs history replay + ) + + // Update agentSessionId (new ACP session) + const updatedSession = await this.sessionStore.updateMeta(sessionId, { + agentSessionId, + status: 'active' + }) + + console.log( + `[SessionLifecycle] Resumed session: ${sessionId} (new agent session: ${agentSessionId})` + ) + + return updatedSession + } + + /** + * Delete a session and stop its agent + */ + async delete(sessionId: string): Promise { + // Stop the session's agent process first + await this.agentProcessManager.stop(sessionId) + + // Delete from store + if (this.sessionStore) { + await this.sessionStore.delete(sessionId) + } + if (this.inMemorySession?.id === sessionId) { + this.inMemorySession = null + } + } + + /** + * Update session metadata + */ + async updateMeta(sessionId: string, updates: Partial): Promise { + if (!this.sessionStore) { + throw new Error('Session update not available in CLI mode') + } + return this.sessionStore.updateMeta(sessionId, updates) + } + + /** + * Switch a session's agent (stops current, starts new) + */ + async switchAgent(sessionId: string, newAgentId: string): Promise { + if (!this.sessionStore) { + throw new Error('Session agent switch not available in CLI mode') + } + + const data = await this.sessionStore.get(sessionId) + if (!data) { + throw new Error(`Session not found: ${sessionId}`) + } + + // Get new agent config + const newAgentConfig = DEFAULT_AGENTS[newAgentId] + if (!newAgentConfig) { + throw new Error(`Unknown agent: ${newAgentId}`) + } + + console.log( + `[SessionLifecycle] Switching session ${sessionId} from ${data.session.agentId} to ${newAgentId}` + ) + + // Stop current agent if running + await this.agentProcessManager.stop(sessionId) + + // Update session's agentId + let updatedSession = await this.sessionStore.updateMeta(sessionId, { + agentId: newAgentId + }) + + // Start new agent (isResumed = true to replay history) + const { agentSessionId } = await this.agentProcessManager.start( + sessionId, + newAgentConfig, + data.session.workingDirectory, + true + ) + + // Update agentSessionId + updatedSession = await this.sessionStore.updateMeta(sessionId, { + agentSessionId, + status: 'active' + }) + + // Notify frontend + if (this.events.onSessionMetaUpdated) { + this.events.onSessionMetaUpdated(updatedSession) + } + + console.log( + `[SessionLifecycle] Session ${sessionId} switched to ${newAgentId} (agent session: ${agentSessionId})` + ) + + return updatedSession + } + + /** + * List sessions with optional filtering + */ + async list(options?: ListSessionsOptions): Promise { + if (!this.sessionStore) { + return this.inMemorySession ? [this.inMemorySession] : [] + } + return this.sessionStore.list(options) + } + + /** + * Get complete session data + */ + async getData(sessionId: string): Promise { + if (!this.sessionStore) { + return null + } + return this.sessionStore.get(sessionId) + } + + /** + * Find Multica session ID by agent session ID + */ + getSessionIdByAgentSessionId(agentSessionId: string): string | null { + // First check in-memory sessions (running agents) + for (const sessionId of this.agentProcessManager.getRunningSessionIds()) { + const sessionAgent = this.agentProcessManager.get(sessionId) + if (sessionAgent?.agentSessionId === agentSessionId) { + return sessionId + } + } + + // Fallback to session store lookup + if (this.sessionStore) { + const session = this.sessionStore.getByAgentSessionId(agentSessionId) + return session?.id ?? null + } + + return null + } + + /** + * Ensure an agent is running for a session (start if needed) + * This is used by PromptHandler to lazy-start agents on first prompt. + */ + async ensureAgentForSession(sessionId: string): Promise { + // If already running, return existing + const existing = this.agentProcessManager.get(sessionId) + if (existing) { + return existing + } + + // Load session data + if (!this.sessionStore) { + throw new Error('Cannot auto-start agent in CLI mode') + } + + const data = await this.sessionStore.get(sessionId) + if (!data) { + throw new Error(`Session not found: ${sessionId}`) + } + + // Get agent config + const agentConfig = DEFAULT_AGENTS[data.session.agentId] + if (!agentConfig) { + throw new Error(`Unknown agent: ${data.session.agentId}`) + } + + console.log(`[SessionLifecycle] Lazy-starting agent for session ${sessionId}`) + + // Start agent (isResumed = true for lazy start of existing session) + const { agentSessionId } = await this.agentProcessManager.start( + sessionId, + agentConfig, + data.session.workingDirectory, + true // Existing session needs history replay + ) + + // Update session with new agentSessionId + const updatedSession = await this.sessionStore.updateMeta(sessionId, { + agentSessionId, + status: 'active' + }) + + // Notify frontend of session metadata change (important for agentSessionId update) + // This must happen BEFORE sending the prompt so frontend can receive messages + if (this.events.onSessionMetaUpdated) { + this.events.onSessionMetaUpdated(updatedSession) + } + + return this.agentProcessManager.get(sessionId)! + } + + /** + * Get in-memory session (for CLI mode) + */ + getInMemorySession(): MulticaSession | null { + return this.inMemorySession + } +} diff --git a/src/main/conductor/index.ts b/src/main/conductor/index.ts index d3b480c691..e9d6fcabac 100644 --- a/src/main/conductor/index.ts +++ b/src/main/conductor/index.ts @@ -1,5 +1,26 @@ +// Main facade export { Conductor } from './Conductor' +export type { SessionUpdateCallback, ConductorEvents, ConductorOptions } from './Conductor' + +// Modules (for direct use if needed) +export { AgentProcessManager } from './AgentProcessManager' +export { PromptHandler } from './PromptHandler' +export { G3Workaround } from './G3Workaround' +export { SessionLifecycle } from './SessionLifecycle' + +// Utilities export { AgentProcess } from './AgentProcess' export { createAcpClient } from './AcpClientFactory' -export type { SessionUpdateCallback, ConductorEvents } from './Conductor' export type { AcpClientCallbacks, AcpClientFactoryOptions } from './AcpClientFactory' + +// Types +export type { + SessionAgent, + PendingAnswer, + AgentStartResult, + ISessionStore, + IAgentProcessManager, + IPromptHandler, + IG3Workaround, + ISessionLifecycle +} from './types' diff --git a/src/main/conductor/types.ts b/src/main/conductor/types.ts new file mode 100644 index 0000000000..065972175c --- /dev/null +++ b/src/main/conductor/types.ts @@ -0,0 +1,296 @@ +/** + * Type definitions for Conductor modules + * + * This file defines interfaces for the modular Conductor architecture, + * enabling dependency injection and easier testing. + */ +import type { + ClientSideConnection, + SessionNotification, + RequestPermissionRequest, + RequestPermissionResponse +} from '@agentclientprotocol/sdk' +import type { AgentProcess } from './AgentProcess' +import type { + AgentConfig, + MulticaSession, + SessionData, + StoredSessionUpdate, + ListSessionsOptions, + CreateSessionParams, + AskUserQuestionResponseData +} from '../../shared/types' +import type { MessageContent } from '../../shared/types/message' + +// ============================================================================= +// Core Types +// ============================================================================= + +/** + * Per-session agent state (internal to conductor modules) + */ +export interface SessionAgent { + agentProcess: AgentProcess + connection: ClientSideConnection + agentConfig: AgentConfig + agentSessionId: string + /** Whether the first prompt needs history prepended (for resumed sessions) */ + needsHistoryReplay: boolean +} + +/** + * Pending answer for G-3 mechanism (AskUserQuestion workaround) + */ +export interface PendingAnswer { + question: string + answer: string +} + +/** + * Result of starting an agent for a session + */ +export interface AgentStartResult { + connection: ClientSideConnection + agentSessionId: string +} + +// ============================================================================= +// Event Types +// ============================================================================= + +/** + * Session update callback signature + */ +export interface SessionUpdateCallback { + (update: SessionNotification, sequenceNumber?: number): void +} + +/** + * Events emitted by Conductor and its modules + */ +export interface ConductorEvents { + /** Called when a session update is received from the agent */ + onSessionUpdate?: SessionUpdateCallback + /** Called when a permission request is received from the agent */ + onPermissionRequest?: (params: RequestPermissionRequest) => Promise + /** Called when processing state changes (for UI status updates) */ + onStatusChange?: () => void + /** Called when session metadata changes (e.g., agentSessionId after lazy start) */ + onSessionMetaUpdated?: (session: MulticaSession) => void +} + +/** + * Options for Conductor initialization + */ +export interface ConductorOptions { + events?: ConductorEvents + /** Skip session persistence (for CLI mode) */ + skipPersistence?: boolean + /** Custom storage path for sessions */ + storagePath?: string +} + +// ============================================================================= +// Module Interfaces +// ============================================================================= + +/** + * Interface for session storage operations + * Abstracts the persistence layer for dependency injection + */ +export interface ISessionStore { + /** Initialize the storage (create directories, load index) */ + initialize(): Promise + + /** Create a new session */ + create(params: CreateSessionParams): Promise + + /** Get complete session data including updates */ + get(sessionId: string): Promise + + /** List sessions with optional filtering */ + list(options?: ListSessionsOptions): Promise + + /** Update session metadata */ + updateMeta(sessionId: string, updates: Partial): Promise + + /** Delete a session */ + delete(sessionId: string): Promise + + /** Append an update to a session */ + appendUpdate(sessionId: string, update: SessionNotification): Promise + + /** Find session by agent session ID (synchronous, in-memory lookup) */ + getByAgentSessionId(agentSessionId: string): MulticaSession | null +} + +/** + * Interface for agent process management + */ +export interface IAgentProcessManager { + /** + * Start an agent process for a session + * @param sessionId - Multica session ID + * @param config - Agent configuration + * @param cwd - Working directory + * @param isResumed - Whether this is a resumed session (needs history replay) + */ + start( + sessionId: string, + config: AgentConfig, + cwd: string, + isResumed?: boolean + ): Promise + + /** Stop a session's agent process */ + stop(sessionId: string): Promise + + /** Stop all running agent processes */ + stopAll(): Promise + + /** Get session agent state by session ID */ + get(sessionId: string): SessionAgent | undefined + + /** Set session agent state (used by other modules) */ + set(sessionId: string, sessionAgent: SessionAgent): void + + /** Remove session agent state */ + remove(sessionId: string): void + + /** Check if a session has a running agent */ + isRunning(sessionId: string): boolean + + /** Get all running session IDs */ + getRunningSessionIds(): string[] + + /** Get agent config for a session */ + getAgentConfig(sessionId: string): AgentConfig | null +} + +/** + * Interface for prompt handling + */ +export interface IPromptHandler { + /** + * Send a prompt to the agent + * @param sessionId - Multica session ID + * @param content - Message content (text, images) + * @param options.internal - If true, message is not displayed in UI (G-3 mechanism) + * @returns Stop reason from the agent + */ + send( + sessionId: string, + content: MessageContent, + options?: { internal?: boolean } + ): Promise + + /** Cancel an ongoing request */ + cancel(sessionId: string): Promise + + /** Check if a session is currently processing */ + isProcessing(sessionId: string): boolean + + /** Get all session IDs currently processing */ + getProcessingSessionIds(): string[] +} + +/** + * Interface for G-3 workaround (AskUserQuestion answer injection) + * + * The G-3 mechanism handles a limitation in ACP's AskUserQuestion tool: + * the tool only returns "User has answered" without the actual selection. + * This module stores user answers and injects them into the next prompt. + */ +export interface IG3Workaround { + /** Store a user's answer for later injection */ + addPendingAnswer(sessionId: string, question: string, answer: string): void + + /** Get all pending answers for a session */ + getPendingAnswers(sessionId: string): PendingAnswer[] + + /** Clear pending answers for a session (after injection) */ + clearPendingAnswers(sessionId: string): void + + /** Store AskUserQuestion response for persistence (allows state restore after restart) */ + storeResponse( + sessionId: string, + toolCallId: string, + response: AskUserQuestionResponseData + ): Promise +} + +/** + * Interface for session lifecycle management + */ +export interface ISessionLifecycle { + /** Initialize the lifecycle manager */ + initialize(): Promise + + /** Create a new session (agent starts lazily on first prompt) */ + create(cwd: string, agentConfig: AgentConfig): Promise + + /** Load a session without starting its agent (lazy loading) */ + load(sessionId: string): Promise + + /** Resume an existing session (starts a new agent process) */ + resume(sessionId: string): Promise + + /** Delete a session and stop its agent */ + delete(sessionId: string): Promise + + /** Update session metadata */ + updateMeta(sessionId: string, updates: Partial): Promise + + /** Switch a session's agent (stops current, starts new) */ + switchAgent(sessionId: string, newAgentId: string): Promise + + /** List sessions with optional filtering */ + list(options?: ListSessionsOptions): Promise + + /** Get complete session data */ + getData(sessionId: string): Promise + + /** Find Multica session ID by agent session ID */ + getSessionIdByAgentSessionId(agentSessionId: string): string | null +} + +// ============================================================================= +// Module Options +// ============================================================================= + +/** + * Options for AgentProcessManager + */ +export interface AgentProcessManagerOptions { + sessionStore: ISessionStore | null + events: ConductorEvents +} + +/** + * Options for PromptHandler + */ +export interface PromptHandlerOptions { + sessionStore: ISessionStore | null + agentProcessManager: IAgentProcessManager + g3Workaround: IG3Workaround + events: ConductorEvents +} + +/** + * Options for G3Workaround + */ +export interface G3WorkaroundOptions { + sessionStore: ISessionStore | null + events: ConductorEvents +} + +/** + * Options for SessionLifecycle + */ +export interface SessionLifecycleOptions { + sessionStore: ISessionStore | null + agentProcessManager: IAgentProcessManager + events: ConductorEvents + /** In-memory session for CLI mode */ + inMemorySession?: MulticaSession | null +} diff --git a/tests/unit/main/conductor/AgentProcessManager.test.ts b/tests/unit/main/conductor/AgentProcessManager.test.ts new file mode 100644 index 0000000000..584035bc60 --- /dev/null +++ b/tests/unit/main/conductor/AgentProcessManager.test.ts @@ -0,0 +1,301 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Store exit callbacks and error states for testing +const testState = { + exitCallbacks: new Map void>(), + instanceCounter: 0, + mockStartError: null as Error | null, + mockInitializeError: null as Error | null, + mockNewSessionError: null as Error | null +} + +// Mock AgentProcess to avoid spawning real processes +vi.mock('../../../../src/main/conductor/AgentProcess', () => { + return { + AgentProcess: class MockAgentProcess { + private instanceId: string + + constructor() { + this.instanceId = `instance-${testState.instanceCounter++}` + } + + start = vi.fn().mockImplementation(() => { + if (testState.mockStartError) { + return Promise.reject(testState.mockStartError) + } + return Promise.resolve(undefined) + }) + + stop = vi.fn().mockResolvedValue(undefined) + isRunning = vi.fn().mockReturnValue(true) + + onExit = vi.fn((cb: (code: number | null, signal: string | null) => void) => { + testState.exitCallbacks.set(this.instanceId, cb) + }) + + getPid = vi.fn().mockReturnValue(12345) + getStdinWeb = vi.fn().mockReturnValue(new WritableStream()) + getStdoutWeb = vi.fn().mockReturnValue(new ReadableStream()) + } + } +}) + +// Mock ACP SDK +vi.mock('@agentclientprotocol/sdk', () => { + return { + PROTOCOL_VERSION: '1.0', + ndJsonStream: vi.fn().mockReturnValue({}), + ClientSideConnection: class MockClientSideConnection { + initialize = vi.fn().mockImplementation(() => { + if (testState.mockInitializeError) { + return Promise.reject(testState.mockInitializeError) + } + return Promise.resolve({ + protocolVersion: '1.0', + agentInfo: { name: 'mock-agent' } + }) + }) + + newSession = vi.fn().mockImplementation(() => { + if (testState.mockNewSessionError) { + return Promise.reject(testState.mockNewSessionError) + } + return Promise.resolve({ + sessionId: 'mock-acp-session-id' + }) + }) + + prompt = vi.fn().mockResolvedValue({ + stopReason: 'end_turn' + }) + + cancel = vi.fn().mockResolvedValue(undefined) + } + } +}) + +// Import after mocks are set up +import { AgentProcessManager } from '../../../../src/main/conductor/AgentProcessManager' +import type { ISessionStore, ConductorEvents } from '../../../../src/main/conductor/types' +import type { AgentConfig } from '../../../../src/shared/types' + +describe('AgentProcessManager', () => { + let manager: AgentProcessManager + let mockSessionStore: ISessionStore + let mockEvents: ConductorEvents + + const mockAgentConfig: AgentConfig = { + id: 'opencode', + name: 'OpenCode', + command: 'opencode', + args: ['--mcp'], + enabled: true + } + + beforeEach(() => { + // Reset all test state + testState.exitCallbacks.clear() + testState.instanceCounter = 0 + testState.mockStartError = null + testState.mockInitializeError = null + testState.mockNewSessionError = null + + mockSessionStore = { + initialize: vi.fn(), + create: vi.fn(), + get: vi.fn(), + list: vi.fn(), + updateMeta: vi.fn(), + delete: vi.fn(), + appendUpdate: vi.fn().mockResolvedValue({ timestamp: new Date().toISOString() }), + getByAgentSessionId: vi.fn() + } + + mockEvents = { + onSessionUpdate: vi.fn(), + onStatusChange: vi.fn() + } + + manager = new AgentProcessManager({ + sessionStore: mockSessionStore, + events: mockEvents + }) + }) + + describe('start', () => { + it('should start agent process and return connection info', async () => { + const result = await manager.start('session-1', mockAgentConfig, '/test/project') + + expect(result.agentSessionId).toBe('mock-acp-session-id') + expect(result.connection).toBeDefined() + }) + + it('should store session in sessions map', async () => { + await manager.start('session-1', mockAgentConfig, '/test/project') + + expect(manager.get('session-1')).toBeDefined() + expect(manager.get('session-1')?.agentConfig).toBe(mockAgentConfig) + }) + + it('should set needsHistoryReplay for resumed sessions', async () => { + await manager.start('session-1', mockAgentConfig, '/test/project', true) + + const session = manager.get('session-1') + expect(session?.needsHistoryReplay).toBe(true) + }) + + it('should not set needsHistoryReplay for new sessions', async () => { + await manager.start('session-1', mockAgentConfig, '/test/project', false) + + const session = manager.get('session-1') + expect(session?.needsHistoryReplay).toBe(false) + }) + }) + + describe('stop', () => { + it('should stop agent process and remove from map', async () => { + await manager.start('session-1', mockAgentConfig, '/test/project') + expect(manager.get('session-1')).toBeDefined() + + await manager.stop('session-1') + expect(manager.get('session-1')).toBeUndefined() + }) + + it('should handle stopping non-existent session gracefully', async () => { + await expect(manager.stop('non-existent')).resolves.not.toThrow() + }) + }) + + describe('stopAll', () => { + it('should stop all running agent processes', async () => { + await manager.start('session-1', mockAgentConfig, '/test/project') + await manager.start('session-2', mockAgentConfig, '/test/project2') + expect(manager.getRunningSessionIds()).toHaveLength(2) + + await manager.stopAll() + expect(manager.getRunningSessionIds()).toHaveLength(0) + }) + }) + + describe('state queries', () => { + it('should return running session IDs', async () => { + await manager.start('session-1', mockAgentConfig, '/test/project') + await manager.start('session-2', mockAgentConfig, '/test/project2') + + const runningIds = manager.getRunningSessionIds() + expect(runningIds).toContain('session-1') + expect(runningIds).toContain('session-2') + }) + + it('should check if session is running', async () => { + await manager.start('session-1', mockAgentConfig, '/test/project') + + expect(manager.isRunning('session-1')).toBe(true) + expect(manager.isRunning('non-existent')).toBe(false) + }) + + it('should return agent config for session', async () => { + await manager.start('session-1', mockAgentConfig, '/test/project') + + expect(manager.getAgentConfig('session-1')).toBe(mockAgentConfig) + expect(manager.getAgentConfig('non-existent')).toBeNull() + }) + }) + + describe('set and remove', () => { + it('should allow setting session agent directly', async () => { + const mockSessionAgent = { + agentProcess: {} as any, + connection: {} as any, + agentConfig: mockAgentConfig, + agentSessionId: 'acp-123', + needsHistoryReplay: false + } + + manager.set('session-1', mockSessionAgent) + expect(manager.get('session-1')).toBe(mockSessionAgent) + }) + + it('should allow removing session agent directly', async () => { + await manager.start('session-1', mockAgentConfig, '/test/project') + expect(manager.get('session-1')).toBeDefined() + + manager.remove('session-1') + expect(manager.get('session-1')).toBeUndefined() + }) + }) + + describe('onExit callback', () => { + it('should clean up session when agent process exits', async () => { + await manager.start('session-1', mockAgentConfig, '/test/project') + expect(manager.get('session-1')).toBeDefined() + + // Get the exit callback that was registered + const exitCallback = testState.exitCallbacks.get('instance-0') + expect(exitCallback).toBeDefined() + + // Simulate agent process exit with code 0 + exitCallback!(0, null) + + // Session should be cleaned up + expect(manager.get('session-1')).toBeUndefined() + }) + + it('should clean up session on signal exit', async () => { + await manager.start('session-1', mockAgentConfig, '/test/project') + expect(manager.get('session-1')).toBeDefined() + + // Simulate signal exit (e.g., SIGTERM) + const exitCallback = testState.exitCallbacks.get('instance-0') + exitCallback!(null, 'SIGTERM') + + expect(manager.get('session-1')).toBeUndefined() + }) + + it('should clean up session on non-zero exit code', async () => { + await manager.start('session-1', mockAgentConfig, '/test/project') + expect(manager.get('session-1')).toBeDefined() + + // Simulate crash with exit code 1 + const exitCallback = testState.exitCallbacks.get('instance-0') + exitCallback!(1, null) + + expect(manager.get('session-1')).toBeUndefined() + }) + }) + + describe('error handling', () => { + it('should propagate error when agent process fails to start', async () => { + testState.mockStartError = new Error('Failed to spawn process') + + await expect(manager.start('session-1', mockAgentConfig, '/test/project')).rejects.toThrow( + 'Failed to spawn process' + ) + + // Session should not be in the map + expect(manager.get('session-1')).toBeUndefined() + }) + + it('should propagate error when ACP initialization fails', async () => { + testState.mockInitializeError = new Error('Protocol version mismatch') + + await expect(manager.start('session-1', mockAgentConfig, '/test/project')).rejects.toThrow( + 'Protocol version mismatch' + ) + + // Session should not be in the map + expect(manager.get('session-1')).toBeUndefined() + }) + + it('should propagate error when ACP session creation fails', async () => { + testState.mockNewSessionError = new Error('Session limit exceeded') + + await expect(manager.start('session-1', mockAgentConfig, '/test/project')).rejects.toThrow( + 'Session limit exceeded' + ) + + // Session should not be in the map + expect(manager.get('session-1')).toBeUndefined() + }) + }) +}) diff --git a/tests/unit/main/conductor/G3Workaround.test.ts b/tests/unit/main/conductor/G3Workaround.test.ts new file mode 100644 index 0000000000..50ee81ea17 --- /dev/null +++ b/tests/unit/main/conductor/G3Workaround.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { G3Workaround } from '../../../../src/main/conductor/G3Workaround' +import type { ISessionStore, ConductorEvents } from '../../../../src/main/conductor/types' + +describe('G3Workaround', () => { + let g3: G3Workaround + let mockSessionStore: ISessionStore + let mockEvents: ConductorEvents + + beforeEach(() => { + mockSessionStore = { + initialize: vi.fn(), + create: vi.fn(), + get: vi.fn(), + list: vi.fn(), + updateMeta: vi.fn(), + delete: vi.fn(), + appendUpdate: vi.fn().mockResolvedValue({ timestamp: new Date().toISOString() }), + getByAgentSessionId: vi.fn() + } + + mockEvents = { + onSessionUpdate: vi.fn(), + onStatusChange: vi.fn() + } + + g3 = new G3Workaround({ + sessionStore: mockSessionStore, + events: mockEvents + }) + }) + + describe('pendingAnswers', () => { + it('should add pending answer for session', () => { + g3.addPendingAnswer('session-1', 'What color?', 'Blue') + + const answers = g3.getPendingAnswers('session-1') + expect(answers).toHaveLength(1) + expect(answers[0]).toEqual({ question: 'What color?', answer: 'Blue' }) + }) + + it('should accumulate multiple answers for same session', () => { + g3.addPendingAnswer('session-1', 'Question 1?', 'Answer 1') + g3.addPendingAnswer('session-1', 'Question 2?', 'Answer 2') + + const answers = g3.getPendingAnswers('session-1') + expect(answers).toHaveLength(2) + expect(answers[0]).toEqual({ question: 'Question 1?', answer: 'Answer 1' }) + expect(answers[1]).toEqual({ question: 'Question 2?', answer: 'Answer 2' }) + }) + + it('should return empty array for unknown session', () => { + const answers = g3.getPendingAnswers('unknown-session') + expect(answers).toEqual([]) + }) + + it('should keep answers separate per session', () => { + g3.addPendingAnswer('session-1', 'Q1?', 'A1') + g3.addPendingAnswer('session-2', 'Q2?', 'A2') + + expect(g3.getPendingAnswers('session-1')).toHaveLength(1) + expect(g3.getPendingAnswers('session-2')).toHaveLength(1) + expect(g3.getPendingAnswers('session-1')[0].answer).toBe('A1') + expect(g3.getPendingAnswers('session-2')[0].answer).toBe('A2') + }) + + it('should clear pending answers for session', () => { + g3.addPendingAnswer('session-1', 'Q?', 'A') + expect(g3.getPendingAnswers('session-1')).toHaveLength(1) + + g3.clearPendingAnswers('session-1') + expect(g3.getPendingAnswers('session-1')).toEqual([]) + }) + + it('should not affect other sessions when clearing', () => { + g3.addPendingAnswer('session-1', 'Q1?', 'A1') + g3.addPendingAnswer('session-2', 'Q2?', 'A2') + + g3.clearPendingAnswers('session-1') + + expect(g3.getPendingAnswers('session-1')).toEqual([]) + expect(g3.getPendingAnswers('session-2')).toHaveLength(1) + }) + }) + + describe('storeResponse', () => { + it('should persist response when sessionStore is available', async () => { + const response = { + selectedOption: 'Option A', + answers: [{ question: 'Q?', answer: 'A' }] + } + + await g3.storeResponse('session-1', 'tool-call-123', response) + + expect(mockSessionStore.appendUpdate).toHaveBeenCalledWith('session-1', { + sessionId: 'session-1', + update: { + sessionUpdate: 'askuserquestion_response', + toolCallId: 'tool-call-123', + response + } + }) + }) + + it('should emit onSessionUpdate event after storing', async () => { + const response = { selectedOption: 'Option B' } + + await g3.storeResponse('session-1', 'tool-call-456', response) + + expect(mockEvents.onSessionUpdate).toHaveBeenCalledWith( + { + sessionId: 'session-1', + update: { + sessionUpdate: 'askuserquestion_response', + toolCallId: 'tool-call-456', + response + } + }, + undefined + ) + }) + + it('should skip persistence when sessionStore is null', async () => { + const g3NoStore = new G3Workaround({ + sessionStore: null, + events: mockEvents + }) + + await g3NoStore.storeResponse('session-1', 'tool-call', { selectedOption: 'A' }) + + // Should not throw and should not call appendUpdate + expect(mockSessionStore.appendUpdate).not.toHaveBeenCalled() + }) + + it('should not emit event when sessionStore is null', async () => { + const g3NoStore = new G3Workaround({ + sessionStore: null, + events: mockEvents + }) + + await g3NoStore.storeResponse('session-1', 'tool-call', { selectedOption: 'A' }) + + // Should not emit since we can't persist + expect(mockEvents.onSessionUpdate).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests/unit/main/conductor/PromptHandler.test.ts b/tests/unit/main/conductor/PromptHandler.test.ts new file mode 100644 index 0000000000..47664bd51b --- /dev/null +++ b/tests/unit/main/conductor/PromptHandler.test.ts @@ -0,0 +1,424 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { PromptHandler } from '../../../../src/main/conductor/PromptHandler' +import type { + ISessionStore, + IAgentProcessManager, + IG3Workaround, + ConductorEvents, + SessionAgent +} from '../../../../src/main/conductor/types' +import type { AgentConfig } from '../../../../src/shared/types' + +describe('PromptHandler', () => { + let handler: PromptHandler + let mockSessionStore: ISessionStore + let mockAgentProcessManager: IAgentProcessManager + let mockG3Workaround: IG3Workaround + let mockEvents: ConductorEvents + let mockEnsureAgent: ReturnType + let mockSessionAgent: SessionAgent + + const mockAgentConfig: AgentConfig = { + id: 'opencode', + name: 'OpenCode', + command: 'opencode', + args: ['--mcp'], + enabled: true + } + + beforeEach(() => { + // Create mock connection with prompt method + const mockConnection = { + prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), + cancel: vi.fn().mockResolvedValue(undefined) + } + + mockSessionAgent = { + agentProcess: {} as any, + connection: mockConnection as any, + agentConfig: mockAgentConfig, + agentSessionId: 'acp-session-123', + needsHistoryReplay: false + } + + mockSessionStore = { + initialize: vi.fn(), + create: vi.fn(), + get: vi.fn().mockResolvedValue({ session: {}, updates: [] }), + list: vi.fn(), + updateMeta: vi.fn(), + delete: vi.fn(), + appendUpdate: vi.fn().mockResolvedValue({ timestamp: new Date().toISOString() }), + getByAgentSessionId: vi.fn() + } + + mockAgentProcessManager = { + start: vi.fn(), + stop: vi.fn(), + stopAll: vi.fn(), + get: vi.fn().mockReturnValue(mockSessionAgent), + set: vi.fn(), + remove: vi.fn(), + isRunning: vi.fn().mockReturnValue(true), + getRunningSessionIds: vi.fn().mockReturnValue(['session-1']), + getAgentConfig: vi.fn().mockReturnValue(mockAgentConfig) + } + + mockG3Workaround = { + addPendingAnswer: vi.fn(), + getPendingAnswers: vi.fn().mockReturnValue([]), + clearPendingAnswers: vi.fn(), + storeResponse: vi.fn() + } + + mockEvents = { + onSessionUpdate: vi.fn(), + onStatusChange: vi.fn() + } + + mockEnsureAgent = vi.fn().mockResolvedValue(mockSessionAgent) + + handler = new PromptHandler({ + sessionStore: mockSessionStore, + agentProcessManager: mockAgentProcessManager, + g3Workaround: mockG3Workaround, + events: mockEvents, + ensureAgent: mockEnsureAgent + }) + }) + + describe('send', () => { + it('should send prompt and return stop reason', async () => { + const result = await handler.send('session-1', [{ type: 'text', text: 'Hello' }]) + + expect(result).toBe('end_turn') + expect(mockEnsureAgent).toHaveBeenCalledWith('session-1') + }) + + it('should convert MessageContent to ACP format', async () => { + await handler.send('session-1', [ + { type: 'text', text: 'Hello' }, + { type: 'image', data: 'base64data', mimeType: 'image/png' } + ]) + + const connection = mockSessionAgent.connection as any + expect(connection.prompt).toHaveBeenCalledWith({ + sessionId: 'acp-session-123', + prompt: [ + { type: 'text', text: 'Hello' }, + { type: 'image', data: 'base64data', mimeType: 'image/png' } + ] + }) + }) + + it('should store user message before sending', async () => { + await handler.send('session-1', [{ type: 'text', text: 'Hello' }]) + + expect(mockSessionStore.appendUpdate).toHaveBeenCalledWith('session-1', { + sessionId: 'acp-session-123', + update: { + sessionUpdate: 'user_message', + content: [{ type: 'text', text: 'Hello' }], + _internal: false + } + }) + }) + + it('should mark internal messages with _internal flag', async () => { + await handler.send('session-1', [{ type: 'text', text: 'Hello' }], { internal: true }) + + expect(mockSessionStore.appendUpdate).toHaveBeenCalledWith('session-1', { + sessionId: 'acp-session-123', + update: { + sessionUpdate: 'user_message', + content: [{ type: 'text', text: 'Hello' }], + _internal: true + } + }) + }) + + it('should update processing state', async () => { + expect(handler.isProcessing('session-1')).toBe(false) + + const sendPromise = handler.send('session-1', [{ type: 'text', text: 'Hello' }]) + + // During send, should be processing + expect(handler.isProcessing('session-1')).toBe(true) + expect(mockEvents.onStatusChange).toHaveBeenCalled() + + await sendPromise + + // After send, should not be processing + expect(handler.isProcessing('session-1')).toBe(false) + }) + + it('should inject pending G-3 answers', async () => { + mockG3Workaround.getPendingAnswers = vi.fn().mockReturnValue([ + { question: 'What color?', answer: 'Blue' }, + { question: 'What size?', answer: 'Large' } + ]) + + await handler.send('session-1', [{ type: 'text', text: 'Hello' }]) + + const connection = mockSessionAgent.connection as any + const callArgs = connection.prompt.mock.calls[0][0] + + // First element should be the injected answers + expect(callArgs.prompt[0].type).toBe('text') + expect(callArgs.prompt[0].text).toContain('[User\'s answer to "What color?"]: Blue') + expect(callArgs.prompt[0].text).toContain('[User\'s answer to "What size?"]: Large') + + // Pending answers should be cleared + expect(mockG3Workaround.clearPendingAnswers).toHaveBeenCalledWith('session-1') + }) + + it('should handle errors gracefully', async () => { + const mockConnection = mockSessionAgent.connection as any + mockConnection.prompt.mockRejectedValue(new Error('Test error')) + + const result = await handler.send('session-1', [{ type: 'text', text: 'Hello' }]) + + expect(result).toBe('error') + expect(mockEvents.onSessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'acp-session-123', + update: expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Error:') + }) + }) + }), + undefined + ) + }) + + it('should clear processing state on error', async () => { + const mockConnection = mockSessionAgent.connection as any + mockConnection.prompt.mockRejectedValue(new Error('Test error')) + + await handler.send('session-1', [{ type: 'text', text: 'Hello' }]) + + expect(handler.isProcessing('session-1')).toBe(false) + }) + }) + + describe('cancel', () => { + it('should cancel ongoing request', async () => { + await handler.cancel('session-1') + + const connection = mockSessionAgent.connection as any + expect(connection.cancel).toHaveBeenCalledWith({ sessionId: 'acp-session-123' }) + }) + + it('should handle non-existent session gracefully', async () => { + mockAgentProcessManager.get = vi.fn().mockReturnValue(undefined) + + await expect(handler.cancel('non-existent')).resolves.not.toThrow() + }) + }) + + describe('processing state', () => { + it('should return processing session IDs', async () => { + const sendPromise = handler.send('session-1', [{ type: 'text', text: 'Hello' }]) + + expect(handler.getProcessingSessionIds()).toContain('session-1') + + await sendPromise + }) + + it('should track multiple processing sessions', async () => { + // Start two prompts + const mockSessionAgent2 = { + ...mockSessionAgent, + agentSessionId: 'acp-session-456' + } + mockEnsureAgent + .mockResolvedValueOnce(mockSessionAgent) + .mockResolvedValueOnce(mockSessionAgent2) + + const promise1 = handler.send('session-1', [{ type: 'text', text: 'Hello 1' }]) + const promise2 = handler.send('session-2', [{ type: 'text', text: 'Hello 2' }]) + + expect(handler.getProcessingSessionIds()).toContain('session-1') + expect(handler.getProcessingSessionIds()).toContain('session-2') + + await Promise.all([promise1, promise2]) + }) + }) + + describe('history replay', () => { + it('should prepend history for resumed sessions', async () => { + const resumedSessionAgent = { + ...mockSessionAgent, + needsHistoryReplay: true + } + mockEnsureAgent.mockResolvedValue(resumedSessionAgent) + + mockSessionStore.get = vi.fn().mockResolvedValue({ + session: {}, + updates: [ + { + timestamp: new Date().toISOString(), + update: { + update: { + sessionUpdate: 'user_message', + content: [{ type: 'text', text: 'Previous message' }] + } + } + }, + { + timestamp: new Date().toISOString(), + update: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Previous response' } + } + } + } + ] + }) + + await handler.send('session-1', [{ type: 'text', text: 'New message' }]) + + const connection = mockSessionAgent.connection as any + const callArgs = connection.prompt.mock.calls[0][0] + + // First element should be the history + expect(callArgs.prompt[0].type).toBe('text') + expect(callArgs.prompt[0].text).toContain('[Session History') + + // Should mark as replayed + expect(resumedSessionAgent.needsHistoryReplay).toBe(false) + }) + + it('should skip history replay if no meaningful history', async () => { + const resumedSessionAgent = { + ...mockSessionAgent, + needsHistoryReplay: true + } + mockEnsureAgent.mockResolvedValue(resumedSessionAgent) + + mockSessionStore.get = vi.fn().mockResolvedValue({ + session: {}, + updates: [] // Empty history + }) + + await handler.send('session-1', [{ type: 'text', text: 'New message' }]) + + const connection = mockSessionAgent.connection as any + const callArgs = connection.prompt.mock.calls[0][0] + + // Should just have the user message + expect(callArgs.prompt).toHaveLength(1) + expect(callArgs.prompt[0].text).toBe('New message') + }) + + it('should continue without history when sessionStore.get throws', async () => { + const resumedSessionAgent = { + ...mockSessionAgent, + needsHistoryReplay: true + } + mockEnsureAgent.mockResolvedValue(resumedSessionAgent) + + // Simulate sessionStore.get throwing an error + mockSessionStore.get = vi.fn().mockRejectedValue(new Error('Database connection failed')) + + const result = await handler.send('session-1', [{ type: 'text', text: 'New message' }]) + + // Should still send the prompt successfully + expect(result).toBe('end_turn') + + // Should mark as replayed even on error (to prevent repeated attempts) + expect(resumedSessionAgent.needsHistoryReplay).toBe(false) + + const connection = mockSessionAgent.connection as any + const callArgs = connection.prompt.mock.calls[0][0] + + // Should just have the user message (no history) + expect(callArgs.prompt).toHaveLength(1) + expect(callArgs.prompt[0].text).toBe('New message') + }) + }) + + describe('error parsing', () => { + it('should parse MCP missing environment variable error', async () => { + const mockConnection = mockSessionAgent.connection as any + mockConnection.prompt.mockRejectedValue( + new Error('Missing environment variables: OPENAI_API_KEY') + ) + + await handler.send('session-1', [{ type: 'text', text: 'Hello' }]) + + expect(mockEvents.onSessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + content: expect.objectContaining({ + text: expect.stringContaining( + 'MCP server requires environment variable: OPENAI_API_KEY' + ) + }) + }) + }), + undefined + ) + }) + + it('should parse MaxFileReadTokenExceededError', async () => { + const mockConnection = mockSessionAgent.connection as any + mockConnection.prompt.mockRejectedValue( + new Error('MaxFileReadTokenExceededError: file too large') + ) + + await handler.send('session-1', [{ type: 'text', text: 'Hello' }]) + + expect(mockEvents.onSessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + content: expect.objectContaining({ + text: expect.stringContaining('File is too large to read') + }) + }) + }), + undefined + ) + }) + + it('should parse mcp-config-invalid error', async () => { + const mockConnection = mockSessionAgent.connection as any + mockConnection.prompt.mockRejectedValue(new Error('mcp-config-invalid: server name missing')) + + await handler.send('session-1', [{ type: 'text', text: 'Hello' }]) + + expect(mockEvents.onSessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + content: expect.objectContaining({ + text: expect.stringContaining('MCP server configuration is invalid') + }) + }) + }), + undefined + ) + }) + + it('should use generic fallback for unknown errors', async () => { + const mockConnection = mockSessionAgent.connection as any + mockConnection.prompt.mockRejectedValue(new Error('Some completely unknown error')) + + await handler.send('session-1', [{ type: 'text', text: 'Hello' }]) + + expect(mockEvents.onSessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + content: expect.objectContaining({ + text: expect.stringContaining('Agent encountered an error') + }) + }) + }), + undefined + ) + }) + }) +}) diff --git a/tests/unit/main/conductor/SessionLifecycle.test.ts b/tests/unit/main/conductor/SessionLifecycle.test.ts new file mode 100644 index 0000000000..5b78c3ea73 --- /dev/null +++ b/tests/unit/main/conductor/SessionLifecycle.test.ts @@ -0,0 +1,441 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { SessionLifecycle } from '../../../../src/main/conductor/SessionLifecycle' +import type { + ISessionStore, + IAgentProcessManager, + ConductorEvents, + SessionAgent +} from '../../../../src/main/conductor/types' +import type { AgentConfig, MulticaSession, SessionData } from '../../../../src/shared/types' + +describe('SessionLifecycle', () => { + let lifecycle: SessionLifecycle + let mockSessionStore: ISessionStore + let mockAgentProcessManager: IAgentProcessManager + let mockEvents: ConductorEvents + + const mockAgentConfig: AgentConfig = { + id: 'opencode', + name: 'OpenCode', + command: 'opencode', + args: ['--mcp'], + enabled: true + } + + const mockSession: MulticaSession = { + id: 'session-123', + agentSessionId: 'acp-123', + agentId: 'opencode', + workingDirectory: '/test/project', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + status: 'active', + messageCount: 0 + } + + const mockSessionAgent: SessionAgent = { + agentProcess: {} as any, + connection: {} as any, + agentConfig: mockAgentConfig, + agentSessionId: 'acp-123', + needsHistoryReplay: false + } + + beforeEach(() => { + mockSessionStore = { + initialize: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue(mockSession), + get: vi.fn().mockResolvedValue({ session: mockSession, updates: [] } as SessionData), + list: vi.fn().mockResolvedValue([mockSession]), + updateMeta: vi.fn().mockResolvedValue(mockSession), + delete: vi.fn().mockResolvedValue(undefined), + appendUpdate: vi.fn().mockResolvedValue({ timestamp: new Date().toISOString() }), + getByAgentSessionId: vi.fn().mockReturnValue(mockSession) + } + + mockAgentProcessManager = { + start: vi.fn().mockResolvedValue({ connection: {}, agentSessionId: 'acp-new-123' }), + stop: vi.fn().mockResolvedValue(undefined), + stopAll: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockReturnValue(undefined), // Default: no running agent + set: vi.fn(), + remove: vi.fn(), + isRunning: vi.fn().mockReturnValue(false), + getRunningSessionIds: vi.fn().mockReturnValue([]), + getAgentConfig: vi.fn().mockReturnValue(mockAgentConfig) + } + + mockEvents = { + onSessionUpdate: vi.fn(), + onStatusChange: vi.fn(), + onSessionMetaUpdated: vi.fn() + } + + lifecycle = new SessionLifecycle({ + sessionStore: mockSessionStore, + agentProcessManager: mockAgentProcessManager, + events: mockEvents + }) + }) + + describe('initialize', () => { + it('should initialize session store', async () => { + await lifecycle.initialize() + expect(mockSessionStore.initialize).toHaveBeenCalled() + }) + + it('should handle missing session store (CLI mode)', async () => { + const cliLifecycle = new SessionLifecycle({ + sessionStore: null, + agentProcessManager: mockAgentProcessManager, + events: mockEvents + }) + + await expect(cliLifecycle.initialize()).resolves.not.toThrow() + }) + }) + + describe('create', () => { + it('should create session with persistence', async () => { + const session = await lifecycle.create('/test/project', mockAgentConfig) + + expect(mockSessionStore.create).toHaveBeenCalledWith({ + agentSessionId: '', + agentId: 'opencode', + workingDirectory: '/test/project' + }) + expect(session).toEqual(mockSession) + }) + + it('should create in-memory session in CLI mode', async () => { + const cliLifecycle = new SessionLifecycle({ + sessionStore: null, + agentProcessManager: mockAgentProcessManager, + events: mockEvents + }) + + const session = await cliLifecycle.create('/test/project', mockAgentConfig) + + expect(session.agentId).toBe('opencode') + expect(session.workingDirectory).toBe('/test/project') + expect(session.status).toBe('active') + expect(cliLifecycle.getInMemorySession()).toBe(session) + }) + }) + + describe('load', () => { + it('should load session without starting agent', async () => { + const session = await lifecycle.load('session-123') + + expect(session).toEqual(mockSession) + expect(mockAgentProcessManager.start).not.toHaveBeenCalled() + }) + + it('should throw for non-existent session', async () => { + mockSessionStore.get = vi.fn().mockResolvedValue(null) + + await expect(lifecycle.load('non-existent')).rejects.toThrow('Session not found') + }) + + it('should throw in CLI mode', async () => { + const cliLifecycle = new SessionLifecycle({ + sessionStore: null, + agentProcessManager: mockAgentProcessManager, + events: mockEvents + }) + + await expect(cliLifecycle.load('session-123')).rejects.toThrow('not available in CLI mode') + }) + }) + + describe('resume', () => { + it('should start agent for existing session', async () => { + const session = await lifecycle.resume('session-123') + + expect(mockAgentProcessManager.start).toHaveBeenCalledWith( + 'session-123', + expect.objectContaining({ id: 'opencode' }), + '/test/project', + true // isResumed + ) + expect(mockSessionStore.updateMeta).toHaveBeenCalledWith('session-123', { + agentSessionId: 'acp-new-123', + status: 'active' + }) + }) + + it('should return existing session if agent already running', async () => { + mockAgentProcessManager.get = vi.fn().mockReturnValue(mockSessionAgent) + + const session = await lifecycle.resume('session-123') + + expect(mockAgentProcessManager.start).not.toHaveBeenCalled() + expect(session).toEqual(mockSession) + }) + + it('should throw for unknown agent', async () => { + mockSessionStore.get = vi.fn().mockResolvedValue({ + session: { ...mockSession, agentId: 'unknown-agent' }, + updates: [] + }) + + await expect(lifecycle.resume('session-123')).rejects.toThrow('Unknown agent') + }) + }) + + describe('delete', () => { + it('should stop agent and delete from store', async () => { + await lifecycle.delete('session-123') + + expect(mockAgentProcessManager.stop).toHaveBeenCalledWith('session-123') + expect(mockSessionStore.delete).toHaveBeenCalledWith('session-123') + }) + + it('should clear in-memory session in CLI mode', async () => { + const cliLifecycle = new SessionLifecycle({ + sessionStore: null, + agentProcessManager: mockAgentProcessManager, + events: mockEvents + }) + + await cliLifecycle.create('/test/project', mockAgentConfig) + const inMemory = cliLifecycle.getInMemorySession() + expect(inMemory).not.toBeNull() + + await cliLifecycle.delete(inMemory!.id) + expect(cliLifecycle.getInMemorySession()).toBeNull() + }) + }) + + describe('updateMeta', () => { + it('should update session metadata', async () => { + await lifecycle.updateMeta('session-123', { title: 'New Title' }) + + expect(mockSessionStore.updateMeta).toHaveBeenCalledWith('session-123', { + title: 'New Title' + }) + }) + + it('should throw in CLI mode', async () => { + const cliLifecycle = new SessionLifecycle({ + sessionStore: null, + agentProcessManager: mockAgentProcessManager, + events: mockEvents + }) + + await expect(cliLifecycle.updateMeta('session-123', {})).rejects.toThrow( + 'not available in CLI mode' + ) + }) + }) + + describe('switchAgent', () => { + it('should stop current agent and start new one', async () => { + await lifecycle.switchAgent('session-123', 'claude-code') + + expect(mockAgentProcessManager.stop).toHaveBeenCalledWith('session-123') + expect(mockSessionStore.updateMeta).toHaveBeenCalledWith('session-123', { + agentId: 'claude-code' + }) + expect(mockAgentProcessManager.start).toHaveBeenCalledWith( + 'session-123', + expect.objectContaining({ id: 'claude-code' }), + '/test/project', + true + ) + }) + + it('should notify frontend of metadata change', async () => { + await lifecycle.switchAgent('session-123', 'claude-code') + + expect(mockEvents.onSessionMetaUpdated).toHaveBeenCalled() + }) + + it('should throw for unknown new agent', async () => { + await expect(lifecycle.switchAgent('session-123', 'unknown-agent')).rejects.toThrow( + 'Unknown agent' + ) + }) + + it('should throw when session not found', async () => { + mockSessionStore.get = vi.fn().mockResolvedValue(null) + + await expect(lifecycle.switchAgent('non-existent', 'claude-code')).rejects.toThrow( + 'Session not found' + ) + }) + + it('should throw in CLI mode', async () => { + const cliLifecycle = new SessionLifecycle({ + sessionStore: null, + agentProcessManager: mockAgentProcessManager, + events: mockEvents + }) + + await expect(cliLifecycle.switchAgent('session-123', 'claude-code')).rejects.toThrow( + 'not available in CLI mode' + ) + }) + }) + + describe('list', () => { + it('should list sessions from store', async () => { + const sessions = await lifecycle.list() + + expect(sessions).toEqual([mockSession]) + expect(mockSessionStore.list).toHaveBeenCalled() + }) + + it('should return in-memory session in CLI mode', async () => { + const cliLifecycle = new SessionLifecycle({ + sessionStore: null, + agentProcessManager: mockAgentProcessManager, + events: mockEvents + }) + + await cliLifecycle.create('/test/project', mockAgentConfig) + const sessions = await cliLifecycle.list() + + expect(sessions).toHaveLength(1) + expect(sessions[0].workingDirectory).toBe('/test/project') + }) + + it('should return empty array in CLI mode with no session', async () => { + const cliLifecycle = new SessionLifecycle({ + sessionStore: null, + agentProcessManager: mockAgentProcessManager, + events: mockEvents + }) + + const sessions = await cliLifecycle.list() + expect(sessions).toEqual([]) + }) + }) + + describe('getData', () => { + it('should return complete session data', async () => { + const data = await lifecycle.getData('session-123') + + expect(data).toEqual({ session: mockSession, updates: [] }) + }) + + it('should return null in CLI mode', async () => { + const cliLifecycle = new SessionLifecycle({ + sessionStore: null, + agentProcessManager: mockAgentProcessManager, + events: mockEvents + }) + + const data = await cliLifecycle.getData('session-123') + expect(data).toBeNull() + }) + }) + + describe('getSessionIdByAgentSessionId', () => { + it('should find session ID from running agents', () => { + mockAgentProcessManager.getRunningSessionIds = vi.fn().mockReturnValue(['session-123']) + mockAgentProcessManager.get = vi.fn().mockReturnValue(mockSessionAgent) + + const sessionId = lifecycle.getSessionIdByAgentSessionId('acp-123') + expect(sessionId).toBe('session-123') + }) + + it('should fallback to session store', () => { + mockAgentProcessManager.getRunningSessionIds = vi.fn().mockReturnValue([]) + + const sessionId = lifecycle.getSessionIdByAgentSessionId('acp-123') + expect(sessionId).toBe('session-123') + expect(mockSessionStore.getByAgentSessionId).toHaveBeenCalledWith('acp-123') + }) + + it('should return null if not found', () => { + mockAgentProcessManager.getRunningSessionIds = vi.fn().mockReturnValue([]) + mockSessionStore.getByAgentSessionId = vi.fn().mockReturnValue(null) + + const sessionId = lifecycle.getSessionIdByAgentSessionId('unknown') + expect(sessionId).toBeNull() + }) + }) + + describe('ensureAgentForSession', () => { + it('should return existing agent if already running', async () => { + mockAgentProcessManager.get = vi.fn().mockReturnValue(mockSessionAgent) + + const result = await lifecycle.ensureAgentForSession('session-123') + + expect(result).toBe(mockSessionAgent) + expect(mockAgentProcessManager.start).not.toHaveBeenCalled() + }) + + it('should start agent if not running', async () => { + mockAgentProcessManager.get = vi + .fn() + .mockReturnValueOnce(undefined) // First call: not running + .mockReturnValueOnce(mockSessionAgent) // Second call: after start + + const result = await lifecycle.ensureAgentForSession('session-123') + + expect(mockAgentProcessManager.start).toHaveBeenCalledWith( + 'session-123', + expect.objectContaining({ id: 'opencode' }), + '/test/project', + true + ) + expect(mockEvents.onSessionMetaUpdated).toHaveBeenCalled() + }) + + it('should throw in CLI mode', async () => { + const cliLifecycle = new SessionLifecycle({ + sessionStore: null, + agentProcessManager: mockAgentProcessManager, + events: mockEvents + }) + + await expect(cliLifecycle.ensureAgentForSession('session-123')).rejects.toThrow( + 'Cannot auto-start agent in CLI mode' + ) + }) + + it('should throw when session not found', async () => { + mockAgentProcessManager.get = vi.fn().mockReturnValue(undefined) + mockSessionStore.get = vi.fn().mockResolvedValue(null) + + await expect(lifecycle.ensureAgentForSession('non-existent')).rejects.toThrow( + 'Session not found' + ) + }) + + it('should throw when agent config is unknown', async () => { + mockAgentProcessManager.get = vi.fn().mockReturnValue(undefined) + mockSessionStore.get = vi.fn().mockResolvedValue({ + session: { ...mockSession, agentId: 'unknown-agent' }, + updates: [] + }) + + await expect(lifecycle.ensureAgentForSession('session-123')).rejects.toThrow('Unknown agent') + }) + }) + + describe('getInMemorySession', () => { + it('should return null when no in-memory session exists', () => { + expect(lifecycle.getInMemorySession()).toBeNull() + }) + + it('should return in-memory session after creation in CLI mode', async () => { + const cliLifecycle = new SessionLifecycle({ + sessionStore: null, + agentProcessManager: mockAgentProcessManager, + events: mockEvents + }) + + expect(cliLifecycle.getInMemorySession()).toBeNull() + + await cliLifecycle.create('/test/project', mockAgentConfig) + + const inMemory = cliLifecycle.getInMemorySession() + expect(inMemory).not.toBeNull() + expect(inMemory?.workingDirectory).toBe('/test/project') + expect(inMemory?.agentId).toBe('opencode') + }) + }) +})