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..ab2b201c99 --- /dev/null +++ b/apps/frontend/src/main/claude-profile/__tests__/unified-account-selection.test.ts @@ -0,0 +1,202 @@ +/** + * 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', + oauthToken: 'fake-token-for-testing', + 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'); + }); +}); 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}`; } 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..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 @@ -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,224 @@ 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 () => { + 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(); + }); + }); + + // =========================================================================== + // 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 ef4c92b903..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'; @@ -399,7 +400,85 @@ export function finalizeClaudeInvoke( } /** - * Handle rate limit detection and profile switching + * 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, + * 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( + toOAuthUnifiedId(activeProfile.id) + ); + + if (bestAccount) { + await switchToUnifiedAccount(profileManager, bestAccount); + 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 +511,38 @@ 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); - } - - 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); - }); - } + // Use unified account selection (OAuth + API) instead of OAuth-only + // 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, ')'); + + 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 (restarts terminal, not just setActiveProfile) + 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); + sendRateLimitIpc(getWindow, terminal.id, resetTime, currentProfileId, autoSwitchSettings.autoSwitchOnRateLimit); + }); } /** @@ -1088,6 +1176,12 @@ export function invokeClaude( SessionHandler.releaseSessionId(terminal.id); terminal.claudeSessionId = undefined; + // 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; @@ -1280,6 +1374,12 @@ export async function invokeClaudeAsync( SessionHandler.releaseSessionId(terminal.id); terminal.claudeSessionId = undefined; + // 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; // 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; }