fix(runtime-provider-management): harden provider directory interactions

This commit is contained in:
777genius 2026-04-25 19:48:23 +03:00
parent 4b77c648bf
commit 41ca9fc0cb
5 changed files with 390 additions and 3 deletions

View file

@ -257,7 +257,7 @@ export function useRuntimeProviderManagement(
const refreshDirectoryData = input.refresh === true;
const query = input.query ?? directoryQuery;
const filter = input.filter ?? directoryFilter;
const cursor = input.cursor ?? (append ? directoryNextCursor : null);
const cursor = input.cursor ?? null;
const requestSeq = directoryRequestSeq.current + 1;
directoryRequestSeq.current = requestSeq;
@ -319,7 +319,6 @@ export function useRuntimeProviderManagement(
},
[
directoryFilter,
directoryNextCursor,
directoryQuery,
directorySupported,
options.enabled,
@ -629,7 +628,6 @@ export function useRuntimeProviderManagement(
connectError instanceof Error ? connectError.message : 'Failed to connect provider'
);
} finally {
setApiKeyValue('');
setSavingProviderId(null);
}
},

View file

@ -152,4 +152,61 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
expect.objectContaining({ cwd: '/Users/test/project' })
);
});
it('loads provider directory with optional args and omits absent values', async () => {
execCliMock.mockResolvedValue({
stdout: JSON.stringify({
schemaVersion: 1,
runtimeId: 'opencode',
directory: {
runtimeId: 'opencode',
totalCount: 1,
returnedCount: 1,
query: 'deep',
filter: 'connectable',
limit: 10,
cursor: null,
nextCursor: null,
fetchedAt: '2026-04-25T00:00:00.000Z',
entries: [],
diagnostics: [],
},
}),
stderr: '',
});
const client = new AgentTeamsRuntimeProviderManagementCliClient();
const response = await client.loadProviderDirectory({
runtimeId: 'opencode',
projectPath: '/Users/test/project',
query: 'deep',
filter: 'connectable',
limit: 10,
refresh: true,
});
expect(response.directory?.query).toBe('deep');
expect(execCliMock).toHaveBeenCalledWith(
'/repo/cli-dev',
[
'runtime',
'providers',
'directory',
'--runtime',
'opencode',
'--json',
'--project-path',
'/Users/test/project',
'--query',
'deep',
'--filter',
'connectable',
'--limit',
'10',
'--refresh',
],
expect.objectContaining({ cwd: '/Users/test/project' })
);
expect(JSON.stringify(execCliMock.mock.calls[0])).not.toContain('undefined');
});
});

View file

@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { registerRuntimeProviderManagementIpc } from '../../../../src/features/runtime-provider-management/main';
import {
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY,
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
} from '../../../../src/features/runtime-provider-management/contracts';
@ -130,6 +131,22 @@ describe('registerRuntimeProviderManagementIpc', () => {
registerRuntimeProviderManagementIpc(ipcMain, feature);
await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_VIEW)?.({}, { runtimeId: 'opencode' });
await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY)?.(
{},
{
runtimeId: 'opencode',
query: 'deep',
filter: 'connectable',
limit: 10,
}
);
expect(feature.loadProviderDirectory).toHaveBeenCalledWith({
runtimeId: 'opencode',
query: 'deep',
filter: 'connectable',
limit: 10,
});
const response = await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY)?.(
{},
{

View file

@ -257,6 +257,110 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(host.querySelector('[data-testid="runtime-provider-search"]')).not.toBeNull();
});
it('opens the OpenCode provider directory and renders directory rows', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const actions = createActions();
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
directoryOpen: true,
directoryLoaded: true,
directoryTotalCount: 115,
directoryEntries: [
{
providerId: 'deepseek',
displayName: 'DeepSeek',
state: 'available',
setupKind: 'available-readonly',
ownership: [],
recommended: false,
modelCount: 62,
defaultModelId: null,
authMethods: [],
actions: [
{
id: 'configure',
label: 'Configure manually',
enabled: false,
disabledReason: 'OpenCode did not advertise API-key auth',
requiresSecret: false,
ownershipScope: 'runtime',
},
],
sources: ['opencode-provider'],
sourceLabel: 'OpenCode catalog',
providerSource: 'models.dev',
detail: 'Models are visible, but no connected credential was reported',
metadata: {
hasKnownModels: true,
requiresManualConfig: false,
supportedInlineAuth: false,
},
},
],
}),
actions,
disabled: false,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('115 OpenCode providers');
expect(host.textContent).toContain('DeepSeek');
expect(host.textContent).toContain('62 models');
expect(host.textContent).toContain('OpenCode catalog');
expect(host.querySelector('[data-testid="runtime-provider-directory-search"]')).not.toBeNull();
await act(async () => {
host
.querySelector('[data-testid="runtime-provider-directory-row-deepseek"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(actions.selectDirectoryProvider).toHaveBeenCalledWith('deepseek');
});
it('offers global provider search when compact search has no matches', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const actions = createActions();
const state = createState();
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: {
...state,
providers: state.view?.providers ?? [],
providerQuery: 'deep',
},
actions,
disabled: false,
})
);
await Promise.resolve();
});
const searchAll = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Search all OpenCode providers for "deep"')
);
expect(searchAll).not.toBeNull();
await act(async () => {
searchAll?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(actions.searchAllProviders).toHaveBeenCalledWith('deep');
});
it('renders connected provider model picker actions', async () => {
const host = document.createElement('div');
document.body.appendChild(host);

View file

@ -128,6 +128,217 @@ describe('useRuntimeProviderManagement', () => {
});
});
it('lazy-loads provider directory and ignores stale search responses', async () => {
let resolveFirst: ((value: unknown) => void) | null = null;
const loadView = vi.fn(() =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
view: {
runtimeId: 'opencode',
title: 'OpenCode',
runtime: {
state: 'ready',
cliPath: '/opt/homebrew/bin/opencode',
version: '1.0.0',
managedProfile: 'active',
localAuth: 'synced',
},
providers: [],
defaultModel: null,
fallbackModel: null,
diagnostics: [],
},
})
);
const loadProviderDirectory = vi
.fn()
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve;
})
)
.mockResolvedValueOnce({
schemaVersion: 1,
runtimeId: 'opencode',
directory: {
runtimeId: 'opencode',
totalCount: 1,
returnedCount: 1,
query: 'deep',
filter: 'all',
limit: 50,
cursor: null,
nextCursor: null,
fetchedAt: '2026-04-25T00:00:00.000Z',
entries: [
{
providerId: 'deepseek',
displayName: 'DeepSeek',
state: 'available',
setupKind: 'available-readonly',
ownership: [],
recommended: false,
modelCount: 62,
authMethods: [],
defaultModelId: null,
sources: ['opencode-provider'],
sourceLabel: 'OpenCode catalog',
providerSource: 'models.dev',
detail: null,
actions: [],
metadata: {
hasKnownModels: true,
requiresManualConfig: false,
supportedInlineAuth: false,
},
},
],
diagnostics: [],
},
});
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
runtimeProviderManagement: {
loadView,
loadProviderDirectory,
},
} as unknown as ElectronAPI,
});
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' }));
await Promise.resolve();
});
act(() => {
actions?.openDirectory();
});
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 10));
});
await act(async () => {
await vi.waitFor(() => {
expect(loadProviderDirectory).toHaveBeenCalledTimes(1);
});
});
act(() => {
actions?.setDirectoryQuery('deep');
});
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 300));
await vi.waitFor(() => {
expect(loadProviderDirectory).toHaveBeenCalledTimes(2);
});
});
await act(async () => {
resolveFirst?.({
schemaVersion: 1,
runtimeId: 'opencode',
directory: {
runtimeId: 'opencode',
totalCount: 1,
returnedCount: 1,
query: null,
filter: 'all',
limit: 50,
cursor: null,
nextCursor: null,
fetchedAt: '2026-04-25T00:00:00.000Z',
entries: [
{
providerId: 'openrouter',
displayName: 'OpenRouter',
state: 'connected',
setupKind: 'connected',
ownership: ['managed'],
recommended: true,
modelCount: 174,
authMethods: ['api'],
defaultModelId: null,
sources: ['opencode-provider'],
sourceLabel: 'OpenCode catalog',
providerSource: 'models.dev',
detail: null,
actions: [],
metadata: {
hasKnownModels: true,
requiresManualConfig: false,
supportedInlineAuth: true,
},
},
],
diagnostics: [],
},
});
await Promise.resolve();
});
expect(loadProviderDirectory).toHaveBeenLastCalledWith({
runtimeId: 'opencode',
projectPath: '/tmp/project-a',
query: 'deep',
filter: 'all',
limit: 50,
cursor: null,
refresh: false,
});
expect(state?.directoryEntries.map((entry) => entry.providerId)).toEqual(['deepseek']);
});
it('keeps the API key draft when provider connect fails', async () => {
const connectWithApiKey = vi.fn(() =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
error: {
code: 'auth-failed',
message: 'Invalid API key',
},
})
);
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
runtimeProviderManagement: {
connectWithApiKey,
},
} as unknown as ElectronAPI,
});
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
act(() => {
actions?.startConnect('openrouter');
actions?.setApiKeyValue('sk-bad-value');
});
await act(async () => {
await Promise.resolve();
});
await act(async () => {
await actions?.submitConnect('openrouter');
});
expect(connectWithApiKey).toHaveBeenCalledWith({
runtimeId: 'opencode',
providerId: 'openrouter',
apiKey: 'sk-bad-value',
projectPath: null,
});
expect(state?.error).toBe('Invalid API key');
expect(state?.apiKeyValue).toBe('sk-bad-value');
});
it('keeps failed model probes scoped to the model result instead of a global success banner', async () => {
const modelId = 'openrouter/anthropic/claude-3.5-haiku';
const message =