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:
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
type ConfigResponse = { data: Record<string, unknown> };
|
||||
|
||||
(mock as unknown as { restore?: () => void }).restore?.();
|
||||
|
||||
const configResolvers: Array<(response: ConfigResponse) => void> = [];
|
||||
let configCalls = 0;
|
||||
|
||||
mock.module('@opencode-ai/sdk/v2', () => ({
|
||||
createOpencodeClient: mock(() => ({
|
||||
config: {
|
||||
get: mock(() => {
|
||||
configCalls += 1;
|
||||
return new Promise<ConfigResponse>((resolve) => {
|
||||
configResolvers.push(resolve);
|
||||
});
|
||||
}),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
mock.module('@/contexts/runtimeAPIRegistry', () => ({
|
||||
getRegisteredRuntimeAPIs: mock(() => null),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-url', () => ({
|
||||
getRuntimeUrlResolver: mock(() => ({
|
||||
api: (path: string) => path,
|
||||
})),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-switch', () => ({
|
||||
getRuntimeApiBaseUrl: mock(() => ''),
|
||||
getRuntimeKey: mock(() => 'test-runtime'),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: mock(async () => new Response(JSON.stringify([]), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/startupTrace', () => ({
|
||||
markStartupTrace: mock(() => undefined),
|
||||
}));
|
||||
|
||||
const { opencodeClient } = await import(`./client?cache-test=${Date.now()}`);
|
||||
|
||||
describe('opencodeClient getConfig cache', () => {
|
||||
test('cleared stale in-flight requests do not repopulate cache or delete newer in-flight requests', async () => {
|
||||
const first = opencodeClient.getConfig('/workspace/project');
|
||||
expect(configCalls).toBe(1);
|
||||
|
||||
opencodeClient.clearConfigCache();
|
||||
|
||||
const second = opencodeClient.getConfig('/workspace/project');
|
||||
expect(configCalls).toBe(2);
|
||||
|
||||
configResolvers[0]?.({ data: { model: 'old/model' } });
|
||||
expect(await first).toEqual({ model: 'old/model' });
|
||||
|
||||
const third = opencodeClient.getConfig('/workspace/project');
|
||||
expect(configCalls).toBe(2);
|
||||
|
||||
configResolvers[1]?.({ data: { model: 'new/model' } });
|
||||
expect(await second).toEqual({ model: 'new/model' });
|
||||
expect(await third).toEqual({ model: 'new/model' });
|
||||
|
||||
const cached = await opencodeClient.getConfig('/workspace/project');
|
||||
expect(cached).toEqual({ model: 'new/model' });
|
||||
expect(configCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
// Use relative path by default (works with both dev and nginx proxy server)
|
||||
// Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments
|
||||
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api";
|
||||
const CONFIG_CACHE_TTL_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Render an SDK error payload into a short string for Error messages.
|
||||
@@ -231,6 +232,9 @@ class OpencodeService {
|
||||
private listDirectoryInFlight: Map<string, Promise<FilesystemEntry[]>> = new Map();
|
||||
private configProvidersInFlight: Map<string, Promise<{ providers: Provider[]; default: { [key: string]: string } }>> = new Map();
|
||||
private listAgentsInFlight: Map<string, Promise<Agent[]>> = new Map();
|
||||
private configInFlight: Map<string, Promise<Config>> = new Map();
|
||||
private configCache: Map<string, { config: Config; expiresAt: number }> = new Map();
|
||||
private configCacheGeneration = 0;
|
||||
private listDirectoryCache: Map<string, { entries: FilesystemEntry[]; expiresAt: number }> = new Map();
|
||||
|
||||
constructor(baseUrl: string = DEFAULT_BASE_URL) {
|
||||
@@ -256,6 +260,7 @@ class OpencodeService {
|
||||
this.listDirectoryInFlight.clear();
|
||||
this.configProvidersInFlight.clear();
|
||||
this.listAgentsInFlight.clear();
|
||||
this.clearConfigCache();
|
||||
this.listDirectoryCache.clear();
|
||||
}
|
||||
|
||||
@@ -1232,17 +1237,62 @@ class OpencodeService {
|
||||
}
|
||||
|
||||
// Configuration
|
||||
async getConfig(): Promise<Config> {
|
||||
const response = await this.client.config.get();
|
||||
if (!response.data) throw new Error('Failed to get config');
|
||||
return response.data;
|
||||
clearConfigCache(): void {
|
||||
this.configCacheGeneration += 1;
|
||||
this.configInFlight.clear();
|
||||
this.configCache.clear();
|
||||
}
|
||||
|
||||
async getConfig(directory?: string | null): Promise<Config> {
|
||||
const effectiveDirectory = this.normalizeCandidatePath(directory) ?? directory ?? this.currentDirectory ?? undefined;
|
||||
const key = effectiveDirectory ?? '';
|
||||
const cached = this.configCache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
markStartupTrace('opencodeClient.getConfig:cacheHit', { directory: effectiveDirectory ?? null });
|
||||
return cached.config;
|
||||
}
|
||||
|
||||
const existing = this.configInFlight.get(key);
|
||||
if (existing) {
|
||||
markStartupTrace('opencodeClient.getConfig:deduped', { directory: effectiveDirectory ?? null });
|
||||
return existing;
|
||||
}
|
||||
|
||||
const generation = this.configCacheGeneration;
|
||||
const request = (async () => {
|
||||
markStartupTrace('opencodeClient.getConfig:start', { directory: effectiveDirectory ?? null });
|
||||
const started = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const scopedClient = effectiveDirectory ? this.getScopedApiClient(effectiveDirectory) : this.client;
|
||||
const response = await scopedClient.config.get();
|
||||
if (!response.data) throw new Error('Failed to get config');
|
||||
const ended = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
markStartupTrace('opencodeClient.getConfig:end', {
|
||||
directory: effectiveDirectory ?? null,
|
||||
durationMs: Math.round(ended - started),
|
||||
});
|
||||
if (generation === this.configCacheGeneration) {
|
||||
this.configCache.set(key, { config: response.data, expiresAt: Date.now() + CONFIG_CACHE_TTL_MS });
|
||||
}
|
||||
return response.data;
|
||||
})();
|
||||
|
||||
this.configInFlight.set(key, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (this.configInFlight.get(key) === request) {
|
||||
this.configInFlight.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateConfig(config: Record<string, unknown>): Promise<Config> {
|
||||
// IMPORTANT: Do NOT pass directory parameter for config updates
|
||||
// The config should be global, not directory-specific
|
||||
const response = await this.client.config.update({ config: config as Config });
|
||||
return unwrapSdkData(response, 'global.config.update');
|
||||
const data = unwrapSdkData(response, 'global.config.update');
|
||||
this.clearConfigCache();
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import type { StoreApi, UseBoundStore } from "zustand";
|
||||
import { devtools, persist, createJSONStorage } from "zustand/middleware";
|
||||
import type { Provider, Agent } from "@opencode-ai/sdk/v2";
|
||||
import type { Provider, Agent, Config } from "@opencode-ai/sdk/v2";
|
||||
import { opencodeClient } from "@/lib/opencode/client";
|
||||
import { scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
|
||||
import type { ModelMetadata } from "@/types";
|
||||
@@ -18,6 +18,7 @@ import { streamDebugEnabled } from "@/stores/utils/streamDebug";
|
||||
import { parseModelIdentifier } from "@/lib/modelIdentifier";
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
import { markStartupTrace, measureStartupTrace } from "@/lib/startupTrace";
|
||||
import { getSyncConfig, subscribeToSyncConfigChanges } from "@/sync/sync-refs";
|
||||
|
||||
const MODELS_DEV_API_URL = "https://models.dev/api.json";
|
||||
const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
|
||||
@@ -824,6 +825,9 @@ interface DirectoryScopedConfig {
|
||||
selectedProviderId: string;
|
||||
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
|
||||
defaultProviders: { [key: string]: string };
|
||||
opencodeDefaultAgent?: string;
|
||||
opencodeDefaultModel?: string;
|
||||
selectionSource?: "auto" | "manual";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -851,9 +855,92 @@ const hydrateActiveDirectorySnapshot = <T extends Partial<ConfigStore>>(merged:
|
||||
next.defaultProviders = snapshot.defaultProviders;
|
||||
}
|
||||
}
|
||||
if (snapshot.opencodeDefaultAgent !== undefined) {
|
||||
next.opencodeDefaultAgent = snapshot.opencodeDefaultAgent;
|
||||
}
|
||||
if (snapshot.opencodeDefaultModel !== undefined) {
|
||||
next.opencodeDefaultModel = snapshot.opencodeDefaultModel;
|
||||
}
|
||||
if (snapshot.selectionSource) {
|
||||
next.selectionSource = snapshot.selectionSource;
|
||||
}
|
||||
return next as T;
|
||||
};
|
||||
|
||||
const createEmptyDirectoryScopedConfig = (
|
||||
providers: ProviderWithModelList[] = [],
|
||||
agents: Agent[] = [],
|
||||
): DirectoryScopedConfig => ({
|
||||
providers,
|
||||
agents,
|
||||
currentProviderId: "",
|
||||
currentModelId: "",
|
||||
currentVariant: undefined,
|
||||
currentAgentName: undefined,
|
||||
selectedProviderId: "",
|
||||
agentModelSelections: {},
|
||||
defaultProviders: {},
|
||||
opencodeDefaultAgent: undefined,
|
||||
opencodeDefaultModel: undefined,
|
||||
selectionSource: "auto",
|
||||
});
|
||||
|
||||
const hasValidVariant = (
|
||||
providers: ProviderWithModelList[],
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
variant: string | undefined,
|
||||
): boolean => {
|
||||
if (!variant) return true;
|
||||
const model = providers
|
||||
.find((provider) => provider.id === providerId)
|
||||
?.models.find((entry) => entry.id === modelId) as { variants?: Record<string, unknown> } | undefined;
|
||||
return !!model?.variants && Object.prototype.hasOwnProperty.call(model.variants, variant);
|
||||
};
|
||||
|
||||
const resolveSelectionWithManualGuard = ({
|
||||
agents,
|
||||
providers,
|
||||
currentAgentName,
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
currentVariant,
|
||||
selectionSource,
|
||||
resolvedAgentName,
|
||||
resolvedProviderId,
|
||||
resolvedModelId,
|
||||
resolvedVariant,
|
||||
}: {
|
||||
agents: Agent[];
|
||||
providers: ProviderWithModelList[];
|
||||
currentAgentName: string | undefined;
|
||||
currentProviderId: string;
|
||||
currentModelId: string;
|
||||
currentVariant: string | undefined;
|
||||
selectionSource: "auto" | "manual";
|
||||
resolvedAgentName: string | undefined;
|
||||
resolvedProviderId: string | undefined;
|
||||
resolvedModelId: string | undefined;
|
||||
resolvedVariant: string | undefined;
|
||||
}) => {
|
||||
const manualAgentName = currentAgentName && agents.some((agent) => agent.name === currentAgentName)
|
||||
? currentAgentName
|
||||
: undefined;
|
||||
const manualModelValid = !!currentProviderId
|
||||
&& !!currentModelId
|
||||
&& hasProviderModel(providers, currentProviderId, currentModelId)
|
||||
&& hasValidVariant(providers, currentProviderId, currentModelId, currentVariant);
|
||||
const preserveManual = selectionSource === "manual" && (!!manualAgentName || manualModelValid);
|
||||
|
||||
return {
|
||||
agentName: preserveManual ? (manualAgentName ?? resolvedAgentName) : resolvedAgentName,
|
||||
providerId: preserveManual && manualModelValid ? currentProviderId : resolvedProviderId,
|
||||
modelId: preserveManual && manualModelValid ? currentModelId : resolvedModelId,
|
||||
variant: preserveManual && manualModelValid ? currentVariant : resolvedVariant,
|
||||
selectionSource: preserveManual ? "manual" as const : "auto" as const,
|
||||
};
|
||||
};
|
||||
|
||||
interface ConfigStore {
|
||||
|
||||
activeDirectoryKey: string;
|
||||
@@ -868,6 +955,7 @@ interface ConfigStore {
|
||||
selectedProviderId: string;
|
||||
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
|
||||
defaultProviders: { [key: string]: string };
|
||||
selectionSource: "auto" | "manual";
|
||||
isConnected: boolean;
|
||||
hasEverConnected: boolean;
|
||||
connectionPhase: "connecting" | "connected" | "reconnecting";
|
||||
@@ -879,7 +967,7 @@ interface ConfigStore {
|
||||
settingsDefaultVariant: string | undefined;
|
||||
settingsDefaultAgent: string | undefined;
|
||||
// OpenCode server's own `default_agent` config field (name of a primary agent), used as a
|
||||
// fallback when our own settingsDefaultAgent is unset. Sourced from opencodeClient.getConfig().
|
||||
// fallback when our own settingsDefaultAgent is unset. Sourced from sync config.
|
||||
opencodeDefaultAgent: string | undefined;
|
||||
// OpenCode server's own global `model` config field ("provider/model"), used as a fallback
|
||||
// when neither our settingsDefaultModel nor the resolved agent pins a model.
|
||||
@@ -963,6 +1051,7 @@ interface ConfigStore {
|
||||
getCurrentModelVariants: () => string[];
|
||||
setAgent: (agentName: string | undefined) => void;
|
||||
applyDefaultModelAgentSelection: () => void;
|
||||
applyOpenCodeConfigDefaults: (directory?: string | null, source?: string, config?: Config) => void;
|
||||
setSelectedProvider: (providerId: string) => void;
|
||||
setSettingsDefaultModel: (model: string | undefined) => void;
|
||||
setSettingsDefaultVariant: (variant: string | undefined) => void;
|
||||
@@ -1015,6 +1104,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
selectedProviderId: "",
|
||||
agentModelSelections: {},
|
||||
defaultProviders: {},
|
||||
selectionSource: "auto",
|
||||
isConnected: false,
|
||||
hasEverConnected: false,
|
||||
connectionPhase: "connecting",
|
||||
@@ -1296,6 +1386,9 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
selectedProviderId: snapshot.selectedProviderId,
|
||||
agentModelSelections: snapshot.agentModelSelections,
|
||||
defaultProviders: snapshot.defaultProviders,
|
||||
opencodeDefaultAgent: snapshot.opencodeDefaultAgent,
|
||||
opencodeDefaultModel: snapshot.opencodeDefaultModel,
|
||||
selectionSource: snapshot.selectionSource ?? "auto",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1309,6 +1402,9 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
selectedProviderId: "",
|
||||
agentModelSelections: {},
|
||||
defaultProviders: {},
|
||||
opencodeDefaultAgent: undefined,
|
||||
opencodeDefaultModel: undefined,
|
||||
selectionSource: "auto",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1638,12 +1734,14 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
currentProviderId: providerId,
|
||||
currentModelId: newModelId,
|
||||
selectedProviderId: providerId,
|
||||
selectionSource: "manual",
|
||||
};
|
||||
|
||||
return {
|
||||
currentProviderId: providerId,
|
||||
currentModelId: newModelId,
|
||||
selectedProviderId: providerId,
|
||||
selectionSource: "manual",
|
||||
directoryScoped: {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: nextSnapshot,
|
||||
@@ -1670,10 +1768,12 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
const nextSnapshot: DirectoryScopedConfig = {
|
||||
...baseSnapshot,
|
||||
currentModelId: modelId,
|
||||
selectionSource: "manual",
|
||||
};
|
||||
|
||||
return {
|
||||
currentModelId: modelId,
|
||||
selectionSource: "manual",
|
||||
directoryScoped: {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: nextSnapshot,
|
||||
@@ -1704,10 +1804,12 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
const nextSnapshot: DirectoryScopedConfig = {
|
||||
...baseSnapshot,
|
||||
currentVariant: variant,
|
||||
selectionSource: "manual",
|
||||
};
|
||||
|
||||
return {
|
||||
currentVariant: variant,
|
||||
selectionSource: "manual",
|
||||
directoryScoped: {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: nextSnapshot,
|
||||
@@ -1763,10 +1865,12 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
const nextSnapshot: DirectoryScopedConfig = {
|
||||
...baseSnapshot,
|
||||
selectedProviderId: providerId,
|
||||
selectionSource: "manual",
|
||||
};
|
||||
|
||||
return {
|
||||
selectedProviderId: providerId,
|
||||
selectionSource: "manual",
|
||||
directoryScoped: {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: nextSnapshot,
|
||||
@@ -1797,10 +1901,12 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
const nextSnapshot: DirectoryScopedConfig = {
|
||||
...baseSnapshot,
|
||||
agentModelSelections: nextSelections,
|
||||
selectionSource: "manual",
|
||||
};
|
||||
|
||||
return {
|
||||
agentModelSelections: nextSelections,
|
||||
selectionSource: "manual",
|
||||
directoryScoped: {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: nextSnapshot,
|
||||
@@ -1844,24 +1950,25 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
// Fetch agents, OpenChamber settings, and the OpenCode config in parallel.
|
||||
// The OpenCode config is best-effort: a failure should not block agent
|
||||
// loading, it just means we won't honor its default_agent this round.
|
||||
const [agents, openChamberDefaults, opencodeConfig] = await Promise.all([
|
||||
// Fetch agents and OpenChamber settings in parallel. OpenCode config
|
||||
// comes from sync state if it is already available; it must not block
|
||||
// the agent refresh path.
|
||||
const configDirectoryPath = fromDirectoryKey(directoryKey);
|
||||
const initialSyncedOpencodeConfig = getSyncConfig(requestedDirectory ?? undefined)
|
||||
?? getSyncConfig(configDirectoryPath ?? undefined);
|
||||
if (initialSyncedOpencodeConfig) {
|
||||
markStartupTrace('loadAgents:syncConfigHit', { directoryKey, source });
|
||||
}
|
||||
const [agents, openChamberDefaults] = await Promise.all([
|
||||
measureStartupTrace(
|
||||
'loadAgents:api',
|
||||
() => opencodeClient.listAgents(fromDirectoryKey(directoryKey)),
|
||||
() => opencodeClient.listAgents(configDirectoryPath),
|
||||
{ directoryKey, source, requestedDirectory, effectiveDirectory, attempt: attempt + 1 },
|
||||
),
|
||||
fetchOpenChamberDefaults(),
|
||||
opencodeClient
|
||||
.withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.getConfig())
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const safeAgents = Array.isArray(agents) ? agents : [];
|
||||
const opencodeDefaultAgent = normalizeOptionalString(opencodeConfig?.default_agent);
|
||||
const opencodeDefaultModel = normalizeOptionalString(opencodeConfig?.model);
|
||||
|
||||
const providerLoad = _inFlightProviders.get(directoryKey);
|
||||
if (providerLoad) {
|
||||
@@ -1869,6 +1976,16 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
await providerLoad;
|
||||
}
|
||||
|
||||
const latestSyncedOpencodeConfig = getSyncConfig(requestedDirectory ?? undefined)
|
||||
?? getSyncConfig(configDirectoryPath ?? undefined);
|
||||
const hasLatestSyncedOpencodeConfig = latestSyncedOpencodeConfig !== undefined;
|
||||
const latestSyncedOpencodeDefaultAgent = hasLatestSyncedOpencodeConfig
|
||||
? normalizeOptionalString(latestSyncedOpencodeConfig.default_agent)
|
||||
: undefined;
|
||||
const latestSyncedOpencodeDefaultModel = hasLatestSyncedOpencodeConfig
|
||||
? normalizeOptionalString(latestSyncedOpencodeConfig.model)
|
||||
: undefined;
|
||||
|
||||
const providers = get().activeDirectoryKey === directoryKey
|
||||
? get().providers
|
||||
: (get().directoryScoped[directoryKey]?.providers ?? []);
|
||||
@@ -1902,19 +2019,25 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
agentModelSelections: {},
|
||||
defaultProviders: {},
|
||||
};
|
||||
const opencodeDefaultAgent = hasLatestSyncedOpencodeConfig
|
||||
? latestSyncedOpencodeDefaultAgent
|
||||
: baseSnapshot.opencodeDefaultAgent ?? (state.activeDirectoryKey === directoryKey ? state.opencodeDefaultAgent : undefined);
|
||||
const opencodeDefaultModel = hasLatestSyncedOpencodeConfig
|
||||
? latestSyncedOpencodeDefaultModel
|
||||
: baseSnapshot.opencodeDefaultModel ?? (state.activeDirectoryKey === directoryKey ? state.opencodeDefaultModel : undefined);
|
||||
|
||||
const nextSnapshot: DirectoryScopedConfig = {
|
||||
...baseSnapshot,
|
||||
providers,
|
||||
agents: safeAgents,
|
||||
opencodeDefaultAgent,
|
||||
opencodeDefaultModel,
|
||||
};
|
||||
|
||||
const nextState: Partial<ConfigStore> = {
|
||||
settingsDefaultModel: openChamberDefaults.defaultModel,
|
||||
settingsDefaultVariant: openChamberDefaults.defaultVariant,
|
||||
settingsDefaultAgent: openChamberDefaults.defaultAgent,
|
||||
opencodeDefaultAgent,
|
||||
opencodeDefaultModel,
|
||||
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
|
||||
settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false,
|
||||
settingsDefaultFileViewerPreview: openChamberDefaults.defaultFileViewerPreview ?? false,
|
||||
@@ -1934,11 +2057,20 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
|
||||
if (state.activeDirectoryKey === directoryKey) {
|
||||
nextState.agents = safeAgents;
|
||||
nextState.opencodeDefaultAgent = opencodeDefaultAgent;
|
||||
nextState.opencodeDefaultModel = opencodeDefaultModel;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
|
||||
const latestConfigState = get();
|
||||
const latestSnapshot = latestConfigState.directoryScoped[directoryKey];
|
||||
const opencodeDefaultAgent = latestSnapshot?.opencodeDefaultAgent
|
||||
?? (latestConfigState.activeDirectoryKey === directoryKey ? latestConfigState.opencodeDefaultAgent : undefined);
|
||||
const opencodeDefaultModel = latestSnapshot?.opencodeDefaultModel
|
||||
?? (latestConfigState.activeDirectoryKey === directoryKey ? latestConfigState.opencodeDefaultModel : undefined);
|
||||
|
||||
const shouldPersistResolvedZenModel =
|
||||
!!resolvedZenModel &&
|
||||
resolvedZenModel !== defaultZenModel;
|
||||
@@ -2058,15 +2190,37 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
agentModelSelections: {},
|
||||
defaultProviders: {},
|
||||
};
|
||||
const isActive = state.activeDirectoryKey === directoryKey;
|
||||
const currentAgentName = isActive ? state.currentAgentName : baseSnapshot.currentAgentName;
|
||||
const currentProviderId = isActive ? state.currentProviderId : baseSnapshot.currentProviderId;
|
||||
const currentModelId = isActive ? state.currentModelId : baseSnapshot.currentModelId;
|
||||
const currentVariant = isActive ? state.currentVariant : baseSnapshot.currentVariant;
|
||||
const selectionSource = isActive ? state.selectionSource : (baseSnapshot.selectionSource ?? "auto");
|
||||
const nextSelection = resolveSelectionWithManualGuard({
|
||||
agents: safeAgents,
|
||||
providers,
|
||||
currentAgentName,
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
currentVariant,
|
||||
selectionSource,
|
||||
resolvedAgentName,
|
||||
resolvedProviderId,
|
||||
resolvedModelId,
|
||||
resolvedVariant,
|
||||
});
|
||||
|
||||
const nextSnapshot: DirectoryScopedConfig = {
|
||||
...baseSnapshot,
|
||||
providers,
|
||||
agents: safeAgents,
|
||||
currentAgentName: resolvedAgentName,
|
||||
currentProviderId: resolvedProviderId ?? baseSnapshot.currentProviderId,
|
||||
currentModelId: resolvedModelId ?? baseSnapshot.currentModelId,
|
||||
currentVariant: resolvedVariant,
|
||||
currentAgentName: nextSelection.agentName,
|
||||
currentProviderId: nextSelection.providerId ?? baseSnapshot.currentProviderId,
|
||||
currentModelId: nextSelection.modelId ?? baseSnapshot.currentModelId,
|
||||
currentVariant: nextSelection.variant,
|
||||
opencodeDefaultAgent,
|
||||
opencodeDefaultModel,
|
||||
selectionSource: nextSelection.selectionSource,
|
||||
};
|
||||
|
||||
const nextState: Partial<ConfigStore> = {
|
||||
@@ -2076,13 +2230,16 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
},
|
||||
};
|
||||
|
||||
if (state.activeDirectoryKey === directoryKey) {
|
||||
nextState.currentAgentName = resolvedAgentName;
|
||||
if (resolvedProviderId && resolvedModelId) {
|
||||
nextState.currentProviderId = resolvedProviderId;
|
||||
nextState.currentModelId = resolvedModelId;
|
||||
nextState.currentVariant = resolvedVariant;
|
||||
if (isActive) {
|
||||
nextState.currentAgentName = nextSelection.agentName;
|
||||
nextState.opencodeDefaultAgent = opencodeDefaultAgent;
|
||||
nextState.opencodeDefaultModel = opencodeDefaultModel;
|
||||
if (nextSelection.providerId && nextSelection.modelId) {
|
||||
nextState.currentProviderId = nextSelection.providerId;
|
||||
nextState.currentModelId = nextSelection.modelId;
|
||||
nextState.currentVariant = nextSelection.variant;
|
||||
}
|
||||
nextState.selectionSource = nextSelection.selectionSource;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
@@ -2210,10 +2367,12 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
const nextSnapshot: DirectoryScopedConfig = {
|
||||
...baseSnapshot,
|
||||
currentAgentName: agentName,
|
||||
selectionSource: "manual",
|
||||
};
|
||||
|
||||
return {
|
||||
currentAgentName: agentName,
|
||||
selectionSource: "manual",
|
||||
directoryScoped: {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: nextSnapshot,
|
||||
@@ -2261,6 +2420,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
currentModelId: modelId,
|
||||
currentVariant: variant,
|
||||
selectedProviderId: providerId,
|
||||
selectionSource: "manual",
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -2268,6 +2428,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
currentModelId: modelId,
|
||||
currentVariant: variant,
|
||||
selectedProviderId: providerId,
|
||||
selectionSource: "manual",
|
||||
directoryScoped: {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: nextSnapshot,
|
||||
@@ -2399,10 +2560,12 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
selectedProviderId: resolvedProviderId,
|
||||
}
|
||||
: {}),
|
||||
selectionSource: "auto",
|
||||
};
|
||||
|
||||
const nextState: Partial<ConfigStore> = {
|
||||
currentAgentName: resolvedAgentName,
|
||||
selectionSource: "auto",
|
||||
directoryScoped: {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: nextSnapshot,
|
||||
@@ -2420,6 +2583,153 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
applyOpenCodeConfigDefaults: (directory, source = "syncConfig", config) => {
|
||||
const eventDirectory = directory ?? fromDirectoryKey(get().activeDirectoryKey);
|
||||
const directoryKey = toConfigDirectoryKey(eventDirectory);
|
||||
const configDirectory = fromDirectoryKey(directoryKey);
|
||||
const syncedConfig = config
|
||||
?? getSyncConfig(eventDirectory ?? undefined)
|
||||
?? getSyncConfig(configDirectory ?? undefined);
|
||||
if (!syncedConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const opencodeDefaultAgent = normalizeOptionalString(syncedConfig.default_agent);
|
||||
const opencodeDefaultModel = normalizeOptionalString(syncedConfig.model);
|
||||
|
||||
set((state) => {
|
||||
const snapshot = state.directoryScoped[directoryKey];
|
||||
const isActive = state.activeDirectoryKey === directoryKey;
|
||||
const providers = isActive ? state.providers : (snapshot?.providers ?? []);
|
||||
const agents = isActive ? state.agents : (snapshot?.agents ?? []);
|
||||
const baseSnapshot: DirectoryScopedConfig = snapshot ?? createEmptyDirectoryScopedConfig(providers, agents);
|
||||
const defaultsChanged = baseSnapshot.opencodeDefaultAgent !== opencodeDefaultAgent
|
||||
|| baseSnapshot.opencodeDefaultModel !== opencodeDefaultModel
|
||||
|| (isActive && (
|
||||
state.opencodeDefaultAgent !== opencodeDefaultAgent
|
||||
|| state.opencodeDefaultModel !== opencodeDefaultModel
|
||||
));
|
||||
const defaultsSnapshot: DirectoryScopedConfig = {
|
||||
...baseSnapshot,
|
||||
providers,
|
||||
agents,
|
||||
opencodeDefaultAgent,
|
||||
opencodeDefaultModel,
|
||||
};
|
||||
const nextState: Partial<ConfigStore> = {
|
||||
directoryScoped: {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: defaultsSnapshot,
|
||||
},
|
||||
};
|
||||
|
||||
if (isActive) {
|
||||
nextState.opencodeDefaultAgent = opencodeDefaultAgent;
|
||||
nextState.opencodeDefaultModel = opencodeDefaultModel;
|
||||
}
|
||||
|
||||
const selectionSource = isActive ? state.selectionSource : (snapshot?.selectionSource ?? "auto");
|
||||
|
||||
if (providers.length === 0 || agents.length === 0) {
|
||||
if (!defaultsChanged) {
|
||||
return state;
|
||||
}
|
||||
return nextState;
|
||||
}
|
||||
|
||||
const resolved = resolveDefaultAgentModelSelection({
|
||||
agents,
|
||||
providers,
|
||||
settingsDefaultAgent: state.settingsDefaultAgent,
|
||||
settingsDefaultModel: state.settingsDefaultModel,
|
||||
settingsDefaultVariant: state.settingsDefaultVariant,
|
||||
opencodeDefaultAgent,
|
||||
opencodeDefaultModel,
|
||||
});
|
||||
|
||||
if (!resolved.agentName) {
|
||||
if (!defaultsChanged) {
|
||||
return state;
|
||||
}
|
||||
return nextState;
|
||||
}
|
||||
|
||||
const currentAgentName = isActive ? state.currentAgentName : baseSnapshot.currentAgentName;
|
||||
const currentProviderId = isActive ? state.currentProviderId : baseSnapshot.currentProviderId;
|
||||
const currentModelId = isActive ? state.currentModelId : baseSnapshot.currentModelId;
|
||||
const currentVariant = isActive ? state.currentVariant : baseSnapshot.currentVariant;
|
||||
const nextSelection = resolveSelectionWithManualGuard({
|
||||
agents,
|
||||
providers,
|
||||
currentAgentName,
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
currentVariant,
|
||||
selectionSource,
|
||||
resolvedAgentName: resolved.agentName,
|
||||
resolvedProviderId: resolved.providerId,
|
||||
resolvedModelId: resolved.modelId,
|
||||
resolvedVariant: resolved.variant,
|
||||
});
|
||||
|
||||
const nextSnapshot: DirectoryScopedConfig = {
|
||||
...defaultsSnapshot,
|
||||
providers,
|
||||
agents,
|
||||
currentAgentName: nextSelection.agentName,
|
||||
...(nextSelection.providerId && nextSelection.modelId
|
||||
? {
|
||||
currentProviderId: nextSelection.providerId,
|
||||
currentModelId: nextSelection.modelId,
|
||||
currentVariant: nextSelection.variant,
|
||||
selectedProviderId: nextSelection.providerId,
|
||||
}
|
||||
: {}),
|
||||
selectionSource: nextSelection.selectionSource,
|
||||
};
|
||||
|
||||
const selectionChanged = baseSnapshot.currentAgentName !== nextSnapshot.currentAgentName
|
||||
|| baseSnapshot.currentProviderId !== nextSnapshot.currentProviderId
|
||||
|| baseSnapshot.currentModelId !== nextSnapshot.currentModelId
|
||||
|| baseSnapshot.currentVariant !== nextSnapshot.currentVariant
|
||||
|| baseSnapshot.selectedProviderId !== nextSnapshot.selectedProviderId
|
||||
|| (baseSnapshot.selectionSource ?? "auto") !== nextSnapshot.selectionSource
|
||||
|| (isActive && (
|
||||
state.currentAgentName !== nextSelection.agentName
|
||||
|| state.selectionSource !== nextSelection.selectionSource
|
||||
|| (nextSelection.providerId !== undefined && nextSelection.modelId !== undefined && (
|
||||
state.currentProviderId !== nextSelection.providerId
|
||||
|| state.currentModelId !== nextSelection.modelId
|
||||
|| state.currentVariant !== nextSelection.variant
|
||||
|| state.selectedProviderId !== nextSelection.providerId
|
||||
))
|
||||
));
|
||||
|
||||
if (!defaultsChanged && !selectionChanged) {
|
||||
return state;
|
||||
}
|
||||
|
||||
nextState.directoryScoped = {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: nextSnapshot,
|
||||
};
|
||||
|
||||
if (isActive) {
|
||||
nextState.currentAgentName = nextSelection.agentName;
|
||||
nextState.selectionSource = nextSelection.selectionSource;
|
||||
if (nextSelection.providerId && nextSelection.modelId) {
|
||||
nextState.currentProviderId = nextSelection.providerId;
|
||||
nextState.currentModelId = nextSelection.modelId;
|
||||
nextState.currentVariant = nextSelection.variant;
|
||||
nextState.selectedProviderId = nextSelection.providerId;
|
||||
}
|
||||
}
|
||||
|
||||
markStartupTrace('loadAgents:opencodeConfigDefaultsApplied', { directoryKey, eventDirectory, source });
|
||||
return nextState;
|
||||
});
|
||||
},
|
||||
|
||||
setSettingsDefaultModel: (model: string | undefined) => {
|
||||
set({ settingsDefaultModel: model });
|
||||
},
|
||||
@@ -3020,6 +3330,8 @@ if (!unsubscribeConfigStoreChanges) {
|
||||
unsubscribeConfigStoreChanges = subscribeToConfigChanges(async (event) => {
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
opencodeClient.clearConfigCache();
|
||||
|
||||
if (scopeMatches(event, "agents")) {
|
||||
const { loadAgents } = useConfigStore.getState();
|
||||
tasks.push(loadAgents({ source: 'configChange:agents' }).then(() => {}));
|
||||
@@ -3037,6 +3349,14 @@ if (!unsubscribeConfigStoreChanges) {
|
||||
|
||||
let unsubscribeConfigStoreDirectoryChanges: (() => void) | null = null;
|
||||
|
||||
let unsubscribeConfigStoreSyncConfigChanges: (() => void) | null = null;
|
||||
|
||||
if (!unsubscribeConfigStoreSyncConfigChanges) {
|
||||
unsubscribeConfigStoreSyncConfigChanges = subscribeToSyncConfigChanges((directory, config) => {
|
||||
useConfigStore.getState().applyOpenCodeConfigDefaults(directory, 'syncConfig', config);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && !unsubscribeConfigStoreDirectoryChanges) {
|
||||
unsubscribeConfigStoreDirectoryChanges = useDirectoryStore.subscribe((state, prevState) => {
|
||||
const nextKey = toDirectoryKey(state.currentDirectory);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpencodeClient, PermissionRequest, Project, QuestionRequest } from
|
||||
import { retry } from "./retry"
|
||||
import type { GlobalState, State } from "./types"
|
||||
import { runtimeFetch } from "../lib/runtime-fetch"
|
||||
import { emitSyncConfigChanged } from "./sync-refs"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
@@ -135,7 +136,9 @@ export async function bootstrapDirectory(input: {
|
||||
const seededProject = projectID(directory, g.projects)
|
||||
if (seededProject) set({ project: seededProject })
|
||||
if (Object.keys(state.config ?? {}).length === 0 && Object.keys(g.config ?? {}).length > 0) {
|
||||
set({ config: g.config as State["config"] })
|
||||
const seededConfig = g.config as State["config"]
|
||||
set({ config: seededConfig })
|
||||
emitSyncConfigChanged(directory, seededConfig)
|
||||
}
|
||||
if (loading) set({ status: "partial" })
|
||||
|
||||
@@ -147,7 +150,11 @@ export async function bootstrapDirectory(input: {
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => sdk.project.current().then((x) => set({ project: unwrap(x, "project.current").id }))),
|
||||
retry(() => sdk.config.get().then((x) => set({ config: unwrap(x, "config.get") }))),
|
||||
retry(() => sdk.config.get().then((x) => {
|
||||
const config = unwrap(x, "config.get")
|
||||
set({ config })
|
||||
emitSyncConfigChanged(directory, config)
|
||||
})),
|
||||
retry(() =>
|
||||
sdk.path.get().then((x) => {
|
||||
const data = unwrap(x, "path.get")
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* session-actions) use them to read child-store domain data without hooks.
|
||||
*/
|
||||
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Config, OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ChildStoreManager } from "./child-store"
|
||||
import { getSessionMaterializationStatus } from "./materialization"
|
||||
import type { State } from "./types"
|
||||
@@ -14,6 +14,7 @@ let _sdk: OpencodeClient | null = null
|
||||
let _childStores: ChildStoreManager | null = null
|
||||
let _directory: string = ""
|
||||
let _registerSessionDirectory: ((sessionID: string, directory: string) => void) | null = null
|
||||
const configListeners = new Set<(directory: string, config: Config) => void>()
|
||||
|
||||
export function setSyncRefs(
|
||||
sdk: OpencodeClient,
|
||||
@@ -59,6 +60,26 @@ export function getDirectoryState(directory?: string): State | undefined {
|
||||
return stores.getState(dir)
|
||||
}
|
||||
|
||||
/** Read resolved OpenCode config from a directory child store, if bootstrapped. */
|
||||
export function getSyncConfig(directory?: string): Config | undefined {
|
||||
const config = getDirectoryState(directory)?.config
|
||||
return config && Object.keys(config).length > 0 ? config : undefined
|
||||
}
|
||||
|
||||
export function subscribeToSyncConfigChanges(listener: (directory: string, config: Config) => void): () => void {
|
||||
configListeners.add(listener)
|
||||
return () => {
|
||||
configListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function emitSyncConfigChanged(directory: string, config: Config): void {
|
||||
if (!directory) return
|
||||
for (const listener of configListeners) {
|
||||
listener(directory, config)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read sessions from current directory's child store */
|
||||
export function getSyncSessions(directory?: string) {
|
||||
return getDirectoryState(directory)?.session ?? []
|
||||
|
||||
Reference in New Issue
Block a user