diff --git a/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx b/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx index f89111ebb..844952966 100644 --- a/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx +++ b/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx @@ -29,7 +29,7 @@ const hostModels = vi.hoisted(() => ({ loads: 0, })) vi.mock('../model/source', () => ({ - providers: () => hostModels.providers, + defaultProviders: () => hostModels.providers, loadDefaultProviders: async () => { hostModels.loads += 1 }, diff --git a/ui-web/src/features/extAgents/ExtAgentsPage.tsx b/ui-web/src/features/extAgents/ExtAgentsPage.tsx index c0d48bac2..77de07455 100644 --- a/ui-web/src/features/extAgents/ExtAgentsPage.tsx +++ b/ui-web/src/features/extAgents/ExtAgentsPage.tsx @@ -6,7 +6,7 @@ import { KeyInput } from '../../components/KeyInput' import { ModelPicker } from '../../components/ModelPicker' import { t } from '../../i18n/t' import * as lang from '../../state/lang' -import { loadDefaultProviders, providers as hostProviders } from '../model/source' +import { defaultProviders as hostProviders, loadDefaultProviders } from '../model/source' import { offered } from '../model/types' import { byOf, installOf, isOwnRow } from './catalogue' import { SectionBlock, Spin, Tile, connect, ordered, shownOf } from './Rows' diff --git a/ui-web/src/features/model/source.test.ts b/ui-web/src/features/model/source.test.ts index 1b759d3ec..1814bf0d4 100644 --- a/ui-web/src/features/model/source.test.ts +++ b/ui-web/src/features/model/source.test.ts @@ -114,7 +114,9 @@ async function live({ session = null, answers = null }: Options = {}) { loadPermMode: settingsModule.loadPermMode, loadSettings: settingsModule.loadSettings, settingsSnapshot: settingsModule.settingsSnapshot, - get providersLive() { return model.providers() }, + loadDefaultProviders: model.loadDefaultProviders, + get providersLive() { return model.modelSource.providers() }, + get defaultProvidersLive() { return model.defaultProviders() }, get defaultModelLive() { return model.defaultModel() }, get defaultProviderLive() { return model.defaultProvider() }, } @@ -344,6 +346,34 @@ describe('providers the page does not offer', () => { }) }) +describe('the two provider scopes', () => { + const row = (slug: string, current = false) => + ({ slug, name: slug, authenticated: true, models: [`${slug}/m`], is_current: current }) + + it('a default-scoped read leaves the conversation rows alone', async () => { + /* `is_current` is the whole difference: the backend marks the + conversation's provider when the read names a session and + agents.defaults' when it does not. Sharing one array let the settings + dialog's read -- which every settings write repeats -- put the default's + answer under the composer's picker, which then opened on the default's + column with the conversation's model prepended and ticked there, under a + vendor whose key does not serve it. */ + const h = await live({ session: 'sess-1' }) + + const perSession = h.settings.loadProviders() + await h.settle(0, { model: 'gemini/g', provider: 'gemini', providers: [row('deepseek'), row('gemini', true)] }) + await perSession + expect(h.settings.providersLive.filter((p) => p.current).map((p) => p.id)).toEqual(['gemini']) + + const perDefault = h.settings.loadDefaultProviders() + await h.settle(1, { model: 'deepseek/d', provider: 'deepseek', providers: [row('deepseek', true), row('gemini')] }) + await perDefault + + expect(h.settings.providersLive.filter((p) => p.current).map((p) => p.id)).toEqual(['gemini']) + expect(h.settings.defaultProvidersLive.filter((p) => p.current).map((p) => p.id)).toEqual(['deepseek']) + }) +}) + describe('the follows-default repaint under navigation', () => { it('drops the repaint when the reader left during the write', async () => { const h = await live({ session: 'a' }) diff --git a/ui-web/src/features/model/source.ts b/ui-web/src/features/model/source.ts index 21e0f59cc..77b538ea4 100644 --- a/ui-web/src/features/model/source.ts +++ b/ui-web/src/features/model/source.ts @@ -21,6 +21,7 @@ import type { ApiProtocol, Kind, ModelSource, Offer, Provider } from './types' type ProviderWire = ResultOf<'model.options'>['providers'][number] let providersLive: Provider[] = [] +let defaultProvidersLive: Provider[] = [] let defaultModelLive = '' let defaultProviderLive = '' @@ -28,7 +29,15 @@ let defaultProviderLive = '' the boot in src/app/boot.ts is what learns the answer. */ export const setupState: { providerConfigured: boolean | null } = { providerConfigured: null } -export const providers = (): Provider[] => providersLive +/* The rows the settings page reads: the default-scoped answer. Apart from the + session-scoped one below because `is_current` is the difference between them + -- the backend marks the conversation's provider when the read names a + session and `agents.defaults`' when it does not. One array held both, so + opening the settings dialog (or any provider write) put the default's answer + under the composer's picker: it then opened on the default's column with the + conversation's model prepended and ticked there, under a vendor whose key + does not serve it. The same split as `defaultModelLive` above, one layer out. */ +export const defaultProviders = (): Provider[] => defaultProvidersLive export const defaultModel = (): string => defaultModelLive export const defaultProvider = (): string => defaultProviderLive @@ -116,7 +125,7 @@ const rowsOf = (list: ProviderWire[]): Provider[] => coalesced onto the dropped load and showed no provider to connect. */ export async function loadDefaultProviders(): Promise { const mo = await gateway().call('model.options', {}) - providersLive = rowsOf(mo.providers || []) + defaultProvidersLive = rowsOf(mo.providers || []) } export async function loadProviders(sid?: string | null, gen?: number): Promise { @@ -265,6 +274,7 @@ export function stagedTier(): string | null { registered, are both the module's. */ export function _resetForTests(): void { providersLive = [] + defaultProvidersLive = [] defaultModelLive = '' defaultProviderLive = '' paintChip = () => {} diff --git a/ui-web/src/features/settings/source.ts b/ui-web/src/features/settings/source.ts index 7c884c749..fb4867a1c 100644 --- a/ui-web/src/features/settings/source.ts +++ b/ui-web/src/features/settings/source.ts @@ -26,7 +26,7 @@ import { defaultProvider, loadDefaultProviders, persistModel, - providers, + defaultProviders, setDefaultPair, } from '../model/source' import { loadSessions, SESS_CHANNELS } from '../rail/source' @@ -157,7 +157,7 @@ export const settingsSnapshot = (): SettingsSnapshot => ({ // Both default-scoped on purpose: the settings page describes what new // conversations start on, so pairing the default model with the visible // session's provider badged the wrong row whenever the two scopes differ. - providers: providers(), curProvider: defaultProvider(), model: defaultModel(), + providers: defaultProviders(), curProvider: defaultProvider(), model: defaultModel(), tools: extTools(), skills: extSkillRows(), mcp: extMcpRows(), }) as SettingsSnapshot @@ -384,7 +384,7 @@ export const keySet = (tool: string, vendor: string, raw: Record p.on) && !!defaultModel() + return defaultProviders().some((p) => p.on) && !!defaultModel() } export function webStepDone(): boolean {