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 refreshDirectoryData = input.refresh === true;
|
||||||
const query = input.query ?? directoryQuery;
|
const query = input.query ?? directoryQuery;
|
||||||
const filter = input.filter ?? directoryFilter;
|
const filter = input.filter ?? directoryFilter;
|
||||||
const cursor = input.cursor ?? (append ? directoryNextCursor : null);
|
const cursor = input.cursor ?? null;
|
||||||
const requestSeq = directoryRequestSeq.current + 1;
|
const requestSeq = directoryRequestSeq.current + 1;
|
||||||
directoryRequestSeq.current = requestSeq;
|
directoryRequestSeq.current = requestSeq;
|
||||||
|
|
||||||
|
|
@ -319,7 +319,6 @@ export function useRuntimeProviderManagement(
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
directoryFilter,
|
directoryFilter,
|
||||||
directoryNextCursor,
|
|
||||||
directoryQuery,
|
directoryQuery,
|
||||||
directorySupported,
|
directorySupported,
|
||||||
options.enabled,
|
options.enabled,
|
||||||
|
|
@ -629,7 +628,6 @@ export function useRuntimeProviderManagement(
|
||||||
connectError instanceof Error ? connectError.message : 'Failed to connect provider'
|
connectError instanceof Error ? connectError.message : 'Failed to connect provider'
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setApiKeyValue('');
|
|
||||||
setSavingProviderId(null);
|
setSavingProviderId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -152,4 +152,61 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
|
||||||
expect.objectContaining({ cwd: '/Users/test/project' })
|
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 { registerRuntimeProviderManagementIpc } from '../../../../src/features/runtime-provider-management/main';
|
||||||
import {
|
import {
|
||||||
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
|
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
|
||||||
|
RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY,
|
||||||
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
|
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
|
||||||
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
|
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
|
||||||
} from '../../../../src/features/runtime-provider-management/contracts';
|
} from '../../../../src/features/runtime-provider-management/contracts';
|
||||||
|
|
@ -130,6 +131,22 @@ describe('registerRuntimeProviderManagementIpc', () => {
|
||||||
registerRuntimeProviderManagementIpc(ipcMain, feature);
|
registerRuntimeProviderManagementIpc(ipcMain, feature);
|
||||||
|
|
||||||
await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_VIEW)?.({}, { runtimeId: 'opencode' });
|
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)?.(
|
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();
|
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 () => {
|
it('renders connected provider model picker actions', async () => {
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
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 () => {
|
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 modelId = 'openrouter/anthropic/claude-3.5-haiku';
|
||||||
const message =
|
const message =
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue