From a673c93fe9c35fc108640020ffbfca24da7fdbff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sondre=20Engebr=C3=A5ten?= Date: Sun, 15 Feb 2026 11:46:13 +0100 Subject: [PATCH 1/5] fix(profiles): use unified account selection in terminal rate limit handler Replaces getBestAvailableProfile (OAuth-only) with getBestAvailableUnifiedAccount (OAuth + API) so terminals can swap to API profiles like GLM when OAuth is rate-limited. - Add ensureBestProfileActive() helper for proactive swap before invoking Claude (awaited in async path, fire-and-forget in sync) - Rewrite handleRateLimit() to use unified account selection - API profile swaps use setActiveAPIProfile(), OAuth uses existing switchProfileCallback - Add suggestedAccountType to RateLimitEvent type - Add 6 new tests for unified swap behavior Co-Authored-By: Claude Opus 4.6 --- .../claude-integration-handler.test.ts | 178 ++++++++++++++++++ .../terminal/claude-integration-handler.ts | 115 ++++++++--- apps/frontend/src/main/terminal/types.ts | 1 + 3 files changed, 272 insertions(+), 22 deletions(-) diff --git a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts index 5126fd6045..dba3e0b98b 100644 --- a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts +++ b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts @@ -90,6 +90,12 @@ vi.mock('../pty-manager', () => ({ writeToPty: mockWriteToPty, })); +// Mock setActiveAPIProfile for handleRateLimit API profile auto-switch +const mockSetActiveAPIProfile = vi.fn().mockResolvedValue({}); +vi.mock('../../services/profile/profile-manager', () => ({ + setActiveAPIProfile: mockSetActiveAPIProfile, +})); + vi.mock('os', async (importOriginal) => { const actual = await importOriginal(); return { @@ -1117,4 +1123,176 @@ describe('claude-integration-handler - Helper Functions', () => { expect(shouldAutoRenameTerminal('Terminal\t1')).toBe(false); }); }); + + // =========================================================================== + // handleRateLimit — Unified account selection (OAuth + API) + // =========================================================================== + describe('handleRateLimit - unified account selection', () => { + const mockExtractRateLimitReset = vi.fn(); + const mockBestAvailableUnifiedAccount = vi.fn(); + const mockRecordRateLimitEvent = vi.fn().mockReturnValue({ type: 'session' }); + const mockGetAutoSwitchSettings = vi.fn(); + const mockSwitchProfileCallback = vi.fn().mockResolvedValue(undefined); + const mockSendIpc = vi.fn(); + + function createRateLimitTestContext() { + const terminal = createMockTerminal({ claudeProfileId: 'oauth-1' }); + const lastNotified = new Map(); + + mockExtractRateLimitReset.mockReturnValue('Feb 19 at 11am'); + mockBestAvailableUnifiedAccount.mockResolvedValue(null); + mockGetAutoSwitchSettings.mockReturnValue({ + enabled: true, + autoSwitchOnRateLimit: true, + }); + + mockGetClaudeProfileManager.mockReturnValue({ + recordRateLimitEvent: mockRecordRateLimitEvent, + getAutoSwitchSettings: mockGetAutoSwitchSettings, + getBestAvailableUnifiedAccount: mockBestAvailableUnifiedAccount, + }); + + const getWindow = vi.fn().mockReturnValue({ + webContents: { send: mockSendIpc }, + }); + + return { terminal, lastNotified, getWindow }; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sends IPC event with suggestedAccountType for API profiles', async () => { + // Override extractRateLimitReset for this test + vi.doMock('../output-parser', () => ({ + extractRateLimitReset: () => 'Feb 19 at 11am', + })); + + const { handleRateLimit } = await import('../claude-integration-handler'); + const { terminal, lastNotified, getWindow } = createRateLimitTestContext(); + + mockBestAvailableUnifiedAccount.mockResolvedValue({ + id: 'api-glm-1', + name: 'GLM API', + type: 'api', + isAvailable: true, + }); + + handleRateLimit(terminal, 'Limit reached · resets Feb 19 at 11am', lastNotified, getWindow, mockSwitchProfileCallback); + + // Wait for async unified account selection + await vi.waitFor(() => { + expect(mockSendIpc).toHaveBeenCalled(); + }); + + const ipcPayload = mockSendIpc.mock.calls[0][1]; + expect(ipcPayload.suggestedAccountType).toBe('api'); + expect(ipcPayload.suggestedProfileId).toBe('api-glm-1'); + expect(ipcPayload.suggestedProfileName).toBe('GLM API'); + }); + + it('calls setActiveAPIProfile (not switchProfileCallback) when auto-switching to API profile', async () => { + const { handleRateLimit } = await import('../claude-integration-handler'); + const { terminal, lastNotified, getWindow } = createRateLimitTestContext(); + + mockBestAvailableUnifiedAccount.mockResolvedValue({ + id: 'api-glm-1', + name: 'GLM API', + type: 'api', + isAvailable: true, + }); + + handleRateLimit(terminal, 'Limit reached · resets Feb 19 at 11am', lastNotified, getWindow, mockSwitchProfileCallback); + + await vi.waitFor(() => { + expect(mockSetActiveAPIProfile).toHaveBeenCalledWith('api-glm-1'); + }); + + // switchProfileCallback should NOT be called for API profiles + expect(mockSwitchProfileCallback).not.toHaveBeenCalled(); + }); + + it('calls switchProfileCallback (not setActiveAPIProfile) when auto-switching to OAuth profile', async () => { + const { handleRateLimit } = await import('../claude-integration-handler'); + const { terminal, lastNotified, getWindow } = createRateLimitTestContext(); + + mockBestAvailableUnifiedAccount.mockResolvedValue({ + id: 'oauth-2', + name: 'Account 2', + type: 'oauth', + isAvailable: true, + }); + + handleRateLimit(terminal, 'Limit reached · resets Feb 19 at 11am', lastNotified, getWindow, mockSwitchProfileCallback); + + await vi.waitFor(() => { + expect(mockSwitchProfileCallback).toHaveBeenCalledWith('term-1', 'oauth-2'); + }); + + // setActiveAPIProfile should NOT be called for OAuth profiles + expect(mockSetActiveAPIProfile).not.toHaveBeenCalled(); + }); + + it('sends IPC event without suggested profile when unified account selection fails', async () => { + const { handleRateLimit } = await import('../claude-integration-handler'); + const { terminal, lastNotified, getWindow } = createRateLimitTestContext(); + + mockBestAvailableUnifiedAccount.mockRejectedValue(new Error('Profile load failed')); + + handleRateLimit(terminal, 'Limit reached · resets Feb 19 at 11am', lastNotified, getWindow, mockSwitchProfileCallback); + + // Wait for the error fallback path + await vi.waitFor(() => { + expect(mockSendIpc).toHaveBeenCalled(); + }); + + const ipcPayload = mockSendIpc.mock.calls[0][1]; + expect(ipcPayload.suggestedProfileId).toBeUndefined(); + expect(ipcPayload.suggestedAccountType).toBeUndefined(); + }); + + it('does not auto-switch when autoSwitchOnRateLimit is disabled', async () => { + const { handleRateLimit } = await import('../claude-integration-handler'); + const { terminal, lastNotified, getWindow } = createRateLimitTestContext(); + + mockGetAutoSwitchSettings.mockReturnValue({ + enabled: true, + autoSwitchOnRateLimit: false, + }); + + mockBestAvailableUnifiedAccount.mockResolvedValue({ + id: 'api-glm-1', + name: 'GLM API', + type: 'api', + isAvailable: true, + }); + + handleRateLimit(terminal, 'Limit reached · resets Feb 19 at 11am', lastNotified, getWindow, mockSwitchProfileCallback); + + await vi.waitFor(() => { + expect(mockSendIpc).toHaveBeenCalled(); + }); + + // Neither switch mechanism should be called + expect(mockSetActiveAPIProfile).not.toHaveBeenCalled(); + expect(mockSwitchProfileCallback).not.toHaveBeenCalled(); + }); + + it('does not auto-switch when no best account is available', async () => { + const { handleRateLimit } = await import('../claude-integration-handler'); + const { terminal, lastNotified, getWindow } = createRateLimitTestContext(); + + mockBestAvailableUnifiedAccount.mockResolvedValue(null); + + handleRateLimit(terminal, 'Limit reached · resets Feb 19 at 11am', lastNotified, getWindow, mockSwitchProfileCallback); + + await vi.waitFor(() => { + expect(mockSendIpc).toHaveBeenCalled(); + }); + + expect(mockSetActiveAPIProfile).not.toHaveBeenCalled(); + expect(mockSwitchProfileCallback).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index ef4c92b903..51c083cbf0 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -399,7 +399,41 @@ export function finalizeClaudeInvoke( } /** - * Handle rate limit detection and profile switching + * Proactive profile swap: check if any better profile (OAuth or API) is available + * before invoking Claude. If the active profile is rate-limited or at capacity, + * swap to the best available unified account and persist the change globally. + * + * Safe to call from both sync and async invoke paths — errors are caught + * and logged, never propagated to the caller. + */ +async function ensureBestProfileActive(): Promise { + try { + const profileManager = getClaudeProfileManager(); + const activeProfile = profileManager.getActiveProfile(); + const bestAccount = await profileManager.getBestAvailableUnifiedAccount(activeProfile.id); + + if (bestAccount) { + if (bestAccount.type === 'api') { + const { setActiveAPIProfile } = await import('../services/profile/profile-manager'); + await setActiveAPIProfile(bestAccount.id); + } else { + profileManager.setActiveProfile(bestAccount.id); + } + debugLog('[ClaudeIntegration] Proactive profile swap:', { + from: activeProfile.name, + to: bestAccount.name, + type: bestAccount.type, + }); + } + } catch (err) { + debugError('[ClaudeIntegration] Proactive swap check failed (continuing with current profile):', err); + } +} + +/** + * Handle rate limit detection and profile switching. + * Uses unified account selection (OAuth + API) so terminals can swap to + * API profiles like GLM when all OAuth profiles are rate-limited. */ export function handleRateLimit( terminal: TerminalProcess, @@ -432,29 +466,58 @@ export function handleRateLimit( } const autoSwitchSettings = profileManager.getAutoSwitchSettings(); - const bestProfile = profileManager.getBestAvailableProfile(currentProfileId); - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_RATE_LIMIT, { - terminalId: terminal.id, - resetTime, - detectedAt: new Date().toISOString(), - profileId: currentProfileId, - suggestedProfileId: bestProfile?.id, - suggestedProfileName: bestProfile?.name, - autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit - } as RateLimitEvent); - } + // Use unified account selection (OAuth + API) instead of OAuth-only + profileManager.getBestAvailableUnifiedAccount(currentProfileId).then(bestAccount => { + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_RATE_LIMIT, { + terminalId: terminal.id, + resetTime, + detectedAt: new Date().toISOString(), + profileId: currentProfileId, + suggestedProfileId: bestAccount?.id, + suggestedProfileName: bestAccount?.name, + suggestedAccountType: bestAccount?.type, + autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit + } as RateLimitEvent); + } - if (autoSwitchSettings.enabled && autoSwitchSettings.autoSwitchOnRateLimit && bestProfile) { - console.warn('[ClaudeIntegration] Auto-switching to profile:', bestProfile.name); - switchProfileCallback(terminal.id, bestProfile.id).then(_result => { - console.warn('[ClaudeIntegration] Auto-switch completed'); - }).catch(err => { - console.error('[ClaudeIntegration] Auto-switch failed:', err); - }); - } + if (autoSwitchSettings.enabled && autoSwitchSettings.autoSwitchOnRateLimit && bestAccount) { + console.warn('[ClaudeIntegration] Auto-switching to account:', bestAccount.name, '(type:', bestAccount.type, ')'); + + if (bestAccount.type === 'api') { + // API profile: persist globally via setActiveAPIProfile + import('../services/profile/profile-manager').then(({ setActiveAPIProfile }) => { + return setActiveAPIProfile(bestAccount.id); + }).then(() => { + console.warn('[ClaudeIntegration] API profile auto-switch completed:', bestAccount.name); + }).catch(err => { + console.error('[ClaudeIntegration] API profile auto-switch failed:', err); + }); + } else { + // OAuth profile: use existing callback + switchProfileCallback(terminal.id, bestAccount.id).then(() => { + console.warn('[ClaudeIntegration] OAuth profile auto-switch completed'); + }).catch(err => { + console.error('[ClaudeIntegration] OAuth profile auto-switch failed:', err); + }); + } + } + }).catch(err => { + // Fallback: send IPC event without suggested profile + console.error('[ClaudeIntegration] Unified account selection failed:', err); + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_RATE_LIMIT, { + terminalId: terminal.id, + resetTime, + detectedAt: new Date().toISOString(), + profileId: currentProfileId, + autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit + } as RateLimitEvent); + } + }); } /** @@ -1088,6 +1151,10 @@ export function invokeClaude( SessionHandler.releaseSessionId(terminal.id); terminal.claudeSessionId = undefined; + // Fire-and-forget proactive swap: if a swap is needed, it persists globally + // so the profile check later in this function will pick it up on next invocation. + ensureBestProfileActive().catch(() => {/* handled internally */}); + const startTime = Date.now(); const projectPath = cwd || terminal.projectPath || terminal.cwd; @@ -1280,6 +1347,10 @@ export async function invokeClaudeAsync( SessionHandler.releaseSessionId(terminal.id); terminal.claudeSessionId = undefined; + // Proactive swap: check if current profile is rate-limited/at-capacity + // and switch to a better profile (OAuth or API) before building the command. + await ensureBestProfileActive(); + const projectPath = cwd || terminal.projectPath || terminal.cwd; // Ensure profile manager is initialized (async, yields to event loop) diff --git a/apps/frontend/src/main/terminal/types.ts b/apps/frontend/src/main/terminal/types.ts index d68ca2c12d..6a056df1ae 100644 --- a/apps/frontend/src/main/terminal/types.ts +++ b/apps/frontend/src/main/terminal/types.ts @@ -40,6 +40,7 @@ export interface RateLimitEvent { profileId: string; suggestedProfileId?: string; suggestedProfileName?: string; + suggestedAccountType?: 'oauth' | 'api'; autoSwitchEnabled: boolean; } From 487222675ed050a7b44f1ed7fd68df27e806c9f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sondre=20Engebr=C3=A5ten?= Date: Sun, 15 Feb 2026 12:06:44 +0100 Subject: [PATCH 2/5] test: add verification tests for unified account selection in profile-scorer Adds 8 tests for getBestAvailableUnifiedAccount() covering: - API profile selection when all OAuth profiles are rate-limited - API profile selection when OAuth is at 100% capacity - Priority order respect for manual user swaps - Exclusion of specified accounts - Null return when no profiles exist Co-Authored-By: Claude Opus 4.6 --- .../unified-account-selection.test.ts | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 apps/frontend/src/main/claude-profile/__tests__/unified-account-selection.test.ts diff --git a/apps/frontend/src/main/claude-profile/__tests__/unified-account-selection.test.ts b/apps/frontend/src/main/claude-profile/__tests__/unified-account-selection.test.ts new file mode 100644 index 0000000000..8a85692c5c --- /dev/null +++ b/apps/frontend/src/main/claude-profile/__tests__/unified-account-selection.test.ts @@ -0,0 +1,201 @@ +/** + * Tests for getBestAvailableUnifiedAccount (profile-scorer.ts) + * + * Verifies the core scenario: when all OAuth profiles are rate-limited, + * API profiles (GLM, etc.) are correctly selected as alternatives. + * This is the foundation of the terminal unified swap fix. + */ +import { describe, it, expect } from 'vitest'; +import { getBestAvailableUnifiedAccount } from '../profile-scorer'; +import type { ClaudeProfile, ClaudeAutoSwitchSettings, APIProfile } from '../../../shared/types'; + +function createOAuthProfile(overrides: Partial = {}): ClaudeProfile { + return { + id: 'profile-1', + name: 'Account 1', + isDefault: false, + configDir: '/tmp/config', + usage: { weeklyUsagePercent: 50, sessionUsagePercent: 50 }, + rateLimitEvents: [], + ...overrides, + } as ClaudeProfile; +} + +function createAPIProfile(overrides: Partial = {}): APIProfile { + return { + id: 'glm-1', + name: 'GLM API', + baseUrl: 'https://api.z.ai/api/anthropic', + apiKey: 'sk-glm-key', + ...overrides, + } as APIProfile; +} + +const defaultSettings = { + enabled: true, + autoSwitchOnRateLimit: true, + proactiveSwapEnabled: true, + usageCheckInterval: 30000, + sessionThreshold: 95, + weeklyThreshold: 99, + autoSwitchOnAuthFailure: false, +} as ClaudeAutoSwitchSettings; + +describe('getBestAvailableUnifiedAccount', () => { + it('returns API profile when all OAuth profiles are rate-limited', () => { + const rateLimitedOAuth = createOAuthProfile({ + id: 'oauth-1', + name: 'Rate Limited Account', + rateLimitEvents: [ + { + type: 'session', + hitAt: new Date(), + resetAt: new Date(Date.now() + 3600000), + resetTimeString: 'Feb 19 at 11am', + } + ], + }); + + const glmProfile = createAPIProfile({ + id: 'glm-1', + name: 'GLM API', + }); + + const result = getBestAvailableUnifiedAccount( + [rateLimitedOAuth], + [glmProfile], + defaultSettings, + { excludeAccountId: 'oauth-oauth-1' } + ); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('api'); + expect(result!.name).toBe('GLM API'); + }); + + it('returns available OAuth profile over API when OAuth is not rate-limited', () => { + const healthyOAuth = createOAuthProfile({ + id: 'oauth-1', + name: 'Healthy Account', + }); + + const glmProfile = createAPIProfile(); + + const result = getBestAvailableUnifiedAccount( + [healthyOAuth], + [glmProfile], + defaultSettings, + { excludeAccountId: 'some-other-id' } + ); + + expect(result).not.toBeNull(); + // Both are available; result depends on priority order + expect(result!.isAvailable).toBe(true); + }); + + it('returns API profile when OAuth is at capacity (100% weekly usage)', () => { + const atCapacityOAuth = createOAuthProfile({ + id: 'oauth-1', + name: 'At Capacity Account', + usage: { + weeklyUsagePercent: 100, + sessionUsagePercent: 100, + sessionResetTime: '11:59pm', + weeklyResetTime: 'Feb 20', + lastUpdated: new Date(), + }, + }); + + const glmProfile = createAPIProfile(); + + const result = getBestAvailableUnifiedAccount( + [atCapacityOAuth], + [glmProfile], + defaultSettings, + { excludeAccountId: 'oauth-oauth-1' } + ); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('api'); + expect(result!.isAvailable).toBe(true); + }); + + it('returns null when no profiles exist', () => { + const result = getBestAvailableUnifiedAccount( + [], + [], + defaultSettings, + ); + + expect(result).toBeNull(); + }); + + it('returns API profile even when it is the only option', () => { + const glmProfile = createAPIProfile({ + id: 'glm-1', + name: 'GLM API', + apiKey: 'sk-valid-key', + }); + + const result = getBestAvailableUnifiedAccount( + [], + [glmProfile], + defaultSettings, + ); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('api'); + expect(result!.hasUnlimitedUsage).toBe(true); + }); + + it('respects priority order when multiple accounts are available', () => { + const oauth = createOAuthProfile({ id: 'oauth-1', name: 'OAuth' }); + const glm = createAPIProfile({ id: 'glm-1', name: 'GLM' }); + + // GLM first in priority + const result = getBestAvailableUnifiedAccount( + [oauth], + [glm], + defaultSettings, + { priorityOrder: ['api-glm-1', 'oauth-oauth-1'] } + ); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('GLM'); + expect(result!.type).toBe('api'); + }); + + it('excludes the specified account from selection', () => { + const glm = createAPIProfile({ id: 'glm-1', name: 'GLM' }); + + const result = getBestAvailableUnifiedAccount( + [], + [glm], + defaultSettings, + { excludeAccountId: 'api-glm-1' } + ); + + expect(result).toBeNull(); + }); + + it('allows manual selection of API profile via priority order even when OAuth is healthy', () => { + const healthyOAuth = createOAuthProfile({ + id: 'oauth-1', + name: 'Healthy OAuth', + }); + + const glm = createAPIProfile({ id: 'glm-1', name: 'GLM API' }); + + // User puts GLM first in priority → should get GLM + const result = getBestAvailableUnifiedAccount( + [healthyOAuth], + [glm], + defaultSettings, + { priorityOrder: ['api-glm-1', 'oauth-oauth-1'] } + ); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('api'); + expect(result!.name).toBe('GLM API'); + }); +}); From 13c454c792dcd05c93c579ff0386b4b7eea0c4cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sondre=20Engebr=C3=A5ten?= Date: Sun, 15 Feb 2026 12:13:03 +0100 Subject: [PATCH 3/5] fix: skip proactive swap when user manually selects a profile When a profileId is explicitly provided (e.g. via Rate Limit Modal or manual switch), skip ensureBestProfileActive() to avoid silently overriding the user's choice with an auto-selected "better" profile. Adds test verifying getBestAvailableUnifiedAccount is not called when profileId is explicitly provided to invokeClaudeAsync. Co-Authored-By: Claude Opus 4.6 --- .../claude-integration-handler.test.ts | 53 +++++++++++++++++++ .../terminal/claude-integration-handler.ts | 16 +++--- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts index dba3e0b98b..871f1e479e 100644 --- a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts +++ b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts @@ -1295,4 +1295,57 @@ describe('claude-integration-handler - Helper Functions', () => { expect(mockSwitchProfileCallback).not.toHaveBeenCalled(); }); }); + + // =========================================================================== + // Proactive swap — respects manual profile selections + // =========================================================================== + describe('proactive swap respects manual profile selection', () => { + it('does not run proactive swap when profileId is explicitly provided (manual switch)', async () => { + // Set up a profile manager where getBestAvailableUnifiedAccount would swap + // to a different profile if called — proving it was NOT called. + const mockBestUnified = vi.fn().mockResolvedValue({ + id: 'api-glm-1', + name: 'GLM API', + type: 'api', + isAvailable: true, + }); + const profileManager = { + getActiveProfile: vi.fn(() => ({ + id: 'oauth-1', name: 'Account 1', isDefault: true, + })), + getProfile: vi.fn((id: string) => ({ + id, name: 'Manually Selected', isDefault: false, + configDir: '/tmp/manual-config', + })), + getAutoSwitchSettings: vi.fn(() => ({ + enabled: true, autoSwitchOnRateLimit: true, + })), + getBestAvailableUnifiedAccount: mockBestUnified, + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + setActiveProfile: vi.fn(), + }; + + mockGetClaudeCliInvocationAsync.mockResolvedValue({ + command: '/opt/claude/bin/claude', + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + mockInitializeClaudeProfileManager.mockResolvedValue(profileManager); + mockGetClaudeProfileManager.mockReturnValue(profileManager); + + const terminal = createMockTerminal(); + const { invokeClaudeAsync } = await import('../claude-integration-handler'); + + // Provide an explicit profileId — simulating a manual switch + await invokeClaudeAsync(terminal, '/tmp/project', 'manual-profile-1', () => null, vi.fn()); + + // getBestAvailableUnifiedAccount should NOT have been called + // because proactive swap is skipped when profileId is explicit + expect(mockBestUnified).not.toHaveBeenCalled(); + + // The explicit profile should be used, not the auto-selected one + expect(profileManager.getProfile).toHaveBeenCalledWith('manual-profile-1'); + expect(profileManager.markProfileUsed).toHaveBeenCalledWith('manual-profile-1'); + }); + }); }); diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index 51c083cbf0..b7c9d254b4 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -1151,9 +1151,11 @@ export function invokeClaude( SessionHandler.releaseSessionId(terminal.id); terminal.claudeSessionId = undefined; - // Fire-and-forget proactive swap: if a swap is needed, it persists globally - // so the profile check later in this function will pick it up on next invocation. - ensureBestProfileActive().catch(() => {/* handled internally */}); + // Proactive swap only when no explicit profile was requested (i.e. not a manual switch). + // When profileId is set, the user explicitly chose a profile — respect that choice. + if (!profileId) { + ensureBestProfileActive().catch(() => {/* handled internally */}); + } const startTime = Date.now(); const projectPath = cwd || terminal.projectPath || terminal.cwd; @@ -1347,9 +1349,11 @@ export async function invokeClaudeAsync( SessionHandler.releaseSessionId(terminal.id); terminal.claudeSessionId = undefined; - // Proactive swap: check if current profile is rate-limited/at-capacity - // and switch to a better profile (OAuth or API) before building the command. - await ensureBestProfileActive(); + // Proactive swap only when no explicit profile was requested (i.e. not a manual switch). + // When profileId is set, the user explicitly chose a profile — respect that choice. + if (!profileId) { + await ensureBestProfileActive(); + } const projectPath = cwd || terminal.projectPath || terminal.cwd; From 6f7388aa9ef349bb4c92a9ec47011e9ce24f28e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sondre=20Engebr=C3=A5ten?= Date: Sun, 15 Feb 2026 13:50:23 +0100 Subject: [PATCH 4/5] refactor: deduplicate IPC/dispatch helpers, fix exclude ID format, strengthen tests - Extract sendRateLimitIpc() to eliminate duplicated IPC payload construction - Extract switchToUnifiedAccount() to deduplicate API-vs-OAuth dispatch logic - Fix ID format mismatch: use toOAuthUnifiedId() so exclude filter works correctly - Add oauthToken to test factory so OAuth profiles are properly authenticated - Remove inconsistent vi.doMock in first handleRateLimit test Co-Authored-By: Claude Opus 4.6 --- .../unified-account-selection.test.ts | 1 + .../claude-integration-handler.test.ts | 5 -- .../terminal/claude-integration-handler.ts | 89 ++++++++++++------- 3 files changed, 58 insertions(+), 37 deletions(-) diff --git a/apps/frontend/src/main/claude-profile/__tests__/unified-account-selection.test.ts b/apps/frontend/src/main/claude-profile/__tests__/unified-account-selection.test.ts index 8a85692c5c..ab2b201c99 100644 --- a/apps/frontend/src/main/claude-profile/__tests__/unified-account-selection.test.ts +++ b/apps/frontend/src/main/claude-profile/__tests__/unified-account-selection.test.ts @@ -15,6 +15,7 @@ function createOAuthProfile(overrides: Partial = {}): ClaudeProfi name: 'Account 1', isDefault: false, configDir: '/tmp/config', + oauthToken: 'fake-token-for-testing', usage: { weeklyUsagePercent: 50, sessionUsagePercent: 50 }, rateLimitEvents: [], ...overrides, diff --git a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts index 871f1e479e..187488f9d1 100644 --- a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts +++ b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts @@ -1164,11 +1164,6 @@ describe('claude-integration-handler - Helper Functions', () => { }); it('sends IPC event with suggestedAccountType for API profiles', async () => { - // Override extractRateLimitReset for this test - vi.doMock('../output-parser', () => ({ - extractRateLimitReset: () => 'Feb 19 at 11am', - })); - const { handleRateLimit } = await import('../claude-integration-handler'); const { terminal, lastNotified, getWindow } = createRateLimitTestContext(); diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index b7c9d254b4..609ff83203 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -17,6 +17,7 @@ import * as OutputParser from './output-parser'; import * as SessionHandler from './session-handler'; import * as PtyManager from './pty-manager'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; +import { toOAuthUnifiedId } from '../../shared/utils/unified-account'; import { escapeShellArg, escapeForWindowsDoubleQuote, buildCdCommand } from '../../shared/utils/shell-escape'; import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils'; import { isWindows } from '../platform'; @@ -398,6 +399,53 @@ export function finalizeClaudeInvoke( } } +/** + * Build and send a RateLimitEvent IPC payload to the renderer. + * + * Centralises the IPC construction so it cannot drift between the success + * and error paths in handleRateLimit(). + */ +function sendRateLimitIpc( + getWindow: WindowGetter, + terminalId: string, + resetTime: string, + profileId: string, + autoSwitchEnabled: boolean, + bestAccount?: { id: string; name: string; type: string } | null +): void { + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_RATE_LIMIT, { + terminalId, + resetTime, + detectedAt: new Date().toISOString(), + profileId, + suggestedProfileId: bestAccount?.id, + suggestedProfileName: bestAccount?.name, + suggestedAccountType: bestAccount?.type, + autoSwitchEnabled, + } as RateLimitEvent); + } +} + +/** + * Switch globally to a unified account (API or OAuth). + * + * API profiles are persisted via setActiveAPIProfile (lazy-imported). + * OAuth profiles are persisted via profileManager.setActiveProfile. + */ +async function switchToUnifiedAccount( + profileManager: ReturnType, + account: { id: string; type: string } +): Promise { + if (account.type === 'api') { + const { setActiveAPIProfile } = await import('../services/profile/profile-manager'); + await setActiveAPIProfile(account.id); + } else { + profileManager.setActiveProfile(account.id); + } +} + /** * Proactive profile swap: check if any better profile (OAuth or API) is available * before invoking Claude. If the active profile is rate-limited or at capacity, @@ -410,15 +458,12 @@ async function ensureBestProfileActive(): Promise { try { const profileManager = getClaudeProfileManager(); const activeProfile = profileManager.getActiveProfile(); - const bestAccount = await profileManager.getBestAvailableUnifiedAccount(activeProfile.id); + const bestAccount = await profileManager.getBestAvailableUnifiedAccount( + toOAuthUnifiedId(activeProfile.id) + ); if (bestAccount) { - if (bestAccount.type === 'api') { - const { setActiveAPIProfile } = await import('../services/profile/profile-manager'); - await setActiveAPIProfile(bestAccount.id); - } else { - profileManager.setActiveProfile(bestAccount.id); - } + await switchToUnifiedAccount(profileManager, bestAccount); debugLog('[ClaudeIntegration] Proactive profile swap:', { from: activeProfile.name, to: bestAccount.name, @@ -468,20 +513,9 @@ export function handleRateLimit( const autoSwitchSettings = profileManager.getAutoSwitchSettings(); // Use unified account selection (OAuth + API) instead of OAuth-only - profileManager.getBestAvailableUnifiedAccount(currentProfileId).then(bestAccount => { - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_RATE_LIMIT, { - terminalId: terminal.id, - resetTime, - detectedAt: new Date().toISOString(), - profileId: currentProfileId, - suggestedProfileId: bestAccount?.id, - suggestedProfileName: bestAccount?.name, - suggestedAccountType: bestAccount?.type, - autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit - } as RateLimitEvent); - } + // Pass a properly-prefixed unified ID so the exclude filter works correctly + profileManager.getBestAvailableUnifiedAccount(toOAuthUnifiedId(currentProfileId)).then(bestAccount => { + sendRateLimitIpc(getWindow, terminal.id, resetTime, currentProfileId, autoSwitchSettings.autoSwitchOnRateLimit, bestAccount); if (autoSwitchSettings.enabled && autoSwitchSettings.autoSwitchOnRateLimit && bestAccount) { console.warn('[ClaudeIntegration] Auto-switching to account:', bestAccount.name, '(type:', bestAccount.type, ')'); @@ -496,7 +530,7 @@ export function handleRateLimit( console.error('[ClaudeIntegration] API profile auto-switch failed:', err); }); } else { - // OAuth profile: use existing callback + // OAuth profile: use existing callback (restarts terminal, not just setActiveProfile) switchProfileCallback(terminal.id, bestAccount.id).then(() => { console.warn('[ClaudeIntegration] OAuth profile auto-switch completed'); }).catch(err => { @@ -507,16 +541,7 @@ export function handleRateLimit( }).catch(err => { // Fallback: send IPC event without suggested profile console.error('[ClaudeIntegration] Unified account selection failed:', err); - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_RATE_LIMIT, { - terminalId: terminal.id, - resetTime, - detectedAt: new Date().toISOString(), - profileId: currentProfileId, - autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit - } as RateLimitEvent); - } + sendRateLimitIpc(getWindow, terminal.id, resetTime, currentProfileId, autoSwitchSettings.autoSwitchOnRateLimit); }); } From a067a65e8c2c9dc7168dc64f45b8c6f396c83ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sondre=20Engebr=C3=A5ten?= Date: Mon, 16 Feb 2026 08:48:38 +0100 Subject: [PATCH 5/5] fix(codeql): suppress false positive alerts for path hashing Add CodeQL suppression comments for js/weak-crypto-hashing rule on two lines that hash filesystem paths for credential storage identifiers (not passwords). These are legitimate uses of SHA256 for creating deterministic identifiers from config directory paths. Co-Authored-By: Claude Sonnet 4.5 --- apps/frontend/src/main/claude-profile/credential-utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/frontend/src/main/claude-profile/credential-utils.ts b/apps/frontend/src/main/claude-profile/credential-utils.ts index 14dcf35106..3299f1172e 100644 --- a/apps/frontend/src/main/claude-profile/credential-utils.ts +++ b/apps/frontend/src/main/claude-profile/credential-utils.ts @@ -154,6 +154,7 @@ function isValidCredentialsPath(credentialsPath: string): boolean { * @returns The 8-character hex hash suffix */ export function calculateConfigDirHash(configDir: string): string { + // CodeQL[js/weak-crypto-hashing] suppress False positive: hashing filesystem path for identifier, not password return createHash('sha256').update(configDir).digest('hex').slice(0, 8); } @@ -731,6 +732,7 @@ function getSecretServiceAttribute(configDir?: string): string { return 'claude-code'; } // For custom config dirs, create a hashed attribute to avoid conflicts + // CodeQL[js/weak-crypto-hashing] suppress False positive: hashing filesystem path for identifier, not password const hash = createHash('sha256').update(configDir).digest('hex').slice(0, 8); return `claude-code-${hash}`; }