Skip to content

Commit faa3664

Browse files
committed
feat: Auto-select default model when connecting a provider
When a user connects a provider (OpenRouter, Anthropic, or ChatGPT), automatically select the first (default) model from that provider. Changes: - Add pendingProviderAutoSelect field to track newly connected provider - Set flag on OpenRouterKeySaved, AnthropicOAuthConnected, ChatGPTOAuthConnected - Update ModelsConfigReceived to auto-select first model from pending provider - Persist auto-selected model to localStorage - Add comprehensive tests for all three providers
1 parent b3ae1ff commit faa3664

7 files changed

+241
-13
lines changed

libs/client/src/Client__TaskTabs.story.res

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ module Fixtures = {
7272
chatgptOAuthStatus: StateTypes.ChatGPTNotConnected,
7373
modelsConfig: None,
7474
selectedModel: None,
75+
pendingProviderAutoSelect: None,
7576
sessionsLoadState: StateTypes.SessionsNotLoaded,
7677
}
7778

libs/client/src/state/Client__StateSnapshot__Storybook.res

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ let snapshotToState = (snapshot: Snapshot.t): StateTypes.state => {
160160
chatgptOAuthStatus: Client__State__Types.ChatGPTNotConnected,
161161
modelsConfig: None,
162162
selectedModel: None,
163+
pendingProviderAutoSelect: None,
163164
sessionsLoadState: Client__State__Types.SessionsNotLoaded, // Cannot restore load state from snapshot
164165
}
165166
}

libs/client/src/state/Client__State__StateReducer.res

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ let defaultState: state = {
216216
chatgptOAuthStatus: Client__State__Types.ChatGPTNotConnected,
217217
modelsConfig: None,
218218
selectedModel: loadSelectedModelFromStorage(), // Load from localStorage on init
219+
pendingProviderAutoSelect: None,
219220
sessionsLoadState: Client__State__Types.SessionsNotLoaded,
220221
}
221222

@@ -1280,6 +1281,7 @@ let next = (state: state, action) => {
12801281
source: UserOverride,
12811282
saveStatus: Saved,
12821283
},
1284+
pendingProviderAutoSelect: Some("openrouter"),
12831285
}->FrontmanReactStatestore.StateReducer.update(~sideEffects=effects)
12841286

12851287
| OpenRouterKeySaveError({error}) =>
@@ -1311,24 +1313,51 @@ let next = (state: state, action) => {
13111313
}
13121314

13131315
| ModelsConfigReceived({config}) =>
1314-
// Set models config and initialize selected model if not already set
1315-
let selectedModel = switch state.selectedModel {
1316-
| Some(model) => Some(model)
1316+
// When a provider was just connected, auto-select its first model.
1317+
// Otherwise keep the current selection (or fall back to server default).
1318+
let (selectedModel, didAutoSelect) = switch state.pendingProviderAutoSelect {
1319+
| Some(providerId) =>
1320+
// Find the first model from the newly connected provider
1321+
let providerModel =
1322+
config.providers
1323+
->Array.find(p => p.id == providerId)
1324+
->Option.flatMap(p => p.models->Array.get(0))
1325+
->Option.map((m): Client__State__Types.selectedModel => {
1326+
provider: providerId,
1327+
value: m.value,
1328+
})
1329+
switch providerModel {
1330+
| Some(model) => (Some(model), true)
1331+
| None => (state.selectedModel, false)
1332+
}
13171333
| None =>
1318-
// Use default model from config
1319-
Some(
1334+
switch state.selectedModel {
1335+
| Some(model) => (Some(model), false)
1336+
| None =>
1337+
// Use default model from config
13201338
(
1321-
{
1322-
provider: config.defaultModel.provider,
1323-
value: config.defaultModel.value,
1324-
}: Client__State__Types.selectedModel
1325-
),
1326-
)
1339+
Some(
1340+
(
1341+
{
1342+
provider: config.defaultModel.provider,
1343+
value: config.defaultModel.value,
1344+
}: Client__State__Types.selectedModel
1345+
),
1346+
),
1347+
true,
1348+
)
1349+
}
1350+
}
1351+
// Persist whenever we picked a new model
1352+
switch (didAutoSelect, selectedModel) {
1353+
| (true, Some(model)) => saveSelectedModelToStorage(model)
1354+
| _ => ()
13271355
}
13281356
{
13291357
...state,
13301358
modelsConfig: Some(config),
13311359
selectedModel,
1360+
pendingProviderAutoSelect: None,
13321361
}->FrontmanReactStatestore.StateReducer.update
13331362

13341363
| SetSelectedModel({model}) =>
@@ -1400,6 +1429,7 @@ let next = (state: state, action) => {
14001429
{
14011430
...state,
14021431
anthropicOAuthStatus: Client__State__Types.Connected({expiresAt: expiresAtMs}),
1432+
pendingProviderAutoSelect: Some("anthropic"),
14031433
}->FrontmanReactStatestore.StateReducer.update(~sideEffects=effects)
14041434

14051435
| AnthropicOAuthError({error}) =>
@@ -1513,6 +1543,7 @@ let next = (state: state, action) => {
15131543
{
15141544
...state,
15151545
chatgptOAuthStatus: Client__State__Types.ChatGPTConnected({expiresAt: expiresAtMs}),
1546+
pendingProviderAutoSelect: Some("openai"),
15161547
}->FrontmanReactStatestore.StateReducer.update(~sideEffects=effects)
15171548
| _ => state->FrontmanReactStatestore.StateReducer.update
15181549
}

libs/client/src/state/Client__State__Types.res

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,5 +160,8 @@ type state = {
160160
chatgptOAuthStatus: chatgptOAuthStatus,
161161
modelsConfig: option<modelsConfig>,
162162
selectedModel: option<selectedModel>,
163+
// When a provider is freshly connected, this holds its id (e.g. "anthropic")
164+
// so the next ModelsConfigReceived auto-selects a default model from it.
165+
pendingProviderAutoSelect: option<string>,
163166
sessionsLoadState: sessionsLoadState,
164167
}

libs/client/test/Client__ChatGPTOAuth.test.res

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let _makeState = (~chatgptOAuthStatus: Types.chatgptOAuthStatus): Types.state =>
2020
chatgptOAuthStatus,
2121
modelsConfig: None,
2222
selectedModel: None,
23+
pendingProviderAutoSelect: None,
2324
sessionsLoadState: Types.SessionsNotLoaded,
2425
}
2526
}

libs/client/test/Client__ModelsRefresh.test.res

Lines changed: 186 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ let _dummyDeleteSession: Types.deleteSessionFn = (_, ~onComplete as _) => ()
1212
let _apiBaseUrl = "http://localhost:4000"
1313

1414
// Helper: base state with an active ACP session (needed to emit effects)
15-
let _makeState = (~anthropicOAuthStatus=Types.NotConnected, ~chatgptOAuthStatus=Types.ChatGPTNotConnected, ~openrouterKeySettings={Types.source: Types.None, saveStatus: Types.Idle}): Types.state => {
15+
let _makeState = (~anthropicOAuthStatus=Types.NotConnected, ~chatgptOAuthStatus=Types.ChatGPTNotConnected, ~openrouterKeySettings={Types.source: Types.None, saveStatus: Types.Idle}, ~selectedModel=None, ~pendingProviderAutoSelect=None): Types.state => {
1616
{
1717
tasks: Dict.make(),
1818
currentTask: Types.Task.New(Types.Task.makeNew(~previewUrl="http://localhost:3000")),
@@ -30,7 +30,8 @@ let _makeState = (~anthropicOAuthStatus=Types.NotConnected, ~chatgptOAuthStatus=
3030
anthropicOAuthStatus,
3131
chatgptOAuthStatus,
3232
modelsConfig: None,
33-
selectedModel: None,
33+
selectedModel,
34+
pendingProviderAutoSelect,
3435
sessionsLoadState: Types.SessionsNotLoaded,
3536
}
3637
}
@@ -137,3 +138,186 @@ describe("Models list refresh requires active ACP session", () => {
137138
t->expect(_hasFetchModelsEffect(effects))->Expect.toBe(false)
138139
})
139140
})
141+
142+
// ============================================================================
143+
// Auto-select default model from newly connected provider
144+
// ============================================================================
145+
146+
module SampleConfig = {
147+
let anthropicProvider: Types.providerConfig = {
148+
id: "anthropic",
149+
name: "Anthropic (Claude Pro/Max)",
150+
models: [
151+
{displayName: "Claude Sonnet 4.5", value: "claude-sonnet-4-5"},
152+
{displayName: "Claude Opus 4.5", value: "claude-opus-4-5"},
153+
],
154+
}
155+
let openaiProvider: Types.providerConfig = {
156+
id: "openai",
157+
name: "ChatGPT Pro/Plus",
158+
models: [
159+
{displayName: "GPT-5.1 Codex Max", value: "gpt-5.1-codex-max"},
160+
{displayName: "GPT-5.2", value: "gpt-5.2"},
161+
],
162+
}
163+
let openrouterProvider: Types.providerConfig = {
164+
id: "openrouter",
165+
name: "OpenRouter",
166+
models: [
167+
{displayName: "Gemini 3 Flash Preview", value: "google/gemini-3-flash-preview"},
168+
{displayName: "Claude Haiku 4.5", value: "anthropic/claude-haiku-4.5"},
169+
],
170+
}
171+
let configWithAnthropic: Types.modelsConfig = {
172+
providers: [anthropicProvider, openrouterProvider],
173+
defaultModel: {provider: "anthropic", value: "claude-sonnet-4-5"},
174+
}
175+
let configWithOpenAI: Types.modelsConfig = {
176+
providers: [openaiProvider, anthropicProvider, openrouterProvider],
177+
defaultModel: {provider: "openai", value: "gpt-5.1-codex-max"},
178+
}
179+
let configWithOpenRouterOnly: Types.modelsConfig = {
180+
providers: [openrouterProvider],
181+
defaultModel: {provider: "openrouter", value: "google/gemini-3-flash-preview"},
182+
}
183+
}
184+
185+
describe("Provider connect sets pendingProviderAutoSelect", () => {
186+
test("AnthropicOAuthConnected sets pendingProviderAutoSelect to anthropic", t => {
187+
let state = _makeState()
188+
189+
let (nextState, _effects) = Reducer.next(
190+
state,
191+
AnthropicOAuthConnected({expiresAt: "2026-12-31T00:00:00Z"}),
192+
)
193+
194+
t->expect(nextState.pendingProviderAutoSelect)->Expect.toEqual(Some("anthropic"))
195+
})
196+
197+
test("ChatGPTOAuthConnected sets pendingProviderAutoSelect to openai", t => {
198+
let state = _makeState(
199+
~chatgptOAuthStatus=Types.ChatGPTShowingCode({
200+
deviceAuthId: "device-1",
201+
userCode: "ABCD-1234",
202+
verificationUrl: "https://auth.openai.com/codex/device",
203+
}),
204+
)
205+
206+
let (nextState, _effects) = Reducer.next(
207+
state,
208+
ChatGPTOAuthConnected({deviceAuthId: "device-1", expiresAt: "2026-12-31T00:00:00Z"}),
209+
)
210+
211+
t->expect(nextState.pendingProviderAutoSelect)->Expect.toEqual(Some("openai"))
212+
})
213+
214+
test("OpenRouterKeySaved sets pendingProviderAutoSelect to openrouter", t => {
215+
let state = _makeState()
216+
217+
let (nextState, _effects) = Reducer.next(state, OpenRouterKeySaved)
218+
219+
t->expect(nextState.pendingProviderAutoSelect)->Expect.toEqual(Some("openrouter"))
220+
})
221+
})
222+
223+
describe("ModelsConfigReceived auto-selects model from newly connected provider", () => {
224+
test("auto-selects first Anthropic model when pendingProviderAutoSelect is anthropic", t => {
225+
let state = _makeState(
226+
~pendingProviderAutoSelect=Some("anthropic"),
227+
~selectedModel=Some({provider: "openrouter", value: "google/gemini-3-flash-preview"}),
228+
)
229+
230+
let (nextState, _effects) = Reducer.next(
231+
state,
232+
ModelsConfigReceived({config: SampleConfig.configWithAnthropic}),
233+
)
234+
235+
t
236+
->expect(nextState.selectedModel)
237+
->Expect.toEqual(Some({provider: "anthropic", value: "claude-sonnet-4-5"}))
238+
t->expect(nextState.pendingProviderAutoSelect)->Expect.toEqual(None)
239+
})
240+
241+
test("auto-selects first OpenAI model when pendingProviderAutoSelect is openai", t => {
242+
let state = _makeState(
243+
~pendingProviderAutoSelect=Some("openai"),
244+
~selectedModel=Some({provider: "openrouter", value: "google/gemini-3-flash-preview"}),
245+
)
246+
247+
let (nextState, _effects) = Reducer.next(
248+
state,
249+
ModelsConfigReceived({config: SampleConfig.configWithOpenAI}),
250+
)
251+
252+
t
253+
->expect(nextState.selectedModel)
254+
->Expect.toEqual(Some({provider: "openai", value: "gpt-5.1-codex-max"}))
255+
t->expect(nextState.pendingProviderAutoSelect)->Expect.toEqual(None)
256+
})
257+
258+
test("auto-selects first OpenRouter model when pendingProviderAutoSelect is openrouter", t => {
259+
let state = _makeState(
260+
~pendingProviderAutoSelect=Some("openrouter"),
261+
~selectedModel=Some({provider: "openrouter", value: "anthropic/claude-haiku-4.5"}),
262+
)
263+
264+
let (nextState, _effects) = Reducer.next(
265+
state,
266+
ModelsConfigReceived({config: SampleConfig.configWithOpenRouterOnly}),
267+
)
268+
269+
t
270+
->expect(nextState.selectedModel)
271+
->Expect.toEqual(Some({provider: "openrouter", value: "google/gemini-3-flash-preview"}))
272+
t->expect(nextState.pendingProviderAutoSelect)->Expect.toEqual(None)
273+
})
274+
275+
test("keeps current selection when no pending provider auto-select", t => {
276+
let existingModel: Types.selectedModel = {
277+
provider: "openrouter",
278+
value: "google/gemini-3-flash-preview",
279+
}
280+
let state = _makeState(~selectedModel=Some(existingModel))
281+
282+
let (nextState, _effects) = Reducer.next(
283+
state,
284+
ModelsConfigReceived({config: SampleConfig.configWithAnthropic}),
285+
)
286+
287+
t->expect(nextState.selectedModel)->Expect.toEqual(Some(existingModel))
288+
t->expect(nextState.pendingProviderAutoSelect)->Expect.toEqual(None)
289+
})
290+
291+
test("falls back to server default when no selection and no pending provider", t => {
292+
let state = _makeState()
293+
294+
let (nextState, _effects) = Reducer.next(
295+
state,
296+
ModelsConfigReceived({config: SampleConfig.configWithAnthropic}),
297+
)
298+
299+
t
300+
->expect(nextState.selectedModel)
301+
->Expect.toEqual(Some({provider: "anthropic", value: "claude-sonnet-4-5"}))
302+
})
303+
304+
test("clears pendingProviderAutoSelect even if provider not in config", t => {
305+
let existingModel: Types.selectedModel = {
306+
provider: "openrouter",
307+
value: "google/gemini-3-flash-preview",
308+
}
309+
let state = _makeState(
310+
~pendingProviderAutoSelect=Some("openai"),
311+
~selectedModel=Some(existingModel),
312+
)
313+
314+
// Config doesn't have OpenAI provider — keep existing selection
315+
let (nextState, _effects) = Reducer.next(
316+
state,
317+
ModelsConfigReceived({config: SampleConfig.configWithOpenRouterOnly}),
318+
)
319+
320+
t->expect(nextState.selectedModel)->Expect.toEqual(Some(existingModel))
321+
t->expect(nextState.pendingProviderAutoSelect)->Expect.toEqual(None)
322+
})
323+
})

libs/client/test/Client__State__StateReducer.test.res

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ module TestHelpers = {
4141
chatgptOAuthStatus: Client__State__Types.ChatGPTNotConnected,
4242
modelsConfig: None,
4343
selectedModel: None,
44+
pendingProviderAutoSelect: None,
4445
sessionsLoadState: Client__State__Types.SessionsNotLoaded,
4546
}: Client__State__Types.state
4647
)
@@ -666,6 +667,7 @@ describe("Client State Reducer - Task Management Actions", () => {
666667
chatgptOAuthStatus: Client__State__Types.ChatGPTNotConnected,
667668
modelsConfig: None,
668669
selectedModel: None,
670+
pendingProviderAutoSelect: None,
669671
sessionsLoadState: Client__State__Types.SessionsNotLoaded,
670672
}
671673

@@ -714,6 +716,7 @@ describe("Client State Reducer - Task Management Actions", () => {
714716
chatgptOAuthStatus: Client__State__Types.ChatGPTNotConnected,
715717
modelsConfig: None,
716718
selectedModel: None,
719+
pendingProviderAutoSelect: None,
717720
sessionsLoadState: Client__State__Types.SessionsNotLoaded,
718721
}
719722

@@ -761,6 +764,7 @@ describe("Client State Reducer - Task Management Actions", () => {
761764
chatgptOAuthStatus: Client__State__Types.ChatGPTNotConnected,
762765
modelsConfig: None,
763766
selectedModel: None,
767+
pendingProviderAutoSelect: None,
764768
sessionsLoadState: Client__State__Types.SessionsNotLoaded,
765769
}
766770

@@ -829,6 +833,7 @@ describe("Client State Reducer - Task Management Actions", () => {
829833
chatgptOAuthStatus: Client__State__Types.ChatGPTNotConnected,
830834
modelsConfig: None,
831835
selectedModel: None,
836+
pendingProviderAutoSelect: None,
832837
sessionsLoadState: Client__State__Types.SessionsNotLoaded,
833838
}
834839

@@ -945,6 +950,7 @@ describe("Client State Reducer - Session Loading Actions", () => {
945950
chatgptOAuthStatus: Client__State__Types.ChatGPTNotConnected,
946951
modelsConfig: None,
947952
selectedModel: None,
953+
pendingProviderAutoSelect: None,
948954
sessionsLoadState: Client__State__Types.SessionsLoading,
949955
}
950956

@@ -1033,6 +1039,7 @@ describe("Client State Reducer - Session Loading Actions", () => {
10331039
chatgptOAuthStatus: Client__State__Types.ChatGPTNotConnected,
10341040
modelsConfig: None,
10351041
selectedModel: None,
1042+
pendingProviderAutoSelect: None,
10361043
sessionsLoadState: Client__State__Types.SessionsLoaded,
10371044
}
10381045

0 commit comments

Comments
 (0)