From cd27c057f6413cff28c418747c1a594999a2518f Mon Sep 17 00:00:00 2001 From: Ido Frizler Date: Sun, 8 Feb 2026 12:58:55 +0200 Subject: [PATCH] refactor(main): remove session keep-alive logic and extract event forwarding --- src/main/main.ts | 275 +++++++++++++++++-------------------------- src/renderer/App.tsx | 73 ++---------- 2 files changed, 124 insertions(+), 224 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index fa510002..1dede924 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -541,77 +541,13 @@ const sessions = new Map(); let activeSessionId: string | null = null; let sessionCounter = 0; -// Keep-alive interval (5 minutes) to prevent session timeout -const SESSION_KEEPALIVE_INTERVAL = 5 * 60 * 1000; -let keepAliveTimer: NodeJS.Timeout | null = null; - -// Start keep-alive timer for active sessions -function startKeepAlive(): void { - if (keepAliveTimer) return; - - keepAliveTimer = setInterval(async () => { - for (const [sessionId, sessionState] of sessions.entries()) { - // Only ping sessions that are actively processing to avoid noise - if (!sessionState.isProcessing) continue; - - try { - // Ping the session by getting messages (lightweight operation) - await sessionState.session.getMessages(); - log.info(`[${sessionId}] Keep-alive ping successful`); - } catch (error) { - log.warn(`[${sessionId}] Keep-alive ping failed:`, error); - // Session may have timed out on the backend - send idle event to frontend - // to ensure the UI doesn't stay stuck in "processing" state - if (mainWindow && !mainWindow.isDestroyed()) { - log.info(`[${sessionId}] Sending fallback idle event due to session timeout`); - mainWindow.webContents.send('copilot:idle', { sessionId }); - sessionState.isProcessing = false; - } - } - } - }, SESSION_KEEPALIVE_INTERVAL); - - log.info('Started session keep-alive timer'); -} - -// Stop keep-alive timer -function stopKeepAlive(): void { - if (keepAliveTimer) { - clearInterval(keepAliveTimer); - keepAliveTimer = null; - log.info('Stopped session keep-alive timer'); - } -} - -// Resume a session that has been disconnected -async function resumeDisconnectedSession( - sessionId: string, - sessionState: SessionState -): Promise { - log.info(`[${sessionId}] Attempting to resume disconnected session...`); - - const client = await getClientForCwd(sessionState.cwd); - const mcpConfig = await readMcpConfig(); - - // Create browser tools for resumed session - const browserTools = createBrowserTools(sessionId); - log.info( - `[${sessionId}] Resuming with ${browserTools.length} tools:`, - browserTools.map((t) => t.name).join(', ') - ); - - const resumedSession = await client.resumeSession(sessionId, { - mcpServers: mcpConfig.mcpServers, - tools: browserTools, - onPermissionRequest: (request, invocation) => - handlePermissionRequest(request, invocation, sessionId), - }); - - // Set up event handler for resumed session - resumedSession.on((event) => { +// Registers event forwarding from a CopilotSession to the renderer via IPC. +// Used after createSession and resumeSession to wire up the session. +function registerSessionEventForwarding(sessionId: string, session: CopilotSession): void { + session.on((event) => { if (!mainWindow || mainWindow.isDestroyed()) return; - log.info(`[${sessionId}] Event:`, event.type); + console.log(`[${sessionId}] Event:`, event.type); if (event.type === 'assistant.message_delta') { mainWindow.webContents.send('copilot:delta', { sessionId, content: event.data.deltaContent }); @@ -623,7 +559,7 @@ async function resumeDisconnectedSession( mainWindow.webContents.send('copilot:idle', { sessionId }); requestUserAttention(); } else if (event.type === 'tool.execution_start') { - log.info(`[${sessionId}] Tool start FULL:`, JSON.stringify(event.data, null, 2)); + console.log(`[${sessionId}] Tool start FULL:`, JSON.stringify(event.data, null, 2)); mainWindow.webContents.send('copilot:tool-start', { sessionId, toolCallId: event.data.toolCallId, @@ -631,7 +567,7 @@ async function resumeDisconnectedSession( input: event.data.arguments || (event.data as Record), }); } else if (event.type === 'tool.execution_complete') { - log.info(`[${sessionId}] Tool end FULL:`, JSON.stringify(event.data, null, 2)); + console.log(`[${sessionId}] Tool end FULL:`, JSON.stringify(event.data, null, 2)); const completeData = event.data as Record; mainWindow.webContents.send('copilot:tool-end', { sessionId, @@ -641,7 +577,7 @@ async function resumeDisconnectedSession( output: event.data.result?.content || completeData.output, }); } else if (event.type === 'session.error') { - log.info(`[${sessionId}] Session error:`, event.data); + console.log(`[${sessionId}] Session error:`, event.data); const errorMessage = event.data?.message || JSON.stringify(event.data); // Auto-repair tool_result errors (duplicate or orphaned after compaction) @@ -675,10 +611,10 @@ async function resumeDisconnectedSession( messagesLength: event.data.messagesLength, }); } else if (event.type === 'session.compaction_start') { - log.info(`[${sessionId}] Compaction started`); + console.log(`[${sessionId}] Compaction started`); mainWindow.webContents.send('copilot:compactionStart', { sessionId }); } else if (event.type === 'session.compaction_complete') { - log.info(`[${sessionId}] Compaction complete:`, event.data); + console.log(`[${sessionId}] Compaction complete:`, event.data); mainWindow.webContents.send('copilot:compactionComplete', { sessionId, success: event.data.success, @@ -690,6 +626,75 @@ async function resumeDisconnectedSession( }); } }); +} + +// Keep-alive interval (5 minutes) to prevent session timeout +const SESSION_KEEPALIVE_INTERVAL = 5 * 60 * 1000; +let keepAliveTimer: NodeJS.Timeout | null = null; + +// Start keep-alive timer for active sessions +function startKeepAlive(): void { + if (keepAliveTimer) return; + + keepAliveTimer = setInterval(async () => { + for (const [sessionId, sessionState] of sessions.entries()) { + // Only ping sessions that are actively processing to avoid noise + if (!sessionState.isProcessing) continue; + + try { + // Ping the session by getting messages (lightweight operation) + await sessionState.session.getMessages(); + log.info(`[${sessionId}] Keep-alive ping successful`); + } catch (error) { + log.warn(`[${sessionId}] Keep-alive ping failed:`, error); + // Session may have timed out on the backend - send idle event to frontend + // to ensure the UI doesn't stay stuck in "processing" state + if (mainWindow && !mainWindow.isDestroyed()) { + log.info(`[${sessionId}] Sending fallback idle event due to session timeout`); + mainWindow.webContents.send('copilot:idle', { sessionId }); + sessionState.isProcessing = false; + } + } + } + }, SESSION_KEEPALIVE_INTERVAL); + + log.info('Started session keep-alive timer'); +} + +// Stop keep-alive timer +function stopKeepAlive(): void { + if (keepAliveTimer) { + clearInterval(keepAliveTimer); + keepAliveTimer = null; + log.info('Stopped session keep-alive timer'); + } +} + +// Resume a session that has been disconnected +async function resumeDisconnectedSession( + sessionId: string, + sessionState: SessionState +): Promise { + log.info(`[${sessionId}] Attempting to resume disconnected session...`); + + const client = await getClientForCwd(sessionState.cwd); + const mcpConfig = await readMcpConfig(); + + // Create browser tools for resumed session + const browserTools = createBrowserTools(sessionId); + log.info( + `[${sessionId}] Resuming with ${browserTools.length} tools:`, + browserTools.map((t) => t.name).join(', ') + ); + + const resumedSession = await client.resumeSession(sessionId, { + mcpServers: mcpConfig.mcpServers, + tools: browserTools, + onPermissionRequest: (request, invocation) => + handlePermissionRequest(request, invocation, sessionId), + }); + + registerSessionEventForwarding(sessionId, resumedSession); // Update session state with new session object sessionState.session = resumedSession; @@ -1483,90 +1488,7 @@ Browser tools available: browser_navigate, browser_click, browser_fill, browser_ const sessionId = newSession.sessionId; // Use SDK's session ID - // Set up event handler for this session - newSession.on((event) => { - if (!mainWindow || mainWindow.isDestroyed()) return; - - // Always forward events - frontend routes by sessionId - console.log(`[${sessionId}] Event:`, event.type); - - if (event.type === 'assistant.message_delta') { - mainWindow.webContents.send('copilot:delta', { sessionId, content: event.data.deltaContent }); - } else if (event.type === 'assistant.message') { - mainWindow.webContents.send('copilot:message', { sessionId, content: event.data.content }); - } else if (event.type === 'session.idle') { - const currentSessionState = sessions.get(sessionId); - if (currentSessionState) currentSessionState.isProcessing = false; - mainWindow.webContents.send('copilot:idle', { sessionId }); - requestUserAttention(); - } else if (event.type === 'tool.execution_start') { - console.log(`[${sessionId}] Tool start FULL:`, JSON.stringify(event.data, null, 2)); - mainWindow.webContents.send('copilot:tool-start', { - sessionId, - toolCallId: event.data.toolCallId, - toolName: event.data.toolName, - input: event.data.arguments || (event.data as Record), - }); - } else if (event.type === 'tool.execution_complete') { - console.log(`[${sessionId}] Tool end FULL:`, JSON.stringify(event.data, null, 2)); - const completeData = event.data as Record; - mainWindow.webContents.send('copilot:tool-end', { - sessionId, - toolCallId: event.data.toolCallId, - toolName: completeData.toolName, - input: completeData.arguments || completeData, - output: event.data.result?.content || completeData.output, - }); - } else if (event.type === 'session.error') { - console.log(`[${sessionId}] Session error:`, event.data); - const errorMessage = event.data?.message || JSON.stringify(event.data); - - // Auto-repair tool_result errors (duplicate or orphaned after compaction) - if ( - errorMessage.includes('multiple `tool_result` blocks') || - errorMessage.includes('each tool_use must have a single result') || - errorMessage.includes('unexpected `tool_use_id`') || - errorMessage.includes('Each `tool_result` block must have a corresponding `tool_use`') - ) { - log.info(`[${sessionId}] Detected tool_result corruption error, attempting auto-repair...`); - repairDuplicateToolResults(sessionId).then((repaired) => { - if (repaired) { - mainWindow?.webContents.send('copilot:error', { - sessionId, - message: 'Session repaired. Please resend your last message.', - isRepaired: true, - }); - } else { - mainWindow?.webContents.send('copilot:error', { sessionId, message: errorMessage }); - } - }); - return; - } - - mainWindow.webContents.send('copilot:error', { sessionId, message: errorMessage }); - } else if (event.type === 'session.usage_info') { - mainWindow.webContents.send('copilot:usageInfo', { - sessionId, - tokenLimit: event.data.tokenLimit, - currentTokens: event.data.currentTokens, - messagesLength: event.data.messagesLength, - }); - } else if (event.type === 'session.compaction_start') { - console.log(`[${sessionId}] Compaction started`); - mainWindow.webContents.send('copilot:compactionStart', { sessionId }); - } else if (event.type === 'session.compaction_complete') { - console.log(`[${sessionId}] Compaction complete:`, event.data); - mainWindow.webContents.send('copilot:compactionComplete', { - sessionId, - success: event.data.success, - preCompactionTokens: event.data.preCompactionTokens, - postCompactionTokens: event.data.postCompactionTokens, - tokensRemoved: event.data.tokensRemoved, - summaryContent: event.data.summaryContent, - error: event.data.error, - }); - } - }); + registerSessionEventForwarding(sessionId, newSession); sessions.set(sessionId, { session: newSession, @@ -2477,16 +2399,41 @@ ipcMain.handle('copilot:setModel', async (_event, data: { sessionId: string; mod const sessionState = sessions.get(data.sessionId); if (sessionState) { - // Destroy old session before creating new one + const { cwd, client } = sessionState; + + // Destroy local session state (conversation history is preserved on server) console.log(`Destroying session ${data.sessionId} before model change to ${data.model}`); await sessionState.session.destroy(); sessions.delete(data.sessionId); - // Create replacement session with new model (keep same cwd) - const newSessionId = await createNewSession(data.model, sessionState.cwd); - const newSessionState = sessions.get(newSessionId)!; - console.log(`Sessions after model change: ${sessions.size} active`); - return { sessionId: newSessionId, model: data.model, cwd: newSessionState.cwd }; + const mcpConfig = await readMcpConfig(); + const browserTools = createBrowserTools(data.sessionId); + + // Resume the same session with the new model — preserves conversation context + const resumedSession = await client.resumeSession(data.sessionId, { + model: data.model, + mcpServers: mcpConfig.mcpServers, + tools: browserTools, + onPermissionRequest: (request, invocation) => + handlePermissionRequest(request, invocation, resumedSession.sessionId), + }); + + const resumedSessionId = resumedSession.sessionId; + registerSessionEventForwarding(resumedSessionId, resumedSession); + + sessions.set(resumedSessionId, { + session: resumedSession, + client, + model: data.model, + cwd, + alwaysAllowed: new Set(sessionState.alwaysAllowed), + allowedPaths: new Set(sessionState.allowedPaths), + isProcessing: false, + }); + activeSessionId = resumedSessionId; + + console.log(`Session ${resumedSessionId} resumed with model ${data.model}`); + return { sessionId: resumedSessionId, model: data.model, cwd }; } return { model: data.model }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 520891cc..99a9fe17 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4547,68 +4547,21 @@ Only when ALL the above are verified complete, output exactly: ${RALPH_COMPLETIO setStatus('connecting'); try { - // If current tab has messages, create a new tab with the new model instead of replacing - if (activeTab.messages.length > 0) { - const result = await window.electronAPI.copilot.createSession(); - trackEvent(TelemetryEvents.SESSION_CREATED); - // Now change the model on the new session - const modelResult = await window.electronAPI.copilot.setModel(result.sessionId, model); - trackEvent(TelemetryEvents.FEATURE_MODEL_CHANGED); - - const newTab: TabState = { - id: modelResult.sessionId, - name: generateTabName(), - messages: [], - model: modelResult.model, - cwd: modelResult.cwd || result.cwd, - isProcessing: false, - activeTools: [], - hasUnreadCompletion: false, - pendingConfirmations: [], - needsTitle: true, - alwaysAllowed: [], - editedFiles: [], - untrackedFiles: [], - fileViewMode: 'flat', - currentIntent: null, - currentIntentTimestamp: null, - gitBranchRefresh: 0, - }; - setTabs((prev) => [...prev, newTab]); - setActiveTabId(modelResult.sessionId); - setStatus('connected'); - return; - } - - // Empty tab - replace the session with the new model const result = await window.electronAPI.copilot.setModel(activeTab.id, model); trackEvent(TelemetryEvents.FEATURE_MODEL_CHANGED); - // Update the tab with new session ID and model, clear messages - setTabs((prev) => { - const updated = prev.filter((t) => t.id !== activeTab.id); - return [ - ...updated, - { - id: result.sessionId, - name: activeTab.name, - messages: [], - model: result.model, - cwd: result.cwd || activeTab.cwd, - isProcessing: false, - activeTools: [], - hasUnreadCompletion: false, - pendingConfirmations: [], - needsTitle: true, - alwaysAllowed: [], - editedFiles: [], - untrackedFiles: [], - fileViewMode: 'flat', - currentIntent: null, - currentIntentTimestamp: null, - gitBranchRefresh: 0, - }, - ]; - }); + // Update the tab in-place: swap session ID and model, preserve everything else + setTabs((prev) => + prev.map((t) => + t.id === activeTab.id + ? { + ...t, + id: result.sessionId, + model: result.model, + cwd: result.cwd || t.cwd, + } + : t + ) + ); setActiveTabId(result.sessionId); setStatus('connected'); } catch (error) {