Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/commands/config-switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1212,11 +1212,11 @@ async function handleCodexConfigs(configs: ApiConfigDefinition[]): Promise<void>
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 {
Expand Down
93 changes: 92 additions & 1 deletion src/utils/code-tools/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2057,6 +2057,96 @@ export async function listCodexProviders(): Promise<CodexProvider[]> {
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<string, any> }
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
Expand Down Expand Up @@ -2215,6 +2305,7 @@ export async function switchToProvider(providerId: string): Promise<boolean> {
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
Expand Down
5 changes: 3 additions & 2 deletions tests/unit/commands/config-switch-claude-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
14 changes: 5 additions & 9 deletions tests/unit/commands/config-switch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
getCurrentCodexProvider,
listCodexProviders,
readCodexConfig,
switchCodexProvider,
switchToOfficialLogin,
switchToProvider,
} from '../../../src/utils/code-tools/codex'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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')
})
})

Expand Down Expand Up @@ -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')
})
Expand Down
26 changes: 14 additions & 12 deletions tests/unit/commands/init-multi-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -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({
Expand All @@ -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 () => {
Expand All @@ -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({
Expand All @@ -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 () => {
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/utils/code-tools/codex.edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}))
Expand Down
Loading
Loading