Files
openchamber/packages/ui/src/stores/useConfigStore.test.ts
T
Bohdan Triapitsyn d8737d397d fix(ui): keep the selected model when switching agent modes (#2690)
fix(ui): keep the selected model when switching agent modes
2026-08-28 23:41:16 +03:00

1228 lines
46 KiB
TypeScript

import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { Agent } from '@opencode-ai/sdk/v2';
const DIRECTORY = '/workspace/project';
const OTHER_DIRECTORY = '/workspace/other';
const STORAGE_KEY = 'config-store';
type TestAgent = { name: string; mode?: string; hidden?: boolean; model?: { providerID?: string; modelID?: string }; variant?: string };
let storage = new Map<string, string>();
let liveProviderId = 'live';
let liveProviderIdsByDirectory = new Map<string, string>();
let liveProviderVariants: Record<string, Record<string, unknown>> | undefined;
let getProvidersCalls = 0;
let getConfigCalls = 0;
let listAgentsCalls = 0;
let liveAgents: TestAgent[] = [];
let listAgentsImpl: ((directory?: string | null) => Promise<TestAgent[]>) | null = null;
let withDirectoryCalls: Array<string | null> = [];
let currentFetchDirectory: string | null = DIRECTORY;
let configListener: ((event: { scopes: string[]; source?: string; timestamp: number }) => void | Promise<void>) | null = null;
const makeStorage = (): Storage => ({
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
},
key: (index: number) => Array.from(storage.keys())[index] ?? null,
get length() {
return storage.size;
},
}) as Storage;
const provider = (id: string, modelId = `${id}-model`, variants?: Record<string, Record<string, unknown>>) => ({
id,
name: id,
source: 'config' as const,
env: [],
options: {},
models: [
{
id: modelId,
name: modelId,
providerID: id,
api: { id: 'chat', url: '', npm: '' },
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 0, output: 0 },
options: {},
release_date: '',
status: 'active' as const,
headers: {},
attachment: false,
reasoning: false,
temperature: true,
tool_call: true,
...(variants ? { variants } : {}),
},
],
});
const providerResponse = (id: string, modelId = `${id}-model`, variants?: Record<string, Record<string, unknown>>) => ({
id,
name: id,
source: 'config' as const,
env: [],
options: {},
models: {
[modelId]: {
id: modelId,
name: modelId,
providerID: id,
api: { id: 'chat', url: '', npm: '' },
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 0, output: 0 },
options: {},
release_date: '',
status: 'active' as const,
headers: {},
attachment: false,
reasoning: false,
temperature: true,
tool_call: true,
...(variants ? { variants } : {}),
},
},
});
const testAgent = (name: string, options?: Partial<TestAgent>): Agent => ({
name,
mode: options?.mode ?? 'primary',
hidden: options?.hidden,
model: options?.model,
variant: options?.variant,
permission: {},
options: {},
}) as Agent;
const deferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
mock.module('@/stores/utils/safeStorage', () => ({
getDeferredSafeStorage: () => makeStorage(),
getSafeStorage: () => makeStorage(),
createDeferredSafeJSONStorage: () => {
const testStorage = makeStorage();
return {
getItem: (name: string) => {
const value = testStorage.getItem(name);
return value === null ? null : JSON.parse(value);
},
setItem: (name: string, value: unknown) => {
testStorage.setItem(name, JSON.stringify(value));
},
removeItem: (name: string) => {
testStorage.removeItem(name);
},
};
},
}));
mock.module('@/stores/useProjectsStore', () => ({
useProjectsStore: {
getState: () => ({
activeProjectId: 'project',
projects: [
{ id: 'project', path: DIRECTORY, label: 'Project' },
{ id: 'other', path: OTHER_DIRECTORY, label: 'Other' },
],
}),
},
}));
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
setDirectory: mock(() => undefined),
getDirectory: mock(() => DIRECTORY),
checkHealth: mock(async () => true),
withDirectory: mock(async (directory: string | null, callback: () => Promise<unknown>) => {
withDirectoryCalls.push(directory);
const previous = currentFetchDirectory;
currentFetchDirectory = directory;
try {
return await callback();
} finally {
currentFetchDirectory = previous;
}
}),
getProviders: mock(async () => {
getProvidersCalls += 1;
const id = liveProviderIdsByDirectory.get(currentFetchDirectory ?? '') ?? liveProviderId;
return { providers: [providerResponse(id, `${id}-model`, liveProviderVariants)], default: { default: id } };
}),
getProvidersForConfig: mock(async (directory?: string | null) => {
getProvidersCalls += 1;
const id = liveProviderIdsByDirectory.get(directory ?? '') ?? liveProviderId;
return { providers: [providerResponse(id, `${id}-model`, liveProviderVariants)], default: { default: id } };
}),
listAgents: mock(async (directory?: string | null) => {
listAgentsCalls += 1;
const impl = listAgentsImpl as ((directory?: string | null) => Promise<TestAgent[]>) | null;
return impl ? impl(directory) : liveAgents;
}),
getConfig: mock(async () => {
getConfigCalls += 1;
return {};
}),
clearConfigCache: mock(() => undefined),
},
}));
mock.module('@/contexts/runtimeAPIRegistry', () => ({
getRegisteredRuntimeAPIs: mock(() => null),
}));
mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: mock(async () => new Response(JSON.stringify({}), {
headers: { 'Content-Type': 'application/json' },
})),
}));
mock.module('@/lib/persistence', () => ({
updateDesktopSettings: mock(async () => undefined),
}));
mock.module('@/lib/startupTrace', () => ({
markStartupTrace: mock(() => undefined),
measureStartupTrace: mock(async (_name: string, callback: () => Promise<unknown>) => callback()),
}));
mock.module('@/lib/configSync', () => ({
emitConfigChange: mock(() => undefined),
scopeMatches: mock((event: { scopes: string[] }, scope: string) => event.scopes.includes('all') || event.scopes.includes(scope)),
subscribeToConfigChanges: mock((listener: typeof configListener) => {
configListener = listener;
return () => {
if (configListener === listener) {
configListener = null;
}
};
}),
}));
const { useConfigStore } = await import('./useConfigStore');
const { emitSyncConfigChanged, setSyncRefs } = await import('@/sync/sync-refs');
const { useSelectionStore } = await import('@/sync/selection-store');
const { useSessionUIStore } = await import('@/sync/session-ui-store');
describe('useConfigStore provider persistence', () => {
beforeEach(() => {
storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: makeStorage(),
});
liveProviderId = 'live';
liveProviderIdsByDirectory = new Map<string, string>();
liveProviderVariants = undefined;
getProvidersCalls = 0;
getConfigCalls = 0;
listAgentsCalls = 0;
liveAgents = [];
listAgentsImpl = null;
withDirectoryCalls = [];
currentFetchDirectory = DIRECTORY;
setSyncRefs({} as never, { children: new Map(), getState: () => undefined } as never, DIRECTORY);
useSelectionStore.setState({
sessionModelSelections: new Map(),
sessionAgentSelections: new Map(),
sessionAgentModelSelections: new Map(),
lastUsedProvider: null,
});
useSessionUIStore.setState({ currentSessionId: null });
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
directoryScoped: {},
providers: [],
defaultProviders: {},
currentProviderId: '',
currentModelId: '',
currentVariant: undefined,
currentVariantSelection: { override: undefined, inherited: undefined },
selectedProviderId: '',
currentAgentName: undefined,
agents: [],
agentModelSelections: {},
opencodeDefaultAgent: undefined,
opencodeDefaultModel: undefined,
selectionSource: 'auto',
isConnected: true,
isInitialized: false,
});
});
test('hydrates persisted provider snapshots for instant paint, then refreshes to live data', async () => {
storage.set(STORAGE_KEY, JSON.stringify({
state: {
activeDirectoryKey: DIRECTORY,
directoryScoped: {
[DIRECTORY]: {
providers: [provider('stale')],
agents: [{ name: 'build', mode: 'primary' }],
currentProviderId: 'stale',
currentModelId: 'stale-model',
currentAgentName: 'build',
selectedProviderId: 'stale',
agentModelSelections: { build: { providerId: 'stale', modelId: 'stale-model' } },
defaultProviders: { default: 'stale' },
},
[OTHER_DIRECTORY]: {
providers: [provider('other-stale')],
agents: [{ name: 'review', mode: 'primary' }],
currentProviderId: 'other-stale',
currentModelId: 'other-stale-model',
currentAgentName: 'review',
selectedProviderId: 'other-stale',
agentModelSelections: {},
defaultProviders: { default: 'other-stale' },
},
},
currentProviderId: 'stale',
currentModelId: 'stale-model',
selectedProviderId: 'stale',
defaultProviders: { default: 'stale' },
},
version: 0,
}));
await useConfigStore.persist.rehydrate();
// Stale-while-revalidate: the persisted snapshot is hydrated as-is so the
// pickers can paint instantly on cold start, instead of being stripped to empty.
const hydrated = useConfigStore.getState();
expect(hydrated.providers.map((entry) => entry.id)).toEqual(['stale']);
expect(hydrated.defaultProviders).toEqual({ default: 'stale' });
expect(hydrated.directoryScoped[DIRECTORY]?.providers.map((entry) => entry.id)).toEqual(['stale']);
expect(hydrated.directoryScoped[DIRECTORY]?.defaultProviders).toEqual({ default: 'stale' });
expect(hydrated.directoryScoped[DIRECTORY]?.agents).toEqual([{ name: 'build', mode: 'primary' }]);
expect(hydrated.directoryScoped[DIRECTORY]?.currentAgentName).toBe('build');
expect(hydrated.directoryScoped[OTHER_DIRECTORY]?.providers.map((entry) => entry.id)).toEqual(['other-stale']);
liveProviderId = 'fresh';
await hydrated.initializeApp();
const reloaded = useConfigStore.getState();
expect(getProvidersCalls).toBe(1);
expect(reloaded.providers.map((entry) => entry.id)).toEqual(['fresh']);
expect(reloaded.directoryScoped[DIRECTORY]?.providers.map((entry) => entry.id)).toEqual(['fresh']);
expect(reloaded.currentProviderId).toBe('fresh');
expect(reloaded.currentModelId).toBe('fresh-model');
});
test('provider config events refresh all known directory provider caches immediately', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('active-stale')],
defaultProviders: { default: 'active-stale' },
currentProviderId: 'active-stale',
currentModelId: 'active-stale-model',
selectedProviderId: 'active-stale',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('active-stale')],
agents: [],
currentProviderId: 'active-stale',
currentModelId: 'active-stale-model',
currentAgentName: undefined,
selectedProviderId: 'active-stale',
agentModelSelections: {},
defaultProviders: { default: 'active-stale' },
},
[OTHER_DIRECTORY]: {
providers: [provider('inactive-cached')],
agents: [],
currentProviderId: 'inactive-cached',
currentModelId: 'inactive-cached-model',
currentAgentName: undefined,
selectedProviderId: 'inactive-cached',
agentModelSelections: {},
defaultProviders: { default: 'inactive-cached' },
},
},
});
liveProviderIdsByDirectory = new Map([
[DIRECTORY, 'active-live'],
[OTHER_DIRECTORY, 'inactive-live'],
]);
expect(configListener).not.toBeNull();
await configListener?.({ scopes: ['providers'], timestamp: Date.now() });
const state = useConfigStore.getState();
expect(getProvidersCalls).toBe(2);
expect(state.directoryScoped[DIRECTORY]?.providers.map((entry) => entry.id)).toEqual(['active-live']);
expect(state.directoryScoped[OTHER_DIRECTORY]?.providers.map((entry) => entry.id)).toEqual(['inactive-live']);
expect(state.directoryScoped[OTHER_DIRECTORY]?.defaultProviders).toEqual({ default: 'inactive-live' });
});
test('provider reload preserves a valid current variant', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
currentProviderId: 'live',
currentModelId: 'live-model',
currentVariant: 'fast',
selectedProviderId: 'live',
settingsDefaultVariant: 'slow',
directoryScoped: {},
});
liveProviderId = 'live';
liveProviderVariants = { fast: {}, slow: {} };
await useConfigStore.getState().loadProviders({ source: 'test:variant' });
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('live');
expect(state.currentModelId).toBe('live-model');
expect(state.currentVariant).toBe('fast');
});
test('the settings provider selection survives a refresh that no longer lists it', async () => {
// Plugin-registered providers vanish from the list while OpenCode restarts.
// A refresh in that window used to move the user to another provider while
// they were reading or editing the one they picked.
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
currentProviderId: 'live',
currentModelId: 'live-model',
selectedProviderId: 'plugin-provider',
directoryScoped: {},
});
liveProviderId = 'live';
await useConfigStore.getState().loadProviders({ source: 'test:missing-selection' });
const state = useConfigStore.getState();
expect(state.providers.map((entry) => entry.id)).toEqual(['live']);
expect(state.selectedProviderId).toBe('plugin-provider');
expect(state.directoryScoped[DIRECTORY]?.selectedProviderId).toBe('plugin-provider');
});
test('an empty settings provider selection is filled from the refreshed list', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
currentProviderId: '',
currentModelId: '',
selectedProviderId: '',
directoryScoped: {},
});
liveProviderId = 'live';
await useConfigStore.getState().loadProviders({ source: 'test:empty-selection' });
expect(useConfigStore.getState().selectedProviderId).toBe('live');
});
test('changing the chat provider leaves the settings provider selection alone', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('anthropic'), provider('openai')],
currentProviderId: 'anthropic',
currentModelId: 'anthropic-model',
selectedProviderId: 'openai',
directoryScoped: {},
});
useConfigStore.getState().setProvider('anthropic');
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('anthropic');
expect(state.selectedProviderId).toBe('openai');
expect(state.directoryScoped[DIRECTORY]?.selectedProviderId).toBe('openai');
});
test('provider reload preserves the add-provider sentinel selection', async () => {
// The user has opened the "Add provider" form, which sets selectedProviderId
// to the sentinel. A background provider refresh must not navigate them away
// (and discard their unsaved input) just because the sentinel is not a real
// provider id. See issue #1765.
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
currentProviderId: 'live',
currentModelId: 'live-model',
selectedProviderId: '__add_provider__',
directoryScoped: {},
});
liveProviderId = 'live';
await useConfigStore.getState().loadProviders({ source: 'test:add-provider' });
expect(useConfigStore.getState().selectedProviderId).toBe('__add_provider__');
});
test('add-provider sentinel is not persisted as a stable provider selection', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
currentProviderId: 'live',
currentModelId: 'live-model',
selectedProviderId: '__add_provider__',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('live')],
agents: [],
currentProviderId: 'live',
currentModelId: 'live-model',
currentAgentName: undefined,
selectedProviderId: '__add_provider__',
agentModelSelections: {},
defaultProviders: { default: 'live' },
},
},
});
const persisted = JSON.parse(storage.get(STORAGE_KEY) ?? '{}');
expect(persisted.state.selectedProviderId).toBe('');
expect(persisted.state.directoryScoped[DIRECTORY].selectedProviderId).toBe('');
});
test('setAgent applies settings default variant for an agent configured model', () => {
useSessionUIStore.setState({ currentSessionId: 'ses_agent_default_variant' });
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })],
agents: [testAgent('plan', { model: { providerID: 'openai', modelID: 'gpt-5.5' } })],
settingsDefaultVariant: 'high',
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentVariant: undefined,
directoryScoped: {},
});
useConfigStore.getState().setAgent('plan');
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('openai');
expect(state.currentModelId).toBe('gpt-5.5');
expect(state.currentVariant).toBe('high');
expect(state.directoryScoped[DIRECTORY]?.currentVariant).toBe('high');
});
test('cycleCurrentVariant reaches Default, low, and medium from inherited high', () => {
useConfigStore.setState({
providers: [provider('openai', 'gpt-5.6-sol', { none: {}, low: {}, medium: {}, high: {}, xhigh: {}, max: {} })],
currentProviderId: 'openai',
currentModelId: 'gpt-5.6-sol',
currentVariant: 'high',
currentVariantSelection: { override: undefined, inherited: 'high' },
directoryScoped: {},
});
const expectedVariants = ['xhigh', 'max', undefined, 'none', 'low', 'medium', 'high'];
for (const expectedVariant of expectedVariants) {
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(expectedVariant);
expect(useConfigStore.getState().currentVariantSelection.override).toBe(expectedVariant ?? null);
}
useConfigStore.getState().setCurrentVariantOverride('max', 'high');
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
expect(useConfigStore.getState().currentVariant).toBe('high');
expect(useConfigStore.getState().currentVariantSelection).toEqual({ override: null, inherited: 'high' });
});
test('cycleCurrentVariant toggles a single variant with Default', () => {
useConfigStore.setState({
providers: [provider('openai', 'single', { high: {} })],
currentProviderId: 'openai',
currentModelId: 'single',
currentVariant: 'high',
currentVariantSelection: { override: null, inherited: 'high' },
directoryScoped: {},
});
expect(useConfigStore.getState().cycleCurrentVariant()).toBe('high');
expect(useConfigStore.getState().currentVariantSelection.override).toBe('high');
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
expect(useConfigStore.getState().currentVariantSelection.override).toBeNull();
expect(useConfigStore.getState().currentVariant).toBe('high');
});
test('an unavailable explicit variant cycles back to Default', () => {
useConfigStore.setState({
providers: [provider('openai', 'changed', { low: {}, high: {} })],
currentProviderId: 'openai',
currentModelId: 'changed',
currentVariant: 'removed',
currentVariantSelection: { override: 'removed', inherited: 'low' },
directoryScoped: {},
});
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
expect(useConfigStore.getState().currentVariant).toBe('low');
expect(useConfigStore.getState().currentVariantSelection.override).toBeNull();
});
test('setAgent prefers saved and agent variants before settings default', () => {
const sessionId = 'ses_agent_saved_variant';
useSessionUIStore.setState({ currentSessionId: sessionId });
useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, 'plan', 'openai', 'gpt-5.5', 'low');
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5', { low: {}, medium: {}, high: {} })],
agents: [testAgent('plan', {
model: { providerID: 'openai', modelID: 'gpt-5.5' },
variant: 'medium',
})],
settingsDefaultVariant: 'high',
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentVariant: undefined,
directoryScoped: {},
});
useConfigStore.getState().setAgent('plan');
expect(useConfigStore.getState().currentVariant).toBe('low');
useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, 'plan', 'openai', 'gpt-5.5', undefined);
useConfigStore.setState({ currentVariant: undefined, directoryScoped: {} });
useConfigStore.getState().setAgent('plan');
expect(useConfigStore.getState().currentVariant).toBe('medium');
});
test('setAgent applies settings default variant for a saved session agent model', () => {
const sessionId = 'ses_existing_agent_model_default_variant';
useSessionUIStore.setState({ currentSessionId: sessionId });
useSelectionStore.getState().saveAgentModelForSession(sessionId, 'plan', 'openai', 'gpt-5.5');
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })],
agents: [testAgent('plan')],
settingsDefaultVariant: 'high',
currentProviderId: 'other',
currentModelId: 'other-model',
currentVariant: undefined,
directoryScoped: {},
});
useConfigStore.getState().setAgent('plan');
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('openai');
expect(state.currentModelId).toBe('gpt-5.5');
expect(state.currentVariant).toBe('high');
});
test('[issue-2404] setAgent keeps session model override over agent default model', () => {
// Custom agent default is model-a; user manually overrode to model-b for this session.
// Re-applying setAgent (e.g. after delegated subtask completion rematerializes the
// parent) must keep model-b rather than resetting to the agent pin.
const sessionId = 'ses_2404_model_override';
const multiModelProvider = {
...provider('provider', 'model-a'),
models: [
provider('provider', 'model-a').models[0],
provider('provider', 'model-b').models[0],
],
};
useSessionUIStore.setState({ currentSessionId: sessionId });
useSelectionStore.getState().saveSessionModelSelection(sessionId, 'provider', 'model-b');
useSelectionStore.getState().saveAgentModelForSession(sessionId, 'custom-agent', 'provider', 'model-b');
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [multiModelProvider],
agents: [testAgent('custom-agent', { model: { providerID: 'provider', modelID: 'model-a' } })],
currentProviderId: 'provider',
currentModelId: 'model-b',
currentAgentName: 'custom-agent',
selectionSource: 'manual',
currentVariant: undefined,
directoryScoped: {},
});
useConfigStore.getState().setAgent('custom-agent');
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('provider');
expect(state.currentModelId).toBe('model-b');
expect(useSelectionStore.getState().getAgentModelForSession(sessionId, 'custom-agent')).toEqual({
providerId: 'provider',
modelId: 'model-b',
});
});
test('[issue-2404] setAgent uses agent default when no session override exists', () => {
const sessionId = 'ses_2404_agent_default';
const multiModelProvider = {
...provider('provider', 'model-a'),
models: [
provider('provider', 'model-a').models[0],
provider('provider', 'model-b').models[0],
],
};
useSessionUIStore.setState({ currentSessionId: sessionId });
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [multiModelProvider],
agents: [testAgent('custom-agent', { model: { providerID: 'provider', modelID: 'model-a' } })],
currentProviderId: 'provider',
currentModelId: 'model-b',
currentAgentName: undefined,
selectionSource: 'auto',
currentVariant: undefined,
directoryScoped: {},
});
useConfigStore.getState().setAgent('custom-agent');
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('provider');
expect(state.currentModelId).toBe('model-a');
});
test('[issue-2531] setAgent keeps the manual model when switching to an agent without an override', () => {
const sessionId = 'ses_2531_mode_switch';
useSessionUIStore.setState({ currentSessionId: sessionId });
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('deepseek', 'deepseek-v4-pro'), provider('kimi', 'kimi-k3')],
agents: [testAgent('build'), testAgent('plan')],
settingsDefaultModel: 'deepseek/deepseek-v4-pro',
currentProviderId: 'kimi',
currentModelId: 'kimi-k3',
currentAgentName: 'build',
selectionSource: 'manual',
currentVariant: undefined,
directoryScoped: {},
});
useConfigStore.getState().setAgent('plan');
const state = useConfigStore.getState();
expect(state.currentAgentName).toBe('plan');
expect(state.currentProviderId).toBe('kimi');
expect(state.currentModelId).toBe('kimi-k3');
});
test('loadAgents does not fetch OpenCode config directly', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('openai', 'gpt-5.5')],
agents: [],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentAgentName: undefined,
selectedProviderId: 'openai',
agentModelSelections: {},
defaultProviders: {},
selectionSource: 'auto',
},
},
});
liveAgents = [testAgent('build')];
await useConfigStore.getState().loadAgents({ directory: DIRECTORY, source: 'test:noConfigFetch' });
expect(listAgentsCalls).toBe(1);
expect(getConfigCalls).toBe(0);
});
test('a project default carries its own thinking level', async () => {
// The project pins a model plus the level to run it at. Before, the level
// was dropped and only the global settings variant was ever considered —
// and that one belongs to the global model, not this project's.
const projectProvider = provider('anthropic', 'claude-opus-5', { high: {}, low: {} });
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [projectProvider],
agents: [testAgent('build')],
currentProviderId: '',
currentModelId: '',
currentVariant: undefined,
settingsDefaultModel: undefined,
settingsDefaultVariant: 'low',
selectionSource: 'auto',
directoryScoped: {},
});
useConfigStore.getState().applyDefaultModelAgentSelection({
projectDefaultModel: 'anthropic/claude-opus-5',
projectDefaultVariant: 'high',
});
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('anthropic');
expect(state.currentModelId).toBe('claude-opus-5');
expect(state.currentVariant).toBe('high');
});
test('a fresh session applies the settings thinking level instead of the previous override', () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })],
agents: [testAgent('build')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentVariant: 'low',
currentVariantSelection: { override: 'low', inherited: 'high' },
settingsDefaultModel: 'openai/gpt-5.5',
settingsDefaultVariant: 'high',
selectionSource: 'manual',
directoryScoped: {},
});
useConfigStore.getState().applyDefaultModelAgentSelection();
const state = useConfigStore.getState();
expect(state.currentVariant).toBe('high');
expect(state.currentVariantSelection).toEqual({ override: 'high', inherited: 'high' });
expect(state.directoryScoped[DIRECTORY]?.currentVariant).toBe('high');
});
test('a thinking level the project model does not offer is ignored', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('anthropic', 'claude-opus-5')],
agents: [testAgent('build')],
currentProviderId: '',
currentModelId: '',
currentVariant: undefined,
settingsDefaultModel: undefined,
settingsDefaultVariant: undefined,
selectionSource: 'auto',
directoryScoped: {},
});
useConfigStore.getState().applyDefaultModelAgentSelection({
projectDefaultModel: 'anthropic/claude-opus-5',
projectDefaultVariant: 'high',
});
expect(useConfigStore.getState().currentVariant).toBe(undefined);
});
test('manual selection survives an in-flight loadAgents refresh', async () => {
const pendingAgents = deferred<TestAgent[]>();
listAgentsImpl = async () => pendingAgents.promise;
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('manual'), provider('default')],
agents: [testAgent('build')],
currentProviderId: 'default',
currentModelId: 'default-model',
currentAgentName: 'build',
selectedProviderId: 'default',
selectionSource: 'auto',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('manual'), provider('default')],
agents: [testAgent('build')],
currentProviderId: 'default',
currentModelId: 'default-model',
currentAgentName: 'build',
selectedProviderId: 'default',
agentModelSelections: {},
defaultProviders: {},
selectionSource: 'auto',
},
},
});
const load = useConfigStore.getState().loadAgents({ directory: DIRECTORY, source: 'test:manualRace' });
useConfigStore.setState((state) => ({
currentProviderId: 'manual',
currentModelId: 'manual-model',
currentAgentName: 'manual-agent',
selectedProviderId: 'manual',
selectionSource: 'manual',
directoryScoped: {
...state.directoryScoped,
[DIRECTORY]: {
...state.directoryScoped[DIRECTORY],
currentProviderId: 'manual',
currentModelId: 'manual-model',
currentAgentName: 'manual-agent',
selectedProviderId: 'manual',
selectionSource: 'manual',
},
},
}));
pendingAgents.resolve([
testAgent('build', { model: { providerID: 'default', modelID: 'default-model' } }),
testAgent('manual-agent'),
]);
await load;
const state = useConfigStore.getState();
expect(state.currentAgentName).toBe('manual-agent');
expect(state.currentProviderId).toBe('manual');
expect(state.currentModelId).toBe('manual-model');
expect(state.selectionSource).toBe('manual');
});
test('worktree sync config applies to the project-scoped snapshot', () => {
const worktree = '/workspace/project-worktree';
storage.set('oc.worktreeProjectMap', JSON.stringify({ [worktree]: DIRECTORY }));
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5')],
agents: [testAgent('build'), testAgent('review')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentAgentName: 'build',
selectedProviderId: 'openai',
selectionSource: 'auto',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('openai', 'gpt-5.5')],
agents: [testAgent('build'), testAgent('review')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentAgentName: 'build',
selectedProviderId: 'openai',
agentModelSelections: {},
defaultProviders: {},
selectionSource: 'auto',
},
},
});
emitSyncConfigChanged(worktree, { default_agent: 'review', model: 'openai/gpt-5.5' });
const state = useConfigStore.getState();
expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultAgent).toBe('review');
expect(state.directoryScoped[worktree]).toBe(undefined);
expect(state.currentAgentName).toBe('review');
});
test('sync config defaults do not close the add-provider settings flow', () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5'), provider('anthropic', 'claude')],
agents: [
testAgent('build', { model: { providerID: 'anthropic', modelID: 'claude' } }),
testAgent('review', { model: { providerID: 'openai', modelID: 'gpt-5.5' } }),
],
currentProviderId: 'anthropic',
currentModelId: 'claude',
currentAgentName: 'build',
selectedProviderId: '__add_provider__',
selectionSource: 'auto',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('openai', 'gpt-5.5'), provider('anthropic', 'claude')],
agents: [
testAgent('build', { model: { providerID: 'anthropic', modelID: 'claude' } }),
testAgent('review', { model: { providerID: 'openai', modelID: 'gpt-5.5' } }),
],
currentProviderId: 'anthropic',
currentModelId: 'claude',
currentAgentName: 'build',
selectedProviderId: '__add_provider__',
agentModelSelections: {},
defaultProviders: {},
selectionSource: 'auto',
},
},
});
emitSyncConfigChanged(DIRECTORY, { default_agent: 'review', model: 'openai/gpt-5.5' });
const state = useConfigStore.getState();
expect(state.currentAgentName).toBe('review');
expect(state.currentProviderId).toBe('openai');
expect(state.currentModelId).toBe('gpt-5.5');
expect(state.selectedProviderId).toBe('__add_provider__');
expect(state.directoryScoped[DIRECTORY]?.selectedProviderId).toBe('__add_provider__');
});
test('duplicate sync config event is a no-op when defaults and selection are unchanged', () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5')],
agents: [testAgent('build'), testAgent('review')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentAgentName: 'review',
selectedProviderId: 'openai',
opencodeDefaultAgent: 'review',
opencodeDefaultModel: 'openai/gpt-5.5',
selectionSource: 'auto',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('openai', 'gpt-5.5')],
agents: [testAgent('build'), testAgent('review')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentAgentName: 'review',
selectedProviderId: 'openai',
agentModelSelections: {},
defaultProviders: {},
opencodeDefaultAgent: 'review',
opencodeDefaultModel: 'openai/gpt-5.5',
selectionSource: 'auto',
},
},
});
let updates = 0;
const unsubscribe = useConfigStore.subscribe(() => {
updates += 1;
});
emitSyncConfigChanged(DIRECTORY, { default_agent: 'review', model: 'openai/gpt-5.5' });
unsubscribe();
expect(updates).toBe(0);
});
test('project loadAgents preserves defaults previously applied from a worktree config event', async () => {
const worktree = '/workspace/project-worktree';
storage.set('oc.worktreeProjectMap', JSON.stringify({ [worktree]: DIRECTORY }));
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5')],
agents: [testAgent('build'), testAgent('review')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentAgentName: 'build',
selectedProviderId: 'openai',
selectionSource: 'auto',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('openai', 'gpt-5.5')],
agents: [testAgent('build'), testAgent('review')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentAgentName: 'build',
selectedProviderId: 'openai',
agentModelSelections: {},
defaultProviders: {},
selectionSource: 'auto',
},
},
});
liveAgents = [testAgent('build'), testAgent('review')];
emitSyncConfigChanged(worktree, { default_agent: 'review', model: 'openai/gpt-5.5' });
await useConfigStore.getState().loadAgents({ directory: DIRECTORY, source: 'test:preserveWorktreeDefaults' });
const state = useConfigStore.getState();
expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultAgent).toBe('review');
expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultModel).toBe('openai/gpt-5.5');
expect(state.opencodeDefaultAgent).toBe('review');
expect(state.opencodeDefaultModel).toBe('openai/gpt-5.5');
});
test('in-flight loadAgents does not restore defaults cleared by a sync config event', async () => {
const pendingAgents = deferred<TestAgent[]>();
listAgentsImpl = async () => pendingAgents.promise;
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5')],
agents: [testAgent('build'), testAgent('review')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentAgentName: 'review',
selectedProviderId: 'openai',
selectionSource: 'auto',
opencodeDefaultAgent: 'review',
opencodeDefaultModel: 'openai/gpt-5.5',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('openai', 'gpt-5.5')],
agents: [testAgent('build'), testAgent('review')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentAgentName: 'review',
selectedProviderId: 'openai',
agentModelSelections: {},
defaultProviders: {},
opencodeDefaultAgent: 'review',
opencodeDefaultModel: 'openai/gpt-5.5',
selectionSource: 'auto',
},
},
});
const load = useConfigStore.getState().loadAgents({ directory: DIRECTORY, source: 'test:staleDefaultsRace' });
emitSyncConfigChanged(DIRECTORY, {});
pendingAgents.resolve([testAgent('build'), testAgent('review')]);
await load;
const state = useConfigStore.getState();
expect(state.opencodeDefaultAgent).toBe(undefined);
expect(state.opencodeDefaultModel).toBe(undefined);
expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultAgent).toBe(undefined);
expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultModel).toBe(undefined);
});
test('in-flight loadAgents does not restore pre-await sync config defaults after a clearing event', async () => {
const pendingAgents = deferred<TestAgent[]>();
const syncConfigs = new Map<string, Record<string, unknown>>([
[DIRECTORY, { default_agent: 'review', model: 'openai/gpt-5.5' }],
]);
setSyncRefs(
{} as never,
{
children: new Map(),
getState: (directory: string) => ({ config: syncConfigs.get(directory) ?? {} }),
} as never,
DIRECTORY,
);
listAgentsImpl = async () => pendingAgents.promise;
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5')],
agents: [testAgent('build'), testAgent('review')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentAgentName: 'review',
selectedProviderId: 'openai',
selectionSource: 'auto',
opencodeDefaultAgent: 'review',
opencodeDefaultModel: 'openai/gpt-5.5',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('openai', 'gpt-5.5')],
agents: [testAgent('build'), testAgent('review')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentAgentName: 'review',
selectedProviderId: 'openai',
agentModelSelections: {},
defaultProviders: {},
opencodeDefaultAgent: 'review',
opencodeDefaultModel: 'openai/gpt-5.5',
selectionSource: 'auto',
},
},
});
const load = useConfigStore.getState().loadAgents({ directory: DIRECTORY, source: 'test:preAwaitSyncConfigRace' });
syncConfigs.set(DIRECTORY, {});
emitSyncConfigChanged(DIRECTORY, {});
pendingAgents.resolve([testAgent('build'), testAgent('review')]);
await load;
const state = useConfigStore.getState();
expect(state.opencodeDefaultAgent).toBe(undefined);
expect(state.opencodeDefaultModel).toBe(undefined);
expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultAgent).toBe(undefined);
expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultModel).toBe(undefined);
});
test('directory activation isolates selection source and OpenCode defaults', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
selectionSource: 'manual',
currentVariant: 'high',
currentVariantSelection: { override: 'high', inherited: 'medium' },
opencodeDefaultAgent: 'active-default',
opencodeDefaultModel: 'active/model',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('active')],
agents: [testAgent('active-agent')],
currentProviderId: 'active',
currentModelId: 'active-model',
currentAgentName: 'active-agent',
selectedProviderId: 'active',
agentModelSelections: {},
defaultProviders: {},
opencodeDefaultAgent: 'active-default',
opencodeDefaultModel: 'active/model',
selectionSource: 'manual',
},
[OTHER_DIRECTORY]: {
providers: [provider('other')],
agents: [testAgent('other-agent')],
currentProviderId: 'other',
currentModelId: 'other-model',
currentVariant: 'low',
currentAgentName: 'other-agent',
selectedProviderId: 'other',
agentModelSelections: {},
defaultProviders: {},
opencodeDefaultAgent: 'other-default',
opencodeDefaultModel: 'other/model',
selectionSource: 'auto',
},
},
isConnected: false,
});
await useConfigStore.getState().activateDirectory(OTHER_DIRECTORY);
const state = useConfigStore.getState();
expect(state.activeDirectoryKey).toBe(OTHER_DIRECTORY);
expect(state.selectionSource).toBe('auto');
expect(state.opencodeDefaultAgent).toBe('other-default');
expect(state.opencodeDefaultModel).toBe('other/model');
expect(state.currentVariantSelection).toEqual({ override: undefined, inherited: 'low' });
});
test('sync config without defaults clears stored OpenCode defaults without changing manual selection', () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('manual')],
agents: [testAgent('manual-agent')],
currentProviderId: 'manual',
currentModelId: 'manual-model',
currentAgentName: 'manual-agent',
selectedProviderId: 'manual',
selectionSource: 'manual',
opencodeDefaultAgent: 'old-agent',
opencodeDefaultModel: 'old/model',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('manual')],
agents: [testAgent('manual-agent')],
currentProviderId: 'manual',
currentModelId: 'manual-model',
currentAgentName: 'manual-agent',
selectedProviderId: 'manual',
agentModelSelections: {},
defaultProviders: {},
opencodeDefaultAgent: 'old-agent',
opencodeDefaultModel: 'old/model',
selectionSource: 'manual',
},
},
});
emitSyncConfigChanged(DIRECTORY, {});
const state = useConfigStore.getState();
expect(state.opencodeDefaultAgent).toBe(undefined);
expect(state.opencodeDefaultModel).toBe(undefined);
expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultAgent).toBe(undefined);
expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultModel).toBe(undefined);
expect(state.currentAgentName).toBe('manual-agent');
expect(state.currentProviderId).toBe('manual');
expect(state.selectionSource).toBe('manual');
});
});