perf: make OpenCode config defaults non-blocking
Removes startup blocking on OpenCode config defaults Preserves manual and directory-specific model selections Adds regression coverage for config races
This commit is contained in:
@@ -1,14 +1,20 @@
|
||||
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;
|
||||
@@ -102,6 +108,26 @@ const providerResponse = (id: string, modelId = `${id}-model`, variants?: Record
|
||||
},
|
||||
});
|
||||
|
||||
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', () => ({
|
||||
getSafeStorage: () => makeStorage(),
|
||||
}));
|
||||
@@ -143,7 +169,16 @@ mock.module('@/lib/opencode/client', () => ({
|
||||
const id = liveProviderIdsByDirectory.get(directory ?? '') ?? liveProviderId;
|
||||
return { providers: [providerResponse(id, `${id}-model`, liveProviderVariants)], default: { default: id } };
|
||||
}),
|
||||
listAgents: mock(async () => []),
|
||||
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),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -180,16 +215,26 @@ mock.module('@/lib/configSync', () => ({
|
||||
}));
|
||||
|
||||
const { useConfigStore } = await import('./useConfigStore');
|
||||
const { emitSyncConfigChanged, setSyncRefs } = await import('@/sync/sync-refs');
|
||||
|
||||
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);
|
||||
useConfigStore.setState({
|
||||
activeDirectoryKey: DIRECTORY,
|
||||
directoryScoped: {},
|
||||
@@ -199,6 +244,12 @@ describe('useConfigStore provider persistence', () => {
|
||||
currentModelId: '',
|
||||
currentVariant: undefined,
|
||||
selectedProviderId: '',
|
||||
currentAgentName: undefined,
|
||||
agents: [],
|
||||
agentModelSelections: {},
|
||||
opencodeDefaultAgent: undefined,
|
||||
opencodeDefaultModel: undefined,
|
||||
selectionSource: 'auto',
|
||||
isConnected: true,
|
||||
isInitialized: false,
|
||||
});
|
||||
@@ -328,4 +379,388 @@ describe('useConfigStore provider persistence', () => {
|
||||
expect(state.currentModelId).toBe('live-model');
|
||||
expect(state.currentVariant).toBe('fast');
|
||||
});
|
||||
|
||||
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('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('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',
|
||||
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',
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user