fix(runtime-provider-management): harden provider directory interactions
This commit is contained in:
parent
4b77c648bf
commit
41ca9fc0cb
5 changed files with 390 additions and 3 deletions
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)?.(
|
||||
{},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Reference in a new issue