diff --git a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts index ad174900..525f4acb 100644 --- a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts +++ b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts @@ -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); } }, diff --git a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts index 5958f248..87b2a528 100644 --- a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts +++ b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts @@ -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'); + }); }); diff --git a/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts b/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts index 6adb387b..886371a3 100644 --- a/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts +++ b/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts @@ -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)?.( {}, { diff --git a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts index 2525e887..5c938e32 100644 --- a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts +++ b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts @@ -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); diff --git a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts index fb591286..b37e4f20 100644 --- a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts +++ b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts @@ -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 =