fix(opencode): preserve hydrated model lists

This commit is contained in:
777genius 2026-05-20 15:38:46 +03:00
parent 16c1e4b47d
commit e3ff8e1df5
4 changed files with 258 additions and 2 deletions

View file

@ -224,10 +224,16 @@ function mergeProviderStatusCatalogCache(
): CliProviderStatus {
const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? 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 {
...incomingProvider,
models: incomingProvider.models.length > 0 ? incomingProvider.models : currentProvider.models,
models: shouldPreserveCurrentModels ? currentProvider.models : incomingProvider.models,
modelCatalog,
modelCatalogRefreshState:
modelCatalog && incomingRefreshState !== 'error'

View file

@ -204,9 +204,15 @@ function mergeProviderCatalogCache(
): CliProviderStatus {
const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? 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 {
...incomingProvider,
models: incomingProvider.models.length > 0 ? incomingProvider.models : currentProvider.models,
models: shouldPreserveCurrentModels ? currentProvider.models : incomingProvider.models,
modelCatalog,
modelCatalogRefreshState:
modelCatalog && incomingRefreshState !== 'error'

View file

@ -712,6 +712,145 @@ describe('CliInstallerService', () => {
expect(verifiedProvider?.modelVerificationState).toBe('idle');
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', () => {

View file

@ -1486,6 +1486,111 @@ describe('cliInstallerSlice', () => {
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 () => {
const nextProvider = createMultimodelProvider({
providerId: 'opencode',