fix(opencode): preserve hydrated model lists
This commit is contained in:
parent
16c1e4b47d
commit
e3ff8e1df5
4 changed files with 258 additions and 2 deletions
|
|
@ -224,10 +224,16 @@ function mergeProviderStatusCatalogCache(
|
||||||
): CliProviderStatus {
|
): CliProviderStatus {
|
||||||
const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? null;
|
const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? null;
|
||||||
const incomingRefreshState = incomingProvider.modelCatalogRefreshState ?? null;
|
const incomingRefreshState = incomingProvider.modelCatalogRefreshState ?? null;
|
||||||
|
const shouldPreserveCurrentModels =
|
||||||
|
incomingProvider.models.length === 0 ||
|
||||||
|
(incomingProvider.providerId === 'opencode' &&
|
||||||
|
incomingProvider.modelCatalog == null &&
|
||||||
|
incomingProvider.runtimeCapabilities?.modelCatalog?.dynamic === true &&
|
||||||
|
currentProvider.models.length > incomingProvider.models.length);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...incomingProvider,
|
...incomingProvider,
|
||||||
models: incomingProvider.models.length > 0 ? incomingProvider.models : currentProvider.models,
|
models: shouldPreserveCurrentModels ? currentProvider.models : incomingProvider.models,
|
||||||
modelCatalog,
|
modelCatalog,
|
||||||
modelCatalogRefreshState:
|
modelCatalogRefreshState:
|
||||||
modelCatalog && incomingRefreshState !== 'error'
|
modelCatalog && incomingRefreshState !== 'error'
|
||||||
|
|
|
||||||
|
|
@ -204,9 +204,15 @@ function mergeProviderCatalogCache(
|
||||||
): CliProviderStatus {
|
): CliProviderStatus {
|
||||||
const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? null;
|
const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? null;
|
||||||
const incomingRefreshState = incomingProvider.modelCatalogRefreshState ?? null;
|
const incomingRefreshState = incomingProvider.modelCatalogRefreshState ?? null;
|
||||||
|
const shouldPreserveCurrentModels =
|
||||||
|
incomingProvider.models.length === 0 ||
|
||||||
|
(incomingProvider.providerId === 'opencode' &&
|
||||||
|
incomingProvider.modelCatalog == null &&
|
||||||
|
incomingProvider.runtimeCapabilities?.modelCatalog?.dynamic === true &&
|
||||||
|
currentProvider.models.length > incomingProvider.models.length);
|
||||||
return {
|
return {
|
||||||
...incomingProvider,
|
...incomingProvider,
|
||||||
models: incomingProvider.models.length > 0 ? incomingProvider.models : currentProvider.models,
|
models: shouldPreserveCurrentModels ? currentProvider.models : incomingProvider.models,
|
||||||
modelCatalog,
|
modelCatalog,
|
||||||
modelCatalogRefreshState:
|
modelCatalogRefreshState:
|
||||||
modelCatalog && incomingRefreshState !== 'error'
|
modelCatalog && incomingRefreshState !== 'error'
|
||||||
|
|
|
||||||
|
|
@ -712,6 +712,145 @@ describe('CliInstallerService', () => {
|
||||||
expect(verifiedProvider?.modelVerificationState).toBe('idle');
|
expect(verifiedProvider?.modelVerificationState).toBe('idle');
|
||||||
expect(verifiedProvider?.modelAvailability).toEqual([]);
|
expect(verifiedProvider?.modelAvailability).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not shrink cached OpenCode models when a provider refresh returns summary-only models', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
||||||
|
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
||||||
|
displayName: 'agent_teams_orchestrator',
|
||||||
|
supportsSelfUpdate: false,
|
||||||
|
showVersionDetails: false,
|
||||||
|
showBinaryPath: false,
|
||||||
|
});
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
||||||
|
vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => {
|
||||||
|
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||||
|
if (normalizedArgs === '--version') {
|
||||||
|
return { stdout: '2.3.4', stderr: '' };
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected execCli call: ${normalizedArgs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockResolvedValue([
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
displayName: 'OpenCode',
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'opencode_managed',
|
||||||
|
verificationState: 'verified',
|
||||||
|
modelVerificationState: 'idle',
|
||||||
|
statusMessage: null,
|
||||||
|
detailMessage: null,
|
||||||
|
models: [
|
||||||
|
'opencode/big-pickle',
|
||||||
|
'openai/gpt-5.4',
|
||||||
|
'openrouter/openai/gpt-oss-20b:free',
|
||||||
|
],
|
||||||
|
modelCatalog: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
providerId: 'opencode',
|
||||||
|
source: 'app-server',
|
||||||
|
status: 'ready',
|
||||||
|
fetchedAt: '2026-05-20T00:00:00.000Z',
|
||||||
|
staleAt: '2026-05-20T00:10:00.000Z',
|
||||||
|
defaultModelId: 'opencode/big-pickle',
|
||||||
|
defaultLaunchModel: 'opencode/big-pickle',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: 'opencode/big-pickle',
|
||||||
|
launchModel: 'opencode/big-pickle',
|
||||||
|
displayName: 'opencode/big-pickle',
|
||||||
|
hidden: false,
|
||||||
|
supportedReasoningEfforts: [],
|
||||||
|
defaultReasoningEffort: null,
|
||||||
|
inputModalities: ['text'],
|
||||||
|
supportsPersonality: true,
|
||||||
|
isDefault: true,
|
||||||
|
upgrade: false,
|
||||||
|
source: 'app-server',
|
||||||
|
badgeLabel: 'Free',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'openai/gpt-5.4',
|
||||||
|
launchModel: 'openai/gpt-5.4',
|
||||||
|
displayName: 'openai/gpt-5.4',
|
||||||
|
hidden: false,
|
||||||
|
supportedReasoningEfforts: [],
|
||||||
|
defaultReasoningEffort: null,
|
||||||
|
inputModalities: ['text'],
|
||||||
|
supportsPersonality: true,
|
||||||
|
isDefault: false,
|
||||||
|
upgrade: false,
|
||||||
|
source: 'app-server',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics: {
|
||||||
|
configReadState: 'ready',
|
||||||
|
appServerState: 'healthy',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modelCatalogRefreshState: 'ready',
|
||||||
|
modelAvailability: [],
|
||||||
|
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
|
||||||
|
canLoginFromUi: false,
|
||||||
|
capabilities: { teamLaunch: true, oneShot: false, extensions: undefined as never },
|
||||||
|
selectedBackendId: null,
|
||||||
|
resolvedBackendId: null,
|
||||||
|
availableBackends: [],
|
||||||
|
externalRuntimeDiagnostics: [],
|
||||||
|
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||||
|
connection: null,
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatus').mockResolvedValue({
|
||||||
|
providerId: 'opencode',
|
||||||
|
displayName: 'OpenCode',
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'opencode_managed',
|
||||||
|
verificationState: 'verified',
|
||||||
|
modelVerificationState: 'idle',
|
||||||
|
statusMessage: null,
|
||||||
|
detailMessage: null,
|
||||||
|
models: ['opencode/big-pickle'],
|
||||||
|
modelCatalog: null,
|
||||||
|
modelCatalogRefreshState: 'loading',
|
||||||
|
modelAvailability: [],
|
||||||
|
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
|
||||||
|
canLoginFromUi: false,
|
||||||
|
capabilities: { teamLaunch: true, oneShot: false, extensions: undefined as never },
|
||||||
|
selectedBackendId: null,
|
||||||
|
resolvedBackendId: null,
|
||||||
|
availableBackends: [],
|
||||||
|
externalRuntimeDiagnostics: [],
|
||||||
|
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||||
|
connection: null,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await service.getStatus();
|
||||||
|
await service.getProviderStatus('opencode');
|
||||||
|
|
||||||
|
const latestSnapshot = (
|
||||||
|
service as unknown as {
|
||||||
|
latestStatusSnapshot?: Awaited<ReturnType<CliInstallerService['getStatus']>>;
|
||||||
|
}
|
||||||
|
).latestStatusSnapshot;
|
||||||
|
const opencode = latestSnapshot?.providers.find(
|
||||||
|
(provider) => provider.providerId === 'opencode'
|
||||||
|
);
|
||||||
|
expect(opencode?.models).toEqual([
|
||||||
|
'opencode/big-pickle',
|
||||||
|
'openai/gpt-5.4',
|
||||||
|
'openrouter/openai/gpt-oss-20b:free',
|
||||||
|
]);
|
||||||
|
expect(opencode?.modelCatalog?.models.map((model) => model.id)).toEqual([
|
||||||
|
'opencode/big-pickle',
|
||||||
|
'openai/gpt-5.4',
|
||||||
|
]);
|
||||||
|
expect(opencode?.modelCatalogRefreshState).toBe('ready');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('install mutex', () => {
|
describe('install mutex', () => {
|
||||||
|
|
|
||||||
|
|
@ -1486,6 +1486,111 @@ describe('cliInstallerSlice', () => {
|
||||||
expect(provider?.modelCatalog?.defaultModelId).toBe('gpt-5.4');
|
expect(provider?.modelCatalog?.defaultModelId).toBe('gpt-5.4');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps cached OpenCode model list when summary refresh only reports big-pickle', async () => {
|
||||||
|
const currentProvider = createMultimodelProvider({
|
||||||
|
providerId: 'opencode',
|
||||||
|
displayName: 'OpenCode',
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'opencode_managed',
|
||||||
|
statusMessage: null,
|
||||||
|
models: [
|
||||||
|
'opencode/big-pickle',
|
||||||
|
'openai/gpt-5.4',
|
||||||
|
'openrouter/openai/gpt-oss-20b:free',
|
||||||
|
],
|
||||||
|
modelCatalogRefreshState: 'ready',
|
||||||
|
modelCatalog: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
providerId: 'opencode',
|
||||||
|
source: 'app-server',
|
||||||
|
status: 'ready',
|
||||||
|
fetchedAt: '2026-05-20T00:00:00.000Z',
|
||||||
|
staleAt: '2026-05-20T00:10:00.000Z',
|
||||||
|
defaultModelId: 'opencode/big-pickle',
|
||||||
|
defaultLaunchModel: 'opencode/big-pickle',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: 'opencode/big-pickle',
|
||||||
|
launchModel: 'opencode/big-pickle',
|
||||||
|
displayName: 'opencode/big-pickle',
|
||||||
|
hidden: false,
|
||||||
|
supportedReasoningEfforts: [],
|
||||||
|
defaultReasoningEffort: null,
|
||||||
|
inputModalities: ['text'],
|
||||||
|
supportsPersonality: true,
|
||||||
|
isDefault: true,
|
||||||
|
upgrade: false,
|
||||||
|
source: 'app-server',
|
||||||
|
badgeLabel: 'Free',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'openai/gpt-5.4',
|
||||||
|
launchModel: 'openai/gpt-5.4',
|
||||||
|
displayName: 'openai/gpt-5.4',
|
||||||
|
hidden: false,
|
||||||
|
supportedReasoningEfforts: [],
|
||||||
|
defaultReasoningEffort: null,
|
||||||
|
inputModalities: ['text'],
|
||||||
|
supportsPersonality: true,
|
||||||
|
isDefault: false,
|
||||||
|
upgrade: false,
|
||||||
|
source: 'app-server',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics: {
|
||||||
|
configReadState: 'ready',
|
||||||
|
appServerState: 'healthy',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runtimeCapabilities: {
|
||||||
|
modelCatalog: {
|
||||||
|
dynamic: true,
|
||||||
|
source: 'app-server',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||||
|
});
|
||||||
|
|
||||||
|
useStore.setState({
|
||||||
|
cliStatus: createMultimodelStatus([currentProvider]),
|
||||||
|
});
|
||||||
|
vi.mocked(api.cliInstaller.getProviderStatus).mockResolvedValue(
|
||||||
|
createMultimodelProvider({
|
||||||
|
providerId: 'opencode',
|
||||||
|
displayName: 'OpenCode',
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'opencode_managed',
|
||||||
|
statusMessage: null,
|
||||||
|
models: ['opencode/big-pickle'],
|
||||||
|
modelCatalog: null,
|
||||||
|
modelCatalogRefreshState: 'loading',
|
||||||
|
runtimeCapabilities: {
|
||||||
|
modelCatalog: {
|
||||||
|
dynamic: true,
|
||||||
|
source: 'app-server',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await useStore.getState().fetchCliProviderStatus('opencode');
|
||||||
|
|
||||||
|
const provider = useStore
|
||||||
|
.getState()
|
||||||
|
.cliStatus?.providers.find((candidate) => candidate.providerId === 'opencode');
|
||||||
|
expect(provider?.models).toEqual([
|
||||||
|
'opencode/big-pickle',
|
||||||
|
'openai/gpt-5.4',
|
||||||
|
'openrouter/openai/gpt-oss-20b:free',
|
||||||
|
]);
|
||||||
|
expect(provider?.modelCatalog?.models.map((model) => model.id)).toEqual([
|
||||||
|
'opencode/big-pickle',
|
||||||
|
'openai/gpt-5.4',
|
||||||
|
]);
|
||||||
|
expect(provider?.modelCatalogRefreshState).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps OpenCode refresh status-only even when model verification is requested', async () => {
|
it('keeps OpenCode refresh status-only even when model verification is requested', async () => {
|
||||||
const nextProvider = createMultimodelProvider({
|
const nextProvider = createMultimodelProvider({
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue