diff --git a/src/commands/config-switch.ts b/src/commands/config-switch.ts index cc74ed2a..0820f02e 100644 --- a/src/commands/config-switch.ts +++ b/src/commands/config-switch.ts @@ -5,7 +5,7 @@ import inquirer from 'inquirer' import { DEFAULT_CODE_TOOL_TYPE, isCodeToolType, resolveCodeToolType } from '../constants' import { ensureI18nInitialized, i18n } from '../i18n' import { ClaudeCodeConfigManager } from '../utils/claude-code-config-manager' -import { listCodexProviders, readCodexConfig, switchToOfficialLogin as switchCodexOfficialLogin, switchCodexProvider, switchToProvider } from '../utils/code-tools/codex' +import { listCodexProviders, readCodexConfig, switchToOfficialLogin as switchCodexOfficialLogin, switchToProvider } from '../utils/code-tools/codex' import { handleGeneralError } from '../utils/error-handler' import { addNumbersToChoices } from '../utils/prompt-helpers' import { readZcfConfig } from '../utils/zcf-config' @@ -158,8 +158,7 @@ async function handleDirectSwitch(codeType: CodeToolType, target: string): Promi await handleClaudeCodeDirectSwitch(target) } else if (resolvedCodeType === 'codex') { - await switchCodexProvider(target) - // switchCodexProvider already handles success/failure messages + await switchToProvider(target) } } diff --git a/src/commands/init.ts b/src/commands/init.ts index 0f2d5486..52ee2299 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1212,11 +1212,11 @@ async function handleCodexConfigs(configs: ApiConfigDefinition[]): Promise const defaultConfig = configs.find(c => c.default) if (defaultConfig) { // Import and call Codex provider switching function - const { switchCodexProvider } = await import('../utils/code-tools/codex') + const { switchToProvider } = await import('../utils/code-tools/codex') const displayName = defaultConfig.name || defaultConfig.provider || 'custom' const providerId = displayName.toLowerCase().replace(/[^a-z0-9]/g, '-') if (addedProviderIds.includes(providerId)) { - await switchCodexProvider(providerId) + await switchToProvider(providerId) console.log(ansis.green(`✔ ${i18n.t('multi-config:defaultProviderSet', { name: displayName })}`)) } else { diff --git a/src/utils/code-tools/codex.ts b/src/utils/code-tools/codex.ts index ee0fa111..f092c8ce 100644 --- a/src/utils/code-tools/codex.ts +++ b/src/utils/code-tools/codex.ts @@ -13,7 +13,7 @@ import { x } from 'tinyexec' import { AI_OUTPUT_LANGUAGES, CODEX_AGENTS_FILE, CODEX_AUTH_FILE, CODEX_CONFIG_FILE, CODEX_DIR, CODEX_PROMPTS_DIR, SUPPORTED_LANGS, ZCF_CONFIG_FILE } from '../../constants' import { ensureI18nInitialized, format, i18n } from '../../i18n' import { applyAiLanguageDirective } from '../config' -import { copyDir, copyFile, ensureDir, exists, readFile, writeFile } from '../fs-operations' +import { copyDir, copyFile, ensureDir, exists, isDirectory, isFile, readDir, readFile, writeFile } from '../fs-operations' import { readJsonConfig, writeJsonConfig } from '../json-config' import { normalizeTomlPath, wrapCommandWithSudo } from '../platform' // Removed MCP selection and platform command imports from this module @@ -2057,6 +2057,96 @@ export async function listCodexProviders(): Promise { return config?.providers || [] } +const CODEX_SESSION_DIRECTORIES = ['sessions', 'archived_sessions'] as const + +function collectCodexSessionFiles(rootDir: string, files: string[]): void { + if (!exists(rootDir) || !isDirectory(rootDir)) + return + + for (const entry of readDir(rootDir)) { + const entryPath = join(rootDir, entry) + + if (isDirectory(entryPath)) { + collectCodexSessionFiles(entryPath, files) + continue + } + + if (isFile(entryPath) && entryPath.endsWith('.jsonl')) { + files.push(entryPath) + } + } +} + +function rewriteSessionMetaProvider(content: string, providerId: string): string | null { + if (!content.trim()) + return null + + const newline = content.includes('\r\n') ? '\r\n' : '\n' + const rawLines = content.split(/\r?\n/) + const hasTrailingNewline = rawLines.at(-1) === '' + const lines = hasTrailingNewline ? rawLines.slice(0, -1) : rawLines + let changed = false + + const updatedLines = lines.map((line) => { + if (!line.trim()) + return line + + try { + const record = JSON.parse(line) as { type?: string, payload?: Record } + if (record.type !== 'session_meta' || !record.payload || typeof record.payload !== 'object') + return line + + if (record.payload.model_provider === providerId) + return line + + record.payload = { + ...record.payload, + model_provider: providerId, + } + changed = true + return JSON.stringify(record) + } + catch { + return line + } + }) + + if (!changed) + return null + + let updatedContent = updatedLines.join(newline) + if (hasTrailingNewline) + updatedContent += newline + + return updatedContent +} + +function syncCodexSessionProviders(providerId: string): void { + for (const directoryName of CODEX_SESSION_DIRECTORIES) { + const rootDir = join(CODEX_DIR, directoryName) + const sessionFiles: string[] = [] + + try { + collectCodexSessionFiles(rootDir, sessionFiles) + } + catch { + continue + } + + for (const sessionFile of sessionFiles) { + try { + const updatedContent = rewriteSessionMetaProvider(readFile(sessionFile), providerId) + if (updatedContent !== null) { + writeFile(sessionFile, updatedContent) + } + } + catch { + // Ignore malformed or transient session files and keep the provider switch successful. + } + } + } +} + /** * Switch to a different Codex provider * @param providerId - ID of the provider to switch to @@ -2215,6 +2305,7 @@ export async function switchToProvider(providerId: string): Promise { const envValue = auth[provider.tempEnvKey] || null auth.OPENAI_API_KEY = envValue writeJsonConfig(CODEX_AUTH_FILE, auth, { pretty: true }) + syncCodexSessionProviders(providerId) console.log(ansis.green(i18n.t('codex:providerSwitchSuccess', { provider: providerId }))) return true diff --git a/tests/unit/commands/config-switch-claude-code.test.ts b/tests/unit/commands/config-switch-claude-code.test.ts index 31ce1976..fba16fbc 100644 --- a/tests/unit/commands/config-switch-claude-code.test.ts +++ b/tests/unit/commands/config-switch-claude-code.test.ts @@ -466,10 +466,11 @@ describe('config-switch command - Codex Support', () => { expect(mockConsoleLog).toHaveBeenCalledWith('codex:noProvidersAvailable') }) - it('should switch Codex provider directly when target specified', async () => { + it('should use full provider switch flow when switching Codex provider directly', async () => { await configSwitchCommand({ target: 'provider-2', codeType: 'codex' }) - expect(mockSwitchCodexProvider).toHaveBeenCalledWith('provider-2') + expect(mockSwitchToProvider).toHaveBeenCalledWith('provider-2') + expect(mockSwitchCodexProvider).not.toHaveBeenCalled() }) it('should switch to official Codex login via interactive flow', async () => { diff --git a/tests/unit/commands/config-switch.test.ts b/tests/unit/commands/config-switch.test.ts index 6ed6a493..1418728f 100644 --- a/tests/unit/commands/config-switch.test.ts +++ b/tests/unit/commands/config-switch.test.ts @@ -7,7 +7,6 @@ import { getCurrentCodexProvider, listCodexProviders, readCodexConfig, - switchCodexProvider, switchToOfficialLogin, switchToProvider, } from '../../../src/utils/code-tools/codex' @@ -75,7 +74,6 @@ vi.mock('../../../src/utils/zcf-config', () => ({ })) const mockInquirer = vi.mocked(inquirer) -const mockSwitchCodexProvider = vi.mocked(switchCodexProvider) const mockListCodexProviders = vi.mocked(listCodexProviders) const mockGetCurrentCodexProvider = vi.mocked(getCurrentCodexProvider) const mockReadCodexConfig = vi.mocked(readCodexConfig) @@ -166,21 +164,19 @@ describe('config-switch command', () => { describe('with provider argument', () => { it('should switch to specified provider directly', async () => { - mockSwitchCodexProvider.mockResolvedValue(true) + mockSwitchToProvider.mockResolvedValue(true) await configSwitchCommand({ target: 'claude-api' }) - expect(mockSwitchCodexProvider).toHaveBeenCalledWith('claude-api') - // switchCodexProvider handles its own success/failure messages + expect(mockSwitchToProvider).toHaveBeenCalledWith('claude-api') }) it('should handle switching to non-existent provider', async () => { - mockSwitchCodexProvider.mockResolvedValue(false) + mockSwitchToProvider.mockResolvedValue(false) await configSwitchCommand({ target: 'non-existent' }) - expect(mockSwitchCodexProvider).toHaveBeenCalledWith('non-existent') - // switchCodexProvider handles its own success/failure messages + expect(mockSwitchToProvider).toHaveBeenCalledWith('non-existent') }) }) @@ -278,7 +274,7 @@ describe('config-switch command', () => { it('should handle errors when switching providers', async () => { const error = new Error('Failed to write config') - mockSwitchCodexProvider.mockRejectedValue(error) + mockSwitchToProvider.mockRejectedValue(error) await expect(configSwitchCommand({ target: 'claude-api' })).rejects.toThrow('Failed to write config') }) diff --git a/tests/unit/commands/init-multi-config.test.ts b/tests/unit/commands/init-multi-config.test.ts index 766f6bd3..959861fd 100644 --- a/tests/unit/commands/init-multi-config.test.ts +++ b/tests/unit/commands/init-multi-config.test.ts @@ -43,6 +43,7 @@ vi.mock('../../../src/utils/code-tools/codex-provider-manager', () => ({ vi.mock('../../../src/utils/code-tools/codex', () => ({ switchCodexProvider: vi.fn(), + switchToProvider: vi.fn(), })) vi.mock('node:fs', () => ({ @@ -393,7 +394,7 @@ describe('init command - multi-configuration', () => { it('should set default provider for Codex', async () => { const { handleMultiConfigurations } = await import('../../../src/commands/init') const { addProviderToExisting } = await import('../../../src/utils/code-tools/codex-provider-manager') - const { switchCodexProvider } = await import('../../../src/utils/code-tools/codex') + const { switchToProvider } = await import('../../../src/utils/code-tools/codex') vi.mocked(addProviderToExisting).mockResolvedValue({ success: true, @@ -413,7 +414,7 @@ describe('init command - multi-configuration', () => { await handleMultiConfigurations(options, 'codex') // PR #251: provider ID is now lowercase with special chars replaced by dashes - expect(switchCodexProvider).toHaveBeenCalledWith('config1') + expect(switchToProvider).toHaveBeenCalledWith('config1') }) it('should throw error when provider addition fails', async () => { @@ -445,7 +446,7 @@ describe('init command - multi-configuration', () => { it('should handle config without name using provider as fallback', async () => { const { handleMultiConfigurations } = await import('../../../src/commands/init') const { addProviderToExisting } = await import('../../../src/utils/code-tools/codex-provider-manager') - const { switchCodexProvider } = await import('../../../src/utils/code-tools/codex') + const { switchToProvider } = await import('../../../src/utils/code-tools/codex') vi.mocked(addProviderToExisting).mockResolvedValue({ success: true, @@ -465,13 +466,13 @@ describe('init command - multi-configuration', () => { await handleMultiConfigurations(options, 'codex') // Should use provider as displayName and generate providerId from it - expect(switchCodexProvider).toHaveBeenCalledWith('302ai') + expect(switchToProvider).toHaveBeenCalledWith('302ai') }) it('should not set default provider when provider ID not in added list', async () => { const { handleMultiConfigurations } = await import('../../../src/commands/init') const { addProviderToExisting } = await import('../../../src/utils/code-tools/codex-provider-manager') - const { switchCodexProvider } = await import('../../../src/utils/code-tools/codex') + const { switchToProvider } = await import('../../../src/utils/code-tools/codex') // First call succeeds but with different ID vi.mocked(addProviderToExisting).mockResolvedValueOnce({ @@ -502,13 +503,13 @@ describe('init command - multi-configuration', () => { }) await expect(handleMultiConfigurations(options, 'codex')).rejects.toThrow() - expect(switchCodexProvider).not.toHaveBeenCalled() + expect(switchToProvider).not.toHaveBeenCalled() }) it('should log error when default provider ID not in added list for Codex', async () => { const { handleMultiConfigurations } = await import('../../../src/commands/init') const { addProviderToExisting } = await import('../../../src/utils/code-tools/codex-provider-manager') - const { switchCodexProvider } = await import('../../../src/utils/code-tools/codex') + const { switchToProvider } = await import('../../../src/utils/code-tools/codex') // Provider is added successfully vi.mocked(addProviderToExisting).mockResolvedValueOnce({ @@ -530,8 +531,8 @@ describe('init command - multi-configuration', () => { // because we only add Config1 await handleMultiConfigurations(options, 'codex') - // switchCodexProvider should not be called since no config is marked default - expect(switchCodexProvider).not.toHaveBeenCalled() + // switchToProvider should not be called since no config is marked default + expect(switchToProvider).not.toHaveBeenCalled() }) it('should display error when default provider ID mismatch in Codex configs', async () => { @@ -545,11 +546,12 @@ describe('init command - multi-configuration', () => { })) vi.mock('../../../src/utils/code-tools/codex', () => ({ switchCodexProvider: vi.fn(), + switchToProvider: vi.fn(), })) const { handleMultiConfigurations } = await import('../../../src/commands/init') const { addProviderToExisting } = await import('../../../src/utils/code-tools/codex-provider-manager') - const { switchCodexProvider } = await import('../../../src/utils/code-tools/codex') + const { switchToProvider } = await import('../../../src/utils/code-tools/codex') // First provider added successfully vi.mocked(addProviderToExisting).mockResolvedValue({ @@ -571,8 +573,8 @@ describe('init command - multi-configuration', () => { await handleMultiConfigurations(options, 'codex') // The provider ID will be 'my-config-' (special chars replaced with dashes) - // This should match and switchCodexProvider should be called - expect(switchCodexProvider).toHaveBeenCalledWith('my-config-') + // This should match and switchToProvider should be called + expect(switchToProvider).toHaveBeenCalledWith('my-config-') }) it('should handle exception thrown during provider addition', async () => { diff --git a/tests/unit/utils/code-tools/codex.edge.test.ts b/tests/unit/utils/code-tools/codex.edge.test.ts index c573fef2..8d333cb3 100644 --- a/tests/unit/utils/code-tools/codex.edge.test.ts +++ b/tests/unit/utils/code-tools/codex.edge.test.ts @@ -129,6 +129,9 @@ vi.mock('../../../../src/utils/fs-operations', () => ({ copyFile: vi.fn(), ensureDir: vi.fn(), exists: vi.fn(), + readDir: vi.fn(), + isDirectory: vi.fn(), + isFile: vi.fn(), readFile: vi.fn(), writeFile: vi.fn(), })) diff --git a/tests/unit/utils/code-tools/codex.test.ts b/tests/unit/utils/code-tools/codex.test.ts index e867270c..3529fa0b 100644 --- a/tests/unit/utils/code-tools/codex.test.ts +++ b/tests/unit/utils/code-tools/codex.test.ts @@ -83,6 +83,9 @@ vi.mock('../../../../src/utils/fs-operations', () => ({ copyDir: vi.fn(), copyFile: vi.fn(), exists: vi.fn(), + readDir: vi.fn(), + isDirectory: vi.fn(), + isFile: vi.fn(), readFile: vi.fn(), writeFile: vi.fn(), })) @@ -1981,6 +1984,100 @@ model_provider = "" { pretty: true }, ) }) + + it('should sync session metadata to the active provider when switching', async () => { + const fsOps = await import('../../../../src/utils/fs-operations') + const sessionRoot = '/home/test/.codex/sessions' + const archivedRoot = '/home/test/.codex/archived_sessions' + const sessionYearDir = `${sessionRoot}/2026` + const sessionMonthDir = `${sessionYearDir}/04` + const sessionDayDir = `${sessionMonthDir}/14` + const sessionFile = `${sessionDayDir}/rollout-1.jsonl` + + vi.mocked(fsOps.exists).mockImplementation((path: string) => [ + '/home/test/.codex/config.toml', + '/home/test/.codex', + sessionRoot, + archivedRoot, + sessionYearDir, + sessionMonthDir, + sessionDayDir, + sessionFile, + ].includes(path)) + + vi.mocked(fsOps.readDir).mockImplementation((path: string) => { + if (path === sessionRoot) + return ['2026'] + if (path === sessionYearDir) + return ['04'] + if (path === sessionMonthDir) + return ['14'] + if (path === sessionDayDir) + return ['rollout-1.jsonl'] + if (path === archivedRoot) + return [] + return [] + }) + + vi.mocked(fsOps.isDirectory).mockImplementation((path: string) => [ + sessionRoot, + archivedRoot, + sessionYearDir, + sessionMonthDir, + sessionDayDir, + ].includes(path)) + + vi.mocked(fsOps.isFile).mockImplementation((path: string) => path === sessionFile) + + vi.mocked(fsOps.readFile).mockImplementation((path: string) => { + if (path === sessionFile) { + return '{"timestamp":"2026-04-14T08:00:00.000Z","type":"session_meta","payload":{"id":"thread-1","model_provider":"old-provider"}}\n{"timestamp":"2026-04-14T08:00:01.000Z","type":"response_item","payload":{"type":"message"}}\n' + } + + return ` + model_provider = "old-provider" + [model_providers.test-provider] + name = "Test Provider" + base_url = "https://test.com" + wire_api = "responses" + temp_env_key = "TEST_KEY" + requires_openai_auth = true + ` + }) + + vi.mocked(fsOps.copyDir).mockImplementation(() => {}) + vi.mocked(fsOps.writeFile).mockImplementation(() => {}) + + const jsonConfig = await import('../../../../src/utils/json-config') + vi.mocked(jsonConfig.readJsonConfig).mockReturnValue({ + TEST_KEY: 'test-key', + }) + + const codexModule = await import('../../../../src/utils/code-tools/codex') + vi.spyOn(codexModule, 'readCodexConfig').mockReturnValue({ + model: null, + modelProvider: 'old-provider', + providers: [{ + id: 'test-provider', + name: 'Test Provider', + baseUrl: 'https://test.com', + wireApi: 'responses', + tempEnvKey: 'TEST_KEY', + requiresOpenaiAuth: true, + }], + mcpServices: [], + managed: true, + otherConfig: [], + }) + + const result = await codexModule.switchToProvider('test-provider') + + expect(result).toBe(true) + expect(fsOps.writeFile).toHaveBeenCalledWith( + sessionFile, + expect.stringContaining('"model_provider":"test-provider"'), + ) + }) }) })