fix: normalize /provider multi-model selection and semicolon parsing (#841)
* fix provider multi-model selection * fix provider manager multi-model save path
This commit is contained in:
@@ -152,6 +152,7 @@ function createDeferred<T>(): {
|
||||
|
||||
function mockProviderProfilesModule(options?: {
|
||||
addProviderProfile?: (...args: unknown[]) => unknown
|
||||
getActiveProviderProfile?: () => unknown
|
||||
getProviderProfiles?: () => unknown[]
|
||||
updateProviderProfile?: (...args: unknown[]) => unknown
|
||||
setActiveProviderProfile?: (...args: unknown[]) => unknown
|
||||
@@ -160,7 +161,7 @@ function mockProviderProfilesModule(options?: {
|
||||
addProviderProfile: options?.addProviderProfile ?? (() => null),
|
||||
applyActiveProviderProfileFromConfig: () => {},
|
||||
deleteProviderProfile: () => ({ removed: false, activeProfileId: null }),
|
||||
getActiveProviderProfile: () => null,
|
||||
getActiveProviderProfile: options?.getActiveProviderProfile ?? (() => null),
|
||||
getProviderPresetDefaults: (preset: string) =>
|
||||
preset === 'ollama'
|
||||
? {
|
||||
@@ -190,6 +191,7 @@ function mockProviderManagerDependencies(
|
||||
addProviderProfile?: (...args: unknown[]) => unknown
|
||||
applySavedProfileToCurrentSession?: (...args: unknown[]) => Promise<string | null>
|
||||
clearCodexCredentials?: () => { success: boolean; warning?: string }
|
||||
getActiveProviderProfile?: () => unknown
|
||||
getProviderProfiles?: () => unknown[]
|
||||
probeOllamaGenerationReadiness?: () => Promise<{
|
||||
state: 'ready' | 'unreachable' | 'no_models' | 'generation_failed'
|
||||
@@ -229,6 +231,7 @@ function mockProviderManagerDependencies(
|
||||
): void {
|
||||
mockProviderProfilesModule({
|
||||
addProviderProfile: options?.addProviderProfile,
|
||||
getActiveProviderProfile: options?.getActiveProviderProfile,
|
||||
getProviderProfiles: options?.getProviderProfiles,
|
||||
updateProviderProfile: options?.updateProviderProfile,
|
||||
setActiveProviderProfile: options?.setActiveProviderProfile,
|
||||
@@ -331,6 +334,10 @@ async function mountProviderManager(
|
||||
options?: {
|
||||
mode?: 'first-run' | 'manage'
|
||||
onDone?: (result?: unknown) => void
|
||||
onChangeAppState?: (args: {
|
||||
newState: unknown
|
||||
oldState: unknown
|
||||
}) => void
|
||||
},
|
||||
): Promise<{
|
||||
stdin: PassThrough
|
||||
@@ -345,7 +352,7 @@ async function mountProviderManager(
|
||||
})
|
||||
|
||||
root.render(
|
||||
<AppStateProvider>
|
||||
<AppStateProvider onChangeAppState={options?.onChangeAppState}>
|
||||
<KeybindingSetup>
|
||||
<ProviderManager
|
||||
mode={options?.mode ?? 'manage'}
|
||||
@@ -907,6 +914,205 @@ test('ProviderManager keeps Codex OAuth as next-startup only when activating the
|
||||
await mounted.dispose()
|
||||
})
|
||||
|
||||
test('ProviderManager activating a multi-model provider sets the session model to the primary model', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
|
||||
const multiModelProfile = {
|
||||
id: 'provider_multi_model',
|
||||
provider: 'openai',
|
||||
name: 'Multi Model Provider',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-5.4; gpt-5.4-mini',
|
||||
apiKey: 'sk-test',
|
||||
}
|
||||
|
||||
const setActiveProviderProfile = mock(() => multiModelProfile)
|
||||
const appStateChanges: Array<{ newState: any; oldState: any }> = []
|
||||
|
||||
mockProviderManagerDependencies(
|
||||
() => undefined,
|
||||
async () => undefined,
|
||||
{
|
||||
getProviderProfiles: () => [multiModelProfile],
|
||||
setActiveProviderProfile,
|
||||
},
|
||||
)
|
||||
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
||||
const mounted = await mountProviderManager(ProviderManager, {
|
||||
onChangeAppState: args => {
|
||||
appStateChanges.push(args as { newState: any; oldState: any })
|
||||
},
|
||||
})
|
||||
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame =>
|
||||
frame.includes('Provider manager') &&
|
||||
frame.includes('Set active provider'),
|
||||
)
|
||||
|
||||
mounted.stdin.write('j')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame =>
|
||||
frame.includes('Set active provider') &&
|
||||
frame.includes('Multi Model Provider'),
|
||||
)
|
||||
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
await waitForCondition(() => setActiveProviderProfile.mock.calls.length > 0)
|
||||
await waitForCondition(() =>
|
||||
appStateChanges.some(
|
||||
({ newState, oldState }) =>
|
||||
newState.mainLoopModel === 'gpt-5.4' &&
|
||||
oldState.mainLoopModel !== newState.mainLoopModel,
|
||||
),
|
||||
)
|
||||
|
||||
expect(setActiveProviderProfile).toHaveBeenCalledWith('provider_multi_model')
|
||||
expect(
|
||||
appStateChanges.some(
|
||||
({ newState }) =>
|
||||
newState.mainLoopModel === 'gpt-5.4' &&
|
||||
newState.mainLoopModelForSession === null,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
appStateChanges.some(
|
||||
({ newState }) => newState.mainLoopModel === 'gpt-5.4; gpt-5.4-mini',
|
||||
),
|
||||
).toBe(false)
|
||||
|
||||
await mounted.dispose()
|
||||
})
|
||||
|
||||
test('ProviderManager editing an active multi-model provider keeps app state on the primary model', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
|
||||
const multiModelProfile = {
|
||||
id: 'provider_multi_model',
|
||||
provider: 'openai',
|
||||
name: 'Multi Model Provider',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-5.4; gpt-5.4-mini',
|
||||
apiKey: 'sk-test',
|
||||
}
|
||||
|
||||
const updateProviderProfile = mock(() => multiModelProfile)
|
||||
const appStateChanges: Array<{ newState: any; oldState: any }> = []
|
||||
|
||||
mockProviderManagerDependencies(
|
||||
() => undefined,
|
||||
async () => undefined,
|
||||
{
|
||||
getActiveProviderProfile: () => multiModelProfile,
|
||||
getProviderProfiles: () => [multiModelProfile],
|
||||
updateProviderProfile,
|
||||
},
|
||||
)
|
||||
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
||||
const mounted = await mountProviderManager(ProviderManager, {
|
||||
onChangeAppState: args => {
|
||||
appStateChanges.push(args as { newState: any; oldState: any })
|
||||
},
|
||||
})
|
||||
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame =>
|
||||
frame.includes('Provider manager') &&
|
||||
frame.includes('Edit provider'),
|
||||
)
|
||||
|
||||
mounted.stdin.write('j')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('j')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame =>
|
||||
frame.includes('Edit provider') &&
|
||||
frame.includes('Multi Model Provider'),
|
||||
)
|
||||
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame =>
|
||||
frame.includes('Edit provider profile') &&
|
||||
frame.includes('Step 1 of 4'),
|
||||
)
|
||||
|
||||
mounted.stdin.write('\r')
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame => frame.includes('Step 2 of 4'),
|
||||
)
|
||||
|
||||
mounted.stdin.write('\r')
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame => frame.includes('Step 3 of 4'),
|
||||
)
|
||||
|
||||
mounted.stdin.write('\r')
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame => frame.includes('Step 4 of 4'),
|
||||
)
|
||||
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
await waitForCondition(() => updateProviderProfile.mock.calls.length > 0)
|
||||
await waitForCondition(() =>
|
||||
appStateChanges.some(
|
||||
({ newState, oldState }) =>
|
||||
newState.mainLoopModel === 'gpt-5.4' &&
|
||||
oldState.mainLoopModel !== newState.mainLoopModel,
|
||||
),
|
||||
)
|
||||
|
||||
expect(updateProviderProfile).toHaveBeenCalledWith(
|
||||
'provider_multi_model',
|
||||
expect.objectContaining({
|
||||
model: 'gpt-5.4; gpt-5.4-mini',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
appStateChanges.some(
|
||||
({ newState }) =>
|
||||
newState.mainLoopModel === 'gpt-5.4' &&
|
||||
newState.mainLoopModelForSession === null,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
appStateChanges.some(
|
||||
({ newState }) => newState.mainLoopModel === 'gpt-5.4; gpt-5.4-mini',
|
||||
),
|
||||
).toBe(false)
|
||||
|
||||
await mounted.dispose()
|
||||
})
|
||||
|
||||
test('ProviderManager resolves Codex OAuth state from async storage without sync reads in render flow', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
|
||||
Reference in New Issue
Block a user