diff --git a/AGENTS.md b/AGENTS.md index 2ebeeaa5..6caa6619 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,5 +12,16 @@ For new features: - Reference implementation: `src/features/recent-projects` - Feature-local guidance for work inside `src/features`: [src/features/CLAUDE.md](src/features/CLAUDE.md) +## Review guidelines + +- Treat regressions in agent team messaging, task lifecycle, session parsing, code review UI, and provider/runtime detection as high priority. +- Verify new medium and large features follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`, especially cross-process boundaries and public feature entrypoints. +- Check that Electron main, preload, renderer, and shared code keep their responsibilities separate and use the documented path aliases. +- Flag changes that manually concatenate agent block markers instead of using `wrapAgentBlock(text)`. +- Flag changes that can break `isMeta` semantics, chunk generation, teammate message parsing, task/subagent filtering, or structured task references. +- Ensure IPC and main-process handlers validate inputs, fail gracefully, and do not expose unsafe filesystem or process access. +- Confirm user-visible workflows have focused tests or a clear verification path when they touch parsing, persistence, IPC, Git, provider auth, or review flows. +- Prefer `pnpm` commands for verification and avoid recommending `pnpm lint:fix` unless the PR explicitly intends broad formatting changes. + Do not treat this file as a second source of truth. Keep architecture rules centralized in [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md). diff --git a/src/features/runtime-provider-management/contracts/api.ts b/src/features/runtime-provider-management/contracts/api.ts index 15b75aa1..e25f8917 100644 --- a/src/features/runtime-provider-management/contracts/api.ts +++ b/src/features/runtime-provider-management/contracts/api.ts @@ -1,14 +1,17 @@ import type { RuntimeProviderManagementConnectApiKeyInput, + RuntimeProviderManagementConnectInput, RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, + RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadViewInput, RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementModelsResponse, RuntimeProviderManagementProviderResponse, RuntimeProviderManagementSetDefaultModelInput, + RuntimeProviderManagementSetupFormResponse, RuntimeProviderManagementTestModelInput, RuntimeProviderManagementViewResponse, } from './types'; @@ -20,6 +23,12 @@ export interface RuntimeProviderManagementApi { loadProviderDirectory( input: RuntimeProviderManagementLoadDirectoryInput ): Promise; + loadSetupForm( + input: RuntimeProviderManagementLoadSetupFormInput + ): Promise; + connectProvider( + input: RuntimeProviderManagementConnectInput + ): Promise; connectWithApiKey( input: RuntimeProviderManagementConnectApiKeyInput ): Promise; diff --git a/src/features/runtime-provider-management/contracts/channels.ts b/src/features/runtime-provider-management/contracts/channels.ts index 5eada331..1cf64956 100644 --- a/src/features/runtime-provider-management/contracts/channels.ts +++ b/src/features/runtime-provider-management/contracts/channels.ts @@ -1,5 +1,7 @@ export const RUNTIME_PROVIDER_MANAGEMENT_VIEW = 'runtimeProviderManagement:view'; export const RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY = 'runtimeProviderManagement:directory'; +export const RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM = 'runtimeProviderManagement:setupForm'; +export const RUNTIME_PROVIDER_MANAGEMENT_CONNECT = 'runtimeProviderManagement:connect'; export const RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY = 'runtimeProviderManagement:connectApiKey'; export const RUNTIME_PROVIDER_MANAGEMENT_FORGET = 'runtimeProviderManagement:forget'; diff --git a/src/features/runtime-provider-management/contracts/types.ts b/src/features/runtime-provider-management/contracts/types.ts index a2b131fc..d4e592cf 100644 --- a/src/features/runtime-provider-management/contracts/types.ts +++ b/src/features/runtime-provider-management/contracts/types.ts @@ -17,6 +17,55 @@ export type RuntimeProviderOwnershipDto = 'managed' | 'local' | 'env' | 'project export type RuntimeProviderAuthMethodDto = 'api' | 'oauth' | 'wellknown'; +export type RuntimeProviderSetupMethodDto = 'api' | 'oauth' | 'manual'; + +export type RuntimeProviderSetupPromptTypeDto = 'text' | 'select'; + +export interface RuntimeProviderSetupPromptOptionDto { + label: string; + value: string; + hint: string | null; +} + +export interface RuntimeProviderSetupPromptConditionDto { + key: string; + op: string; + value: string; +} + +export interface RuntimeProviderSetupPromptDto { + key: string; + type: RuntimeProviderSetupPromptTypeDto; + label: string; + placeholder: string | null; + required: boolean; + secret: boolean; + options: readonly RuntimeProviderSetupPromptOptionDto[]; + when: RuntimeProviderSetupPromptConditionDto | null; +} + +export type RuntimeProviderSetupFormSourceDto = 'opencode-auth' | 'curated' | 'oauth' | 'manual'; + +export interface RuntimeProviderSetupFormDto { + runtimeId: RuntimeProviderManagementRuntimeId; + providerId: string; + displayName: string; + method: RuntimeProviderSetupMethodDto; + supported: boolean; + title: string; + description: string | null; + submitLabel: string; + disabledReason: string | null; + source: RuntimeProviderSetupFormSourceDto; + secret: { + key: 'key'; + label: string; + placeholder: string | null; + required: boolean; + } | null; + prompts: readonly RuntimeProviderSetupPromptDto[]; +} + export type RuntimeProviderActionIdDto = | 'connect' | 'use' @@ -165,6 +214,13 @@ export interface RuntimeProviderManagementProviderResponse { error?: RuntimeProviderManagementErrorDto; } +export interface RuntimeProviderManagementSetupFormResponse { + schemaVersion: 1; + runtimeId: RuntimeProviderManagementRuntimeId; + setupForm?: RuntimeProviderSetupFormDto; + error?: RuntimeProviderManagementErrorDto; +} + export type RuntimeProviderModelAvailabilityDto = | 'available' | 'unavailable' @@ -235,6 +291,21 @@ export interface RuntimeProviderManagementConnectApiKeyInput { projectPath?: string | null; } +export interface RuntimeProviderManagementLoadSetupFormInput { + runtimeId: RuntimeProviderManagementRuntimeId; + providerId: string; + projectPath?: string | null; +} + +export interface RuntimeProviderManagementConnectInput { + runtimeId: RuntimeProviderManagementRuntimeId; + providerId: string; + method: RuntimeProviderSetupMethodDto; + apiKey?: string | null; + metadata?: Record | null; + projectPath?: string | null; +} + export interface RuntimeProviderManagementForgetInput { runtimeId: RuntimeProviderManagementRuntimeId; providerId: string; diff --git a/src/features/runtime-provider-management/core/application/runtimeProviderManagementUseCases.ts b/src/features/runtime-provider-management/core/application/runtimeProviderManagementUseCases.ts index 49ddd890..bad1c703 100644 --- a/src/features/runtime-provider-management/core/application/runtimeProviderManagementUseCases.ts +++ b/src/features/runtime-provider-management/core/application/runtimeProviderManagementUseCases.ts @@ -1,15 +1,18 @@ import type { RuntimeProviderManagementPort } from './RuntimeProviderManagementPort'; import type { RuntimeProviderManagementConnectApiKeyInput, + RuntimeProviderManagementConnectInput, RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, + RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadViewInput, RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementModelsResponse, RuntimeProviderManagementProviderResponse, RuntimeProviderManagementSetDefaultModelInput, + RuntimeProviderManagementSetupFormResponse, RuntimeProviderManagementTestModelInput, RuntimeProviderManagementViewResponse, } from '@features/runtime-provider-management/contracts'; @@ -28,6 +31,20 @@ export function loadRuntimeProviderDirectory( return port.loadProviderDirectory(input); } +export function loadRuntimeProviderSetupForm( + port: RuntimeProviderManagementPort, + input: RuntimeProviderManagementLoadSetupFormInput +): Promise { + return port.loadSetupForm(input); +} + +export function connectRuntimeProvider( + port: RuntimeProviderManagementPort, + input: RuntimeProviderManagementConnectInput +): Promise { + return port.connectProvider(input); +} + export function connectRuntimeProviderWithApiKey( port: RuntimeProviderManagementPort, input: RuntimeProviderManagementConnectApiKeyInput diff --git a/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts b/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts index dab5396b..bc52cc00 100644 --- a/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts +++ b/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts @@ -1,9 +1,11 @@ import { + RUNTIME_PROVIDER_MANAGEMENT_CONNECT, RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY, RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY, RUNTIME_PROVIDER_MANAGEMENT_FORGET, RUNTIME_PROVIDER_MANAGEMENT_MODELS, RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL, + RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM, RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL, RUNTIME_PROVIDER_MANAGEMENT_VIEW, } from '@features/runtime-provider-management/contracts'; @@ -12,15 +14,18 @@ import { createLogger } from '@shared/utils/logger'; import type { RuntimeProviderManagementFeatureFacade } from '../../composition/createRuntimeProviderManagementFeature'; import type { RuntimeProviderManagementConnectApiKeyInput, + RuntimeProviderManagementConnectInput, RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, + RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadViewInput, RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementModelsResponse, RuntimeProviderManagementProviderResponse, RuntimeProviderManagementSetDefaultModelInput, + RuntimeProviderManagementSetupFormResponse, RuntimeProviderManagementTestModelInput, RuntimeProviderManagementViewResponse, } from '@features/runtime-provider-management/contracts'; @@ -78,6 +83,55 @@ export function registerRuntimeProviderManagementIpc( } ); + ipcMain.handle( + RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM, + async ( + _event, + input: RuntimeProviderManagementLoadSetupFormInput + ): Promise => { + try { + return await feature.loadSetupForm(input); + } catch (error) { + logger.error('Failed to load runtime provider setup form', error); + return { + schemaVersion: 1, + runtimeId: input.runtimeId, + error: { + code: 'runtime-unhealthy', + message: error instanceof Error ? error.message : 'Failed to load provider setup form', + recoverable: true, + }, + }; + } + } + ); + + ipcMain.handle( + RUNTIME_PROVIDER_MANAGEMENT_CONNECT, + async ( + _event, + input: RuntimeProviderManagementConnectInput + ): Promise => { + try { + return await feature.connectProvider(input); + } catch (error) { + logger.error( + 'Failed to connect runtime provider', + error instanceof Error ? error.name : error + ); + return { + schemaVersion: 1, + runtimeId: input.runtimeId, + error: { + code: 'auth-failed', + message: 'Failed to connect provider', + recoverable: true, + }, + }; + } + } + ); + ipcMain.handle( RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY, async ( @@ -200,6 +254,8 @@ export function registerRuntimeProviderManagementIpc( export function removeRuntimeProviderManagementIpc(ipcMain: IpcMain): void { ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_VIEW); ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY); + ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM); + ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_CONNECT); ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY); ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_FORGET); ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_MODELS); diff --git a/src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts b/src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts index 3473d19a..02eda5a0 100644 --- a/src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts +++ b/src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts @@ -4,15 +4,18 @@ import type { RuntimeProviderManagementPort } from '../../core/application'; import type { RuntimeProviderManagementApi, RuntimeProviderManagementConnectApiKeyInput, + RuntimeProviderManagementConnectInput, RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, + RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadViewInput, RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementModelsResponse, RuntimeProviderManagementProviderResponse, RuntimeProviderManagementSetDefaultModelInput, + RuntimeProviderManagementSetupFormResponse, RuntimeProviderManagementTestModelInput, RuntimeProviderManagementViewResponse, } from '@features/runtime-provider-management/contracts'; @@ -33,6 +36,12 @@ export function createRuntimeProviderManagementFeature( loadProviderDirectory: ( input: RuntimeProviderManagementLoadDirectoryInput ): Promise => port.loadProviderDirectory(input), + loadSetupForm: ( + input: RuntimeProviderManagementLoadSetupFormInput + ): Promise => port.loadSetupForm(input), + connectProvider: ( + input: RuntimeProviderManagementConnectInput + ): Promise => port.connectProvider(input), connectWithApiKey: ( input: RuntimeProviderManagementConnectApiKeyInput ): Promise => port.connectWithApiKey(input), diff --git a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts index 95b74222..175f5989 100644 --- a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts +++ b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts @@ -6,10 +6,12 @@ import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import type { RuntimeProviderManagementApi, RuntimeProviderManagementConnectApiKeyInput, + RuntimeProviderManagementConnectInput, RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementErrorDto, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, + RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadViewInput, RuntimeProviderManagementModelsResponse, @@ -17,6 +19,7 @@ import type { RuntimeProviderManagementProviderResponse, RuntimeProviderManagementRuntimeId, RuntimeProviderManagementSetDefaultModelInput, + RuntimeProviderManagementSetupFormResponse, RuntimeProviderManagementTestModelInput, RuntimeProviderManagementViewResponse, } from '@features/runtime-provider-management/contracts'; @@ -30,6 +33,7 @@ type RuntimeProviderManagementErrorResponse = | RuntimeProviderManagementViewResponse | RuntimeProviderManagementDirectoryResponse | RuntimeProviderManagementProviderResponse + | RuntimeProviderManagementSetupFormResponse | RuntimeProviderManagementModelsResponse | RuntimeProviderManagementModelTestResponse; @@ -288,6 +292,121 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv } } + async loadSetupForm( + input: RuntimeProviderManagementLoadSetupFormInput + ): Promise { + const { binaryPath, env } = await resolveCliEnv(); + if (!binaryPath) { + return errorResponse( + input.runtimeId, + 'Multimodel runtime binary was not found.', + 'runtime-missing' + ); + } + + const projectPath = normalizeProjectPath(input.projectPath); + try { + const { stdout } = await execCli( + binaryPath, + appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'setup-form', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--json', + ], + projectPath + ), + runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) + ); + return extractJsonObject(stdout); + } catch (error) { + const response = + extractJsonObjectFromError(error); + if (response) { + return response; + } + return errorResponse( + input.runtimeId, + normalizeCommandFailure(error) + ); + } + } + + async connectProvider( + input: RuntimeProviderManagementConnectInput + ): Promise { + const { binaryPath, env } = await resolveCliEnv(); + if (!binaryPath) { + return errorResponse( + input.runtimeId, + 'Multimodel runtime binary was not found.', + 'runtime-missing' + ); + } + + const projectPath = normalizeProjectPath(input.projectPath); + try { + const child = spawnCli( + binaryPath, + appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'connect', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--stdin-json', + '--json', + ], + projectPath + ), + runtimeProviderCommandOptions( + { + env, + stdio: 'pipe' as const, + }, + projectPath + ) + ) as ChildProcessWithoutNullStreams; + const result = await collectSpawnOutput( + child, + JSON.stringify({ + method: input.method, + apiKey: input.apiKey ?? null, + metadata: input.metadata ?? {}, + }) + ); + if (result.code === 0) { + return extractJsonObject(result.stdout); + } + + try { + return extractJsonObject(result.stdout); + } catch { + return errorResponse( + input.runtimeId, + `Runtime provider connect command failed with exit code ${String(result.code ?? 'unknown')}.` + ); + } + } catch (error) { + const response = extractJsonObjectFromError(error); + if (response) { + return response; + } + return errorResponse( + input.runtimeId, + normalizeCommandFailure(error) + ); + } + } + async connectWithApiKey( input: RuntimeProviderManagementConnectApiKeyInput ): Promise { diff --git a/src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts b/src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts index cd019414..9bc0c4ac 100644 --- a/src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts +++ b/src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts @@ -1,9 +1,11 @@ import { + RUNTIME_PROVIDER_MANAGEMENT_CONNECT, RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY, RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY, RUNTIME_PROVIDER_MANAGEMENT_FORGET, RUNTIME_PROVIDER_MANAGEMENT_MODELS, RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL, + RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM, RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL, RUNTIME_PROVIDER_MANAGEMENT_VIEW, type RuntimeProviderManagementApi, @@ -11,15 +13,18 @@ import { import type { RuntimeProviderManagementConnectApiKeyInput, + RuntimeProviderManagementConnectInput, RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, + RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadViewInput, RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementModelsResponse, RuntimeProviderManagementProviderResponse, RuntimeProviderManagementSetDefaultModelInput, + RuntimeProviderManagementSetupFormResponse, RuntimeProviderManagementTestModelInput, RuntimeProviderManagementViewResponse, } from '@features/runtime-provider-management/contracts'; @@ -37,6 +42,14 @@ export function createRuntimeProviderManagementBridge( input: RuntimeProviderManagementLoadDirectoryInput ): Promise => ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY, input), + loadSetupForm: ( + input: RuntimeProviderManagementLoadSetupFormInput + ): Promise => + ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM, input), + connectProvider: ( + input: RuntimeProviderManagementConnectInput + ): Promise => + ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_CONNECT, input), connectWithApiKey: ( input: RuntimeProviderManagementConnectApiKeyInput ): Promise => diff --git a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts index 61edae80..2366ff03 100644 --- a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts +++ b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts @@ -16,6 +16,7 @@ import type { RuntimeProviderManagementViewDto, RuntimeProviderModelDto, RuntimeProviderModelTestResultDto, + RuntimeProviderSetupFormDto, } from '@features/runtime-provider-management/contracts'; interface UseRuntimeProviderManagementOptions { @@ -45,6 +46,11 @@ export interface RuntimeProviderManagementState { directorySelectedProviderId: string | null; directorySupported: boolean; activeFormProviderId: string | null; + setupForm: RuntimeProviderSetupFormDto | null; + setupFormLoading: boolean; + setupFormError: string | null; + setupSubmitError: string | null; + setupMetadata: Readonly>; apiKeyValue: string; modelPickerProviderId: string | null; modelPickerMode: RuntimeProviderModelPickerMode | null; @@ -77,6 +83,7 @@ export interface RuntimeProviderManagementActions { startConnect: (providerId: string) => void; cancelConnect: () => void; setApiKeyValue: (value: string) => void; + setSetupMetadataValue: (key: string, value: string) => void; submitConnect: (providerId: string) => Promise; forgetProvider: (providerId: string) => Promise; openModelPicker: (providerId: string, mode: RuntimeProviderModelPickerMode) => void; @@ -186,6 +193,11 @@ export function useRuntimeProviderManagement( ); const [directorySupported, setDirectorySupported] = useState(true); const [activeFormProviderId, setActiveFormProviderId] = useState(null); + const [setupForm, setSetupForm] = useState(null); + const [setupFormLoading, setSetupFormLoading] = useState(false); + const [setupFormError, setSetupFormError] = useState(null); + const [setupSubmitError, setSetupSubmitError] = useState(null); + const [setupMetadata, setSetupMetadata] = useState>({}); const [apiKeyValue, setApiKeyValue] = useState(''); const [modelPickerProviderId, setModelPickerProviderId] = useState(null); const [modelPickerMode, setModelPickerMode] = useState( @@ -206,6 +218,7 @@ export function useRuntimeProviderManagement( const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const directoryRequestSeq = useRef(0); + const setupFormRequestSeq = useRef(0); const refresh = useCallback(async (): Promise => { if (!options.enabled) { @@ -342,6 +355,11 @@ export function useRuntimeProviderManagement( setDirectoryLoaded(false); setDirectorySelectedProviderId(null); setApiKeyValue(''); + setSetupMetadata({}); + setSetupForm(null); + setSetupFormLoading(false); + setSetupFormError(null); + setSetupSubmitError(null); setActiveFormProviderId(null); const reset = resetModelState(); setModelPickerProviderId(reset.modelPickerProviderId); @@ -530,6 +548,11 @@ export function useRuntimeProviderManagement( setDirectorySelectedProviderId(providerId); setSelectedProviderId(providerId); setActiveFormProviderId(null); + setSetupForm(null); + setSetupFormError(null); + setSetupSubmitError(null); + setSetupMetadata({}); + setApiKeyValue(''); const compactProvider = view?.providers.find( (provider) => provider.providerId === providerId @@ -561,15 +584,60 @@ export function useRuntimeProviderManagement( setDirectoryNextCursor(null); }, []); - const startConnect = useCallback((providerId: string): void => { - setSelectedProviderId(providerId); - setActiveFormProviderId(providerId); - setModelPickerProviderId(null); - setModelPickerMode(null); - setApiKeyValue(''); - setError(null); - setSuccessMessage(null); - }, []); + const startConnect = useCallback( + (providerId: string): void => { + setSelectedProviderId(providerId); + setActiveFormProviderId(providerId); + setModelPickerProviderId(null); + setModelPickerMode(null); + setApiKeyValue(''); + setSetupMetadata({}); + setSetupForm(null); + setSetupFormError(null); + setSetupSubmitError(null); + setSetupFormLoading(true); + setError(null); + setSuccessMessage(null); + const requestSeq = setupFormRequestSeq.current + 1; + setupFormRequestSeq.current = requestSeq; + + void withUiTimeout( + api.runtimeProviderManagement.loadSetupForm({ + runtimeId: options.runtimeId, + providerId, + projectPath: options.projectPath ?? null, + }), + 'Provider setup form load timed out' + ) + .then((response) => { + if (setupFormRequestSeq.current !== requestSeq) { + return; + } + if (response.error) { + setSetupFormError(response.error.message); + return; + } + setSetupForm(response.setupForm ?? null); + if (!response.setupForm) { + setSetupFormError('Provider setup form response was empty'); + } + }) + .catch((setupError) => { + if (setupFormRequestSeq.current !== requestSeq) { + return; + } + setSetupFormError( + setupError instanceof Error ? setupError.message : 'Failed to load provider setup form' + ); + }) + .finally(() => { + if (setupFormRequestSeq.current === requestSeq) { + setSetupFormLoading(false); + } + }); + }, + [options.projectPath, options.runtimeId] + ); const updateProviderQuery = useCallback( (value: string): void => { @@ -584,43 +652,79 @@ export function useRuntimeProviderManagement( ); const cancelConnect = useCallback((): void => { + setupFormRequestSeq.current += 1; setActiveFormProviderId(null); setApiKeyValue(''); + setSetupMetadata({}); + setSetupForm(null); + setSetupFormLoading(false); + setSetupFormError(null); + setSetupSubmitError(null); setError(null); }, []); + const updateApiKeyValue = useCallback((value: string): void => { + setApiKeyValue(value); + setSetupSubmitError(null); + }, []); + + const setSetupMetadataValue = useCallback((key: string, value: string): void => { + setSetupMetadata((current) => ({ + ...current, + [key]: value, + })); + setSetupSubmitError(null); + }, []); + const submitConnect = useCallback( async (providerId: string): Promise => { const apiKey = apiKeyValue.trim(); if (!apiKey) { - setError('API key is required'); + setSetupSubmitError('API key is required'); + return; + } + if (!setupForm) { + setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded'); + return; + } + if (!setupForm.supported) { + setSetupSubmitError( + setupForm.disabledReason ?? 'Provider setup is not supported in the app' + ); return; } setSavingProviderId(providerId); setError(null); + setSetupSubmitError(null); setSuccessMessage(null); try { const response = await withUiTimeout( - api.runtimeProviderManagement.connectWithApiKey({ + api.runtimeProviderManagement.connectProvider({ runtimeId: options.runtimeId, providerId, + method: setupForm.method, apiKey, + metadata: setupMetadata, projectPath: options.projectPath ?? null, }), 'Provider connect timed out' ); if (response.error) { - setError(response.error.message); + setSetupSubmitError(response.error.message); return; } if (response.provider) { setView((current) => replaceProvider(current, response.provider!)); } setActiveFormProviderId(null); - setSuccessMessage('Provider connected'); + setSuccessMessage(null); setSavingProviderId(null); setApiKeyValue(''); + setSetupMetadata({}); + setSetupForm(null); + setSetupFormError(null); + setSetupSubmitError(null); void Promise.resolve(options.onProviderChanged?.()) .then(() => refresh()) .then(() => loadDirectoryPage({ refresh: true, cursor: null })) @@ -630,14 +734,14 @@ export function useRuntimeProviderManagement( ); }); } catch (connectError) { - setError( + setSetupSubmitError( connectError instanceof Error ? connectError.message : 'Failed to connect provider' ); } finally { setSavingProviderId(null); } }, - [apiKeyValue, loadDirectoryPage, options, refresh] + [apiKeyValue, loadDirectoryPage, options, refresh, setupForm, setupFormError, setupMetadata] ); const forgetProvider = useCallback( @@ -806,7 +910,14 @@ export function useRuntimeProviderManagement( ); const selectProvider = useCallback((providerId: string): void => { + setupFormRequestSeq.current += 1; setSelectedProviderId(providerId); + setActiveFormProviderId(null); + setSetupForm(null); + setSetupFormError(null); + setSetupSubmitError(null); + setSetupMetadata({}); + setApiKeyValue(''); }, []); const state = useMemo( @@ -828,6 +939,11 @@ export function useRuntimeProviderManagement( directorySelectedProviderId, directorySupported, activeFormProviderId, + setupForm, + setupFormLoading, + setupFormError, + setupSubmitError, + setupMetadata, apiKeyValue, modelPickerProviderId, modelPickerMode, @@ -847,6 +963,11 @@ export function useRuntimeProviderManagement( [ activeFormProviderId, apiKeyValue, + setupForm, + setupFormError, + setupFormLoading, + setupSubmitError, + setupMetadata, directoryEntries, directoryError, directoryFilter, @@ -894,7 +1015,8 @@ export function useRuntimeProviderManagement( searchAllProviders, startConnect, cancelConnect, - setApiKeyValue, + setApiKeyValue: updateApiKeyValue, + setSetupMetadataValue, submitConnect, forgetProvider, openModelPicker, @@ -920,9 +1042,11 @@ export function useRuntimeProviderManagement( selectProvider, setDefaultModel, setDirectoryFilter, + setSetupMetadataValue, startConnect, submitConnect, testModel, + updateApiKeyValue, updateDirectoryQuery, updateProviderQuery, useModelForNewTeams, diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx index c8b8c57c..eaa85a52 100644 --- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx +++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx @@ -5,6 +5,13 @@ import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@renderer/components/ui/select'; import { compareOpenCodeTeamModelRecommendations, getOpenCodeTeamModelRecommendation, @@ -41,6 +48,7 @@ import type { RuntimeProviderDirectoryFilterDto, RuntimeProviderModelDto, RuntimeProviderModelTestResultDto, + RuntimeProviderSetupPromptDto, } from '@features/runtime-provider-management/contracts'; import type { CSSProperties, JSX, KeyboardEvent } from 'react'; @@ -64,7 +72,6 @@ interface ProviderRowProps { readonly state: RuntimeProviderManagementState; readonly active: boolean; readonly formOpen: boolean; - readonly apiKeyValue: string; readonly busy: boolean; readonly disabled: boolean; readonly actions: RuntimeProviderManagementActions; @@ -93,7 +100,7 @@ function formatDirectorySetupKind(provider: RuntimeProviderDirectoryEntryDto): s case 'connect-api-key': return 'Connect'; case 'configure-manually': - return 'Configure manually'; + return 'Manual setup required'; case 'requires-environment': return 'Requires environment'; case 'available-readonly': @@ -139,7 +146,7 @@ function directoryEntryMatchesQuery( function directorySetupKindClassName(provider: RuntimeProviderDirectoryEntryDto): string { switch (provider.setupKind) { case 'connected': - return 'border-emerald-400/35 bg-emerald-400/10 text-emerald-200'; + return 'border-emerald-300/70 bg-emerald-600 text-emerald-50'; case 'connect-api-key': case 'available-readonly': return 'border-sky-400/30 bg-sky-400/10 text-sky-200'; @@ -189,12 +196,193 @@ function stateStyle(provider: RuntimeProviderConnectionDto): CSSProperties | und } return { - color: '#86efac', - borderColor: 'rgba(74, 222, 128, 0.38)', - backgroundColor: 'rgba(74, 222, 128, 0.11)', + color: '#ecfdf5', + borderColor: 'rgba(134, 239, 172, 0.72)', + backgroundColor: '#16a34a', }; } +function setupPromptVisible( + prompt: RuntimeProviderSetupPromptDto, + values: Readonly> +): boolean { + if (!prompt.when) { + return true; + } + const currentValue = values[prompt.when.key] ?? ''; + switch (prompt.when.op) { + case 'eq': + return currentValue === prompt.when.value; + case 'neq': + case 'ne': + return currentValue !== prompt.when.value; + default: + return true; + } +} + +function setupFormCanSubmit(state: RuntimeProviderManagementState, providerId: string): boolean { + const form = state.setupForm?.providerId === providerId ? state.setupForm : null; + if (!form?.supported || !form.secret || !state.apiKeyValue.trim()) { + return false; + } + return form.prompts + .filter((prompt) => setupPromptVisible(prompt, state.setupMetadata)) + .every((prompt) => !prompt.required || Boolean(state.setupMetadata[prompt.key]?.trim())); +} + +function ProviderSetupFormPanel({ + provider, + state, + busy, + disabled, + actions, +}: { + readonly provider: RuntimeProviderConnectionDto; + readonly state: RuntimeProviderManagementState; + readonly busy: boolean; + readonly disabled: boolean; + readonly actions: RuntimeProviderManagementActions; +}): JSX.Element { + const form = state.setupForm?.providerId === provider.providerId ? state.setupForm : null; + const loading = state.setupFormLoading && state.activeFormProviderId === provider.providerId; + const error = state.setupFormError; + const submitError = + state.activeFormProviderId === provider.providerId ? state.setupSubmitError : null; + const canSubmit = setupFormCanSubmit(state, provider.providerId); + + return ( +
event.stopPropagation()} + > + {loading ? ( +
+ + Loading provider setup... +
+ ) : null} + + {!loading && error ? ( +
+ {error} +
+ ) : null} + + {!loading && form ? ( +
+
+
{form.title}
+ {form.description ? ( +
+ {form.description} +
+ ) : null} +
+ + {form.secret ? ( +
+ + actions.setApiKeyValue(event.target.value)} + placeholder={form.secret.placeholder ?? 'Paste API key'} + className="h-9 text-sm" + autoFocus + /> +
+ ) : null} + + {form.prompts + .filter((prompt) => setupPromptVisible(prompt, state.setupMetadata)) + .map((prompt) => ( +
+ + {prompt.type === 'select' ? ( + + ) : ( + + actions.setSetupMetadataValue(prompt.key, event.target.value) + } + placeholder={prompt.placeholder ?? undefined} + className="h-9 text-sm" + /> + )} +
+ ))} + + {form.disabledReason && !form.supported ? ( +
+ {form.disabledReason} +
+ ) : null} +
+ ) : null} + + {submitError ? ( +
+ {submitError} +
+ ) : null} + +
+ + +
+
+ ); +} + function RuntimeSummary({ state, onRefresh, @@ -457,7 +645,10 @@ function ProviderActions({ variant="outline" disabled={disabled || busy || !connect.enabled} title={connect.disabledReason ?? undefined} - onClick={onStartConnect} + onClick={(event) => { + event.stopPropagation(); + onStartConnect(); + }} > {busy ? ( @@ -478,7 +669,10 @@ function ProviderActions({ variant="ghost" disabled={disabled || busy || !forget.enabled} title={forget.disabledReason ?? undefined} - onClick={onForget} + onClick={(event) => { + event.stopPropagation(); + onForget(); + }} > {busy ? ( @@ -508,20 +702,39 @@ function ProviderRow({ state, active, formOpen, - apiKeyValue, busy, disabled, actions, }: ProviderRowProps): JSX.Element { + const connect = getProviderAction(provider, 'connect'); + const canOpenConnect = provider.state !== 'connected' && connect?.enabled === true; + const canSelectModels = provider.state === 'connected' && provider.modelCount > 0; + const clickable = !disabled && (canOpenConnect || canSelectModels); + const visuallyActive = active && (canSelectModels || formOpen); + const handleActivate = (): void => { + if (!clickable) { + return; + } + if (canOpenConnect) { + actions.startConnect(provider.providerId); + return; + } + actions.selectProvider(provider.providerId); + }; + return (
actions.selectProvider(provider.providerId)} + onClick={handleActivate} >
@@ -575,46 +788,13 @@ function ProviderRow({
{formOpen ? ( -
-
- - actions.setApiKeyValue(event.target.value)} - placeholder="Paste API key" - className="h-9 text-sm" - autoFocus - /> -
-
- - -
-
+ ) : null} {active && provider.state === 'connected' && provider.modelCount > 0 ? ( @@ -634,7 +814,6 @@ function DirectoryProviderRow({ state, active, formOpen, - apiKeyValue, disabled, busy, actions, @@ -643,7 +822,6 @@ function DirectoryProviderRow({ readonly state: RuntimeProviderManagementState; readonly active: boolean; readonly formOpen: boolean; - readonly apiKeyValue: string; readonly disabled: boolean; readonly busy: boolean; readonly actions: RuntimeProviderManagementActions; @@ -651,24 +829,42 @@ function DirectoryProviderRow({ const connect = getDirectoryAction(provider, 'connect'); const configure = getDirectoryAction(provider, 'configure'); const forget = getDirectoryAction(provider, 'forget'); + const canOpenConnect = provider.state !== 'connected' && connect?.enabled === true; + const canSelectModels = provider.state === 'connected' && provider.modelCount !== 0; + const clickable = !disabled && (canOpenConnect || canSelectModels); + const visuallyActive = active && (canSelectModels || formOpen); + const handleActivate = (): void => { + if (!clickable) { + return; + } + if (canOpenConnect) { + actions.startConnect(provider.providerId); + return; + } + actions.selectDirectoryProvider(provider.providerId); + }; return (
actions.selectDirectoryProvider(provider.providerId)} + onClick={handleActivate} onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== ' ') { + if (!clickable || (event.key !== 'Enter' && event.key !== ' ')) { return; } event.preventDefault(); - actions.selectDirectoryProvider(provider.providerId); + handleActivate(); }} >
@@ -760,47 +956,13 @@ function DirectoryProviderRow({
{formOpen ? ( -
event.stopPropagation()} - > -
- - actions.setApiKeyValue(event.target.value)} - placeholder="Paste API key" - className="h-9 text-sm" - autoFocus - /> -
-
- - -
-
+ ) : null} {active && provider.state === 'connected' && provider.modelCount !== 0 ? ( @@ -909,7 +1071,6 @@ function ProviderDirectoryPanel({ state={state} active={active} formOpen={state.activeFormProviderId === provider.providerId} - apiKeyValue={state.apiKeyValue} disabled={disabled || state.directoryLoading} busy={state.savingProviderId === provider.providerId} actions={actions} @@ -962,15 +1123,20 @@ function ModelBadges({ - {modelRecommendation.level === 'recommended' ? ( - - ) : ( + {modelRecommendation.level === 'not-recommended' || + modelRecommendation.level === 'unavailable-in-opencode' ? ( + ) : ( + )} {modelRecommendation.label} @@ -1036,6 +1202,7 @@ function ModelRow({ if (event.key !== 'Enter' && event.key !== ' ') { return; } + event.stopPropagation(); event.preventDefault(); chooseModel(); }; @@ -1047,7 +1214,10 @@ function ModelRow({ aria-pressed={selected} data-testid={`runtime-provider-model-row-${model.modelId}`} className="cursor-pointer rounded-md border px-3 py-2.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45" - onClick={chooseModel} + onClick={(event) => { + event.stopPropagation(); + chooseModel(); + }} onKeyDown={handleKeyDown} style={{ borderColor: selected ? 'rgba(96, 165, 250, 0.45)' : 'var(--color-border-subtle)', @@ -1146,13 +1316,19 @@ function ProviderModelList({ value={state.modelQuery} disabled={disabled || state.modelsLoading} onChange={(event) => actions.setModelQuery(event.target.value)} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} placeholder="Search models" className="h-10 pl-10 pr-3 text-sm leading-5" style={{ paddingLeft: 42 }} />
{hasRecommendedModels ? ( -
+
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > = { paths: [{ d: NVIDIA_PATH }], }, opencode: { - kind: 'image', - src: opencodeIconUrl, + kind: 'svg', + viewBox: '0 0 24 24', background: 'rgba(148, 163, 184, 0.12)', border: 'rgba(148, 163, 184, 0.32)', + color: '#94A3B8', + paths: [{ d: OPENCODE_PATH }], }, openai: { kind: 'svg', @@ -389,6 +393,111 @@ const BRAND_ALIASES: Record = { vertex: 'google-vertex', }; +// Verified against https://models.dev/logos/{provider}.svg by comparing each +// current provider logo to the Models.dev default fallback SVG. +const MODELS_DEV_LOGO_PROVIDER_IDS = new Set([ + '302ai', + 'abacus', + 'aihubmix', + 'alibaba', + 'alibaba-cn', + 'alibaba-coding-plan', + 'alibaba-coding-plan-cn', + 'amazon-bedrock', + 'anthropic', + 'azure', + 'bailing', + 'baseten', + 'berget', + 'cerebras', + 'cloudferro-sherlock', + 'cloudflare-ai-gateway', + 'cloudflare-workers-ai', + 'cohere', + 'deepinfra', + 'deepseek', + 'digitalocean', + 'dinference', + 'drun', + 'evroc', + 'fastrouter', + 'fireworks-ai', + 'firmware', + 'friendli', + 'github-copilot', + 'github-models', + 'gitlab', + 'google', + 'google-vertex', + 'groq', + 'helicone', + 'hpc-ai', + 'huggingface', + 'iflowcn', + 'inception', + 'inference', + 'io-net', + 'jiekou', + 'kilo', + 'kimi-for-coding', + 'kuae-cloud-coding-plan', + 'llama', + 'llmgateway', + 'lucidquery', + 'meganova', + 'minimax', + 'minimax-cn', + 'mistral', + 'mixlayer', + 'moark', + 'modelscope', + 'moonshotai', + 'moonshotai-cn', + 'nano-gpt', + 'nebius', + 'nova', + 'novita-ai', + 'nvidia', + 'ollama-cloud', + 'openai', + 'opencode', + 'opencode-go', + 'openrouter', + 'ovhcloud', + 'perplexity', + 'perplexity-agent', + 'poe', + 'privatemode-ai', + 'qihang-ai', + 'qiniu-ai', + 'regolo-ai', + 'scaleway', + 'siliconflow', + 'siliconflow-cn', + 'stackit', + 'submodel', + 'tencent-coding-plan', + 'tencent-tokenhub', + 'the-grid-ai', + 'togetherai', + 'v0', + 'venice', + 'vercel', + 'vivgrid', + 'vultr', + 'wafer.ai', + 'xai', + 'xiaomi', + 'xiaomi-token-plan-ams', + 'xiaomi-token-plan-cn', + 'xiaomi-token-plan-sgp', + 'zai', + 'zai-coding-plan', + 'zenmux', + 'zhipuai', + 'zhipuai-coding-plan', +]); + function normalizeProviderKey(value: string): string { return value .trim() @@ -397,26 +506,80 @@ function normalizeProviderKey(value: string): string { .replace(/(?:^-)|(?:-$)/g, ''); } -function getBrandIconKey(provider: ProviderBrand): string | null { +function normalizeModelsDevProviderId(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/(?:^-)|(?:-$)/g, ''); +} + +function hasLocalGraphicIcon(key: string): boolean { + const descriptor = BRAND_ICONS[key]; + return Boolean(descriptor && descriptor.kind !== 'letters'); +} + +function hasLetterIcon(key: string): boolean { + return Boolean(BRAND_ICONS[key]?.kind === 'letters' || LETTER_BRANDS[key]); +} + +function getLocalBrandIconKey(provider: ProviderBrand): string | null { const providerId = normalizeProviderKey(provider.providerId); const displayName = normalizeProviderKey(provider.displayName); const aliasedProviderId = BRAND_ALIASES[providerId] ?? providerId; const aliasedDisplayName = BRAND_ALIASES[displayName] ?? displayName; - const direct = BRAND_ICONS[aliasedProviderId] + const direct = hasLocalGraphicIcon(aliasedProviderId) ? aliasedProviderId - : LETTER_BRANDS[aliasedProviderId] - ? aliasedProviderId - : BRAND_ICONS[aliasedDisplayName] - ? aliasedDisplayName - : LETTER_BRANDS[aliasedDisplayName] - ? aliasedDisplayName - : null; + : hasLocalGraphicIcon(aliasedDisplayName) + ? aliasedDisplayName + : null; if (direct) { return direct; } for (const [needle, iconKey] of Object.entries(BRAND_ALIASES)) { - if (displayName.includes(needle) || providerId.includes(needle)) { + if ( + (displayName.includes(needle) || providerId.includes(needle)) && + hasLocalGraphicIcon(iconKey) + ) { + return iconKey; + } + } + + return null; +} + +function getModelsDevLogoKey(provider: ProviderBrand): string | null { + const providerId = normalizeProviderKey(provider.providerId); + const displayName = normalizeProviderKey(provider.displayName); + const candidates = [ + normalizeModelsDevProviderId(provider.providerId), + BRAND_ALIASES[providerId], + providerId, + normalizeModelsDevProviderId(provider.displayName), + BRAND_ALIASES[displayName], + displayName, + ].filter((candidate): candidate is string => Boolean(candidate)); + + return candidates.find((candidate) => MODELS_DEV_LOGO_PROVIDER_IDS.has(candidate)) ?? null; +} + +function getLetterBrandIconKey(provider: ProviderBrand): string | null { + const providerId = normalizeProviderKey(provider.providerId); + const displayName = normalizeProviderKey(provider.displayName); + const aliasedProviderId = BRAND_ALIASES[providerId] ?? providerId; + const aliasedDisplayName = BRAND_ALIASES[displayName] ?? displayName; + const direct = hasLetterIcon(aliasedProviderId) + ? aliasedProviderId + : hasLetterIcon(aliasedDisplayName) + ? aliasedDisplayName + : null; + if (direct) { + return direct; + } + + for (const [needle, iconKey] of Object.entries(BRAND_ALIASES)) { + if ((displayName.includes(needle) || providerId.includes(needle)) && hasLetterIcon(iconKey)) { return iconKey; } } @@ -436,42 +599,78 @@ function fallbackDescriptor(provider: ProviderBrand): BrandIconDescriptor { } function descriptorFor(provider: ProviderBrand): BrandIconDescriptor { - const key = getBrandIconKey(provider); - return key - ? (BRAND_ICONS[key] ?? LETTER_BRANDS[key] ?? fallbackDescriptor(provider)) - : fallbackDescriptor(provider); + const localKey = getLocalBrandIconKey(provider); + if (localKey) { + return BRAND_ICONS[localKey] ?? fallbackDescriptor(provider); + } + + const modelsDevKey = getModelsDevLogoKey(provider); + if (modelsDevKey) { + return { + kind: 'image', + src: `https://models.dev/logos/${encodeURIComponent(modelsDevKey)}.svg`, + background: 'rgba(148, 163, 184, 0.12)', + border: 'rgba(148, 163, 184, 0.28)', + }; + } + + const letterKey = getLetterBrandIconKey(provider); + if (letterKey) { + return LETTER_BRANDS[letterKey] ?? fallbackDescriptor(provider); + } + + return fallbackDescriptor(provider); } function shellStyle(descriptor: BrandIconDescriptor): CSSProperties { - return { - backgroundColor: descriptor.background, - borderColor: descriptor.border, - color: descriptor.kind === 'image' ? undefined : descriptor.color, - }; + const style: CSSProperties & Record = {}; + + style['--runtime-provider-brand-fallback-background'] = descriptor.background; + style['--runtime-provider-brand-fallback-border'] = descriptor.border; + if (descriptor.kind !== 'image') { + style['--runtime-provider-brand-fallback-color'] = descriptor.color; + } + + return style; } export function ProviderBrandIcon({ provider }: { readonly provider: ProviderBrand }): JSX.Element { const descriptor = descriptorFor(provider); + const [imageFailed, setImageFailed] = useState(false); + const imageSrc = descriptor.kind === 'image' ? descriptor.src : null; + + useEffect(() => { + setImageFailed(false); + }, [imageSrc]); + + const renderedDescriptor = + descriptor.kind === 'image' && imageFailed ? fallbackDescriptor(provider) : descriptor; return ( ); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 8a8f818b..3f027eba 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -5081,10 +5081,7 @@ export class TeamProvisioningService { }); const records = await ledger.list().catch(() => []); for (const record of records) { - if ( - record.status === 'failed_terminal' || - (record.status === 'responded' && record.inboxReadCommittedAt) - ) { + if (record.status === 'failed_terminal' || record.status === 'responded') { continue; } const nextAttemptMs = record.nextAttemptAt ? Date.parse(record.nextAttemptAt) : NaN; @@ -5340,13 +5337,37 @@ export class TeamProvisioningService { ? this.createOpenCodePromptDeliveryLedger(teamName, laneIdentity.laneId) : null; const now = nowIso(); - const active = ledger + let active = ledger ? await ledger.getActiveForMember({ teamName, memberName: canonicalMemberName, laneId: laneIdentity.laneId, }) : null; + if (active && active.inboxMessageId !== messageId && ledger) { + const proof = await this.applyOpenCodeVisibleDestinationProof({ + ledger, + ledgerRecord: active, + teamName, + replyRecipient: active.replyRecipient, + memberName: canonicalMemberName, + }); + active = proof.ledgerRecord; + const activeReadAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({ + responseState: active.responseState, + actionMode: active.actionMode ?? undefined, + taskRefs: active.taskRefs, + visibleReply: proof.visibleReply, + ledgerRecord: active, + }); + if (activeReadAllowed) { + this.logOpenCodePromptDeliveryEvent('opencode_prompt_delivery_response_observed', active, { + visibleReplySemanticallySufficient: true, + unblockedNextDelivery: true, + }); + active = null; + } + } if (active && active.inboxMessageId !== messageId) { const activeDueMs = active.nextAttemptAt ? Date.parse(active.nextAttemptAt) : NaN; this.scheduleOpenCodePromptDeliveryWatchdog({ diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts index 6db7b78f..0cb284d0 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts @@ -786,10 +786,7 @@ function isTaskRefArray(value: unknown): value is TaskRef[] { } function isTerminalForAutomaticSelection(record: OpenCodePromptDeliveryLedgerRecord): boolean { - return ( - record.status === 'failed_terminal' || - (record.status === 'responded' && record.inboxReadCommittedAt != null) - ); + return record.status === 'failed_terminal' || record.status === 'responded'; } function compareOpenCodePromptDeliveryDueOrder( diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 7e3a30d7..9d1b9e7d 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1207,6 +1207,24 @@ export class HttpAPIClient implements ElectronAPI { recoverable: true, }, }), + loadSetupForm: async (input) => ({ + schemaVersion: 1, + runtimeId: input.runtimeId, + error: { + code: 'runtime-unhealthy', + message: 'Runtime provider management is not available in browser mode.', + recoverable: true, + }, + }), + connectProvider: async (input) => ({ + schemaVersion: 1, + runtimeId: input.runtimeId, + error: { + code: 'unsupported-action', + message: 'Runtime provider management is not available in browser mode.', + recoverable: true, + }, + }), connectWithApiKey: async (input) => ({ schemaVersion: 1, runtimeId: input.runtimeId, diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 76da6132..25e41aa7 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -537,15 +537,20 @@ export const TeamModelSelector: React.FC = ({ className={cn( 'inline-flex items-center justify-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold', modelRecommendation.level === 'recommended' - ? 'bg-amber-300/12 border-amber-300/35 text-amber-200' - : 'border-red-300/35 bg-red-400/10 text-red-200' + ? 'bg-emerald-300/12 border-emerald-300/35 text-emerald-200' + : modelRecommendation.level === 'recommended-with-limits' + ? 'bg-amber-300/12 border-amber-300/35 text-amber-200' + : modelRecommendation.level === 'unavailable-in-opencode' + ? 'border-slate-300/30 bg-slate-400/10 text-slate-200' + : 'border-red-300/35 bg-red-400/10 text-red-200' )} title={modelRecommendation.reason} > - {modelRecommendation.level === 'recommended' ? ( - - ) : ( + {modelRecommendation.level === 'not-recommended' || + modelRecommendation.level === 'unavailable-in-opencode' ? ( + ) : ( + )} {modelRecommendation.label} diff --git a/src/renderer/index.css b/src/renderer/index.css index 1c3fb8ef..a1f020e4 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -419,6 +419,36 @@ filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45)); } +/* Provider logos - keep verified brand marks legible on dark provider cards */ + +.runtime-provider-brand-icon { + background-color: var( + --runtime-provider-brand-background, + var(--runtime-provider-brand-fallback-background, rgba(148, 163, 184, 0.12)) + ); + border-color: var( + --runtime-provider-brand-border, + var(--runtime-provider-brand-fallback-border, rgba(148, 163, 184, 0.28)) + ); + color: var(--runtime-provider-brand-fallback-color, inherit); +} + +.runtime-provider-brand-icon > img { + filter: var(--runtime-provider-brand-image-filter, none); +} + +:root:not(.light) .runtime-provider-brand-icon { + --runtime-provider-brand-background: rgba(15, 23, 42, 0.72); + --runtime-provider-brand-border: rgba(226, 232, 240, 0.2); + --runtime-provider-brand-mark: #f8fafc; + --runtime-provider-brand-image-filter: invert(1); + color: var(--runtime-provider-brand-mark); +} + +:root.light .runtime-provider-brand-icon { + --runtime-provider-brand-image-filter: none; +} + /* Light theme overrides - Warm neutral palette for eye comfort */ :root.light { diff --git a/src/renderer/utils/openCodeModelRecommendations.ts b/src/renderer/utils/openCodeModelRecommendations.ts index dcddbf33..137b8d7d 100644 --- a/src/renderer/utils/openCodeModelRecommendations.ts +++ b/src/renderer/utils/openCodeModelRecommendations.ts @@ -1,4 +1,8 @@ -export type OpenCodeTeamModelRecommendationLevel = 'recommended' | 'not-recommended'; +export type OpenCodeTeamModelRecommendationLevel = + | 'recommended' + | 'recommended-with-limits' + | 'unavailable-in-opencode' + | 'not-recommended'; export interface OpenCodeTeamModelRecommendation { readonly level: OpenCodeTeamModelRecommendationLevel; @@ -9,20 +13,68 @@ export interface OpenCodeTeamModelRecommendation { const PASSED_REAL_AGENT_TEAMS_E2E_REASON = 'This exact model route passed real OpenCode Agent Teams E2E: launch, direct reply, and teammate-to-teammate relay.'; +const PASSED_FREE_ROUTE_REAL_AGENT_TEAMS_E2E_REASON = + 'This exact free model route passed real OpenCode Agent Teams E2E, but free routes can still have capacity limits, rate limits, and variable latency.'; + const OPENCODE_TEAM_RECOMMENDED_MODELS = new Set([ - 'opencode/minimax-m2.5-free', 'openrouter/anthropic/claude-haiku-4.5', + 'openrouter/anthropic/claude-opus-4.6', + 'openrouter/anthropic/claude-opus-4.7', 'openrouter/anthropic/claude-sonnet-4.5', - 'openrouter/deepseek/deepseek-v3.2', + 'openrouter/anthropic/claude-sonnet-4.6', 'openrouter/google/gemini-2.5-flash', - 'openrouter/google/gemini-2.5-flash-lite', + 'openrouter/google/gemini-3.1-flash-lite-preview', + 'openrouter/google/gemini-3.1-pro-preview', 'openrouter/google/gemini-3-flash-preview', 'openrouter/minimax/minimax-m2.5', + 'openrouter/minimax/minimax-m2.7', + 'openrouter/moonshotai/kimi-k2.6', 'openrouter/mistralai/codestral-2508', + 'openrouter/mistralai/devstral-2512', + 'openrouter/mistralai/mistral-medium-3.1', + 'openrouter/openai/gpt-5.1', + 'openrouter/openai/gpt-5.1-codex', + 'openrouter/openai/gpt-5.1-codex-mini', + 'openrouter/openai/gpt-5.3-codex', + 'openrouter/openai/gpt-5.4', 'openrouter/openai/gpt-5.4-mini', - 'openrouter/openai/gpt-oss-120b:free', + 'openrouter/qwen/qwen3-max', 'openrouter/qwen/qwen3-coder', 'openrouter/qwen/qwen3-coder-flash', + 'openrouter/x-ai/grok-4.1-fast', + 'openrouter/x-ai/grok-4-fast', + 'openrouter/xiaomi/mimo-v2-pro', + 'openrouter/z-ai/glm-4.6', + 'openrouter/z-ai/glm-5', + 'openrouter/z-ai/glm-5.1', +]); + +const OPENCODE_TEAM_RECOMMENDED_WITH_LIMITS_MODELS = new Set([ + 'opencode/minimax-m2.5-free', + 'openrouter/openai/gpt-oss-120b:free', +]); + +const OPENCODE_TEAM_UNAVAILABLE_MODELS = new Map([ + [ + 'openrouter/qwen/qwen3-coder-plus', + 'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.', + ], + [ + 'openrouter/qwen/qwen3-coder-next', + 'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.', + ], + [ + 'openrouter/qwen/qwen3-max-thinking', + 'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.', + ], + [ + 'openrouter/mistralai/devstral-medium', + 'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.', + ], + [ + 'openrouter/mistralai/mistral-large-2512', + 'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.', + ], ]); const OPENCODE_TEAM_NOT_RECOMMENDED_MODELS = new Map([ @@ -38,10 +90,18 @@ const OPENCODE_TEAM_NOT_RECOMMENDED_MODELS = new Map([ 'openrouter/google/gemini-2.5-pro', 'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay.', ], + [ + 'openrouter/google/gemini-2.5-flash-lite', + 'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay with plain/control-character output instead of MCP message_send.', + ], [ 'openrouter/google/gemini-3-pro-preview', 'OpenRouter reported no runnable endpoints for this model during execution verification.', ], + [ + 'openrouter/deepseek/deepseek-v3.2', + 'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay after treating Agent Teams MCP tools as unavailable.', + ], [ 'openrouter/meta-llama/llama-3.3-70b-instruct:free', 'Execution verification timed out before Agent Teams launch could proceed.', @@ -50,6 +110,18 @@ const OPENCODE_TEAM_NOT_RECOMMENDED_MODELS = new Map([ 'openrouter/minimax/minimax-m2.5:free', 'This OpenRouter free route for MiniMax M2.5 passed direct reply but failed teammate-to-teammate relay. The non-free OpenRouter route and the OpenCode free alias are tracked separately.', ], + [ + 'openrouter/moonshotai/kimi-k2-thinking', + 'Real OpenCode Agent Teams E2E failed during launch reconciliation with an aborted assistant message.', + ], + [ + 'openrouter/openai/gpt-5.2-codex', + 'Real OpenCode Agent Teams E2E failed launch readiness because model verification timed out.', + ], + [ + 'openrouter/openai/gpt-5.1-chat', + 'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay by delegating to the lead instead of messaging the requested teammate.', + ], [ 'openrouter/openai/gpt-oss-20b:free', 'Execution verification passed, but real Agent Teams E2E produced fake tool text instead of MCP message_send.', @@ -58,6 +130,10 @@ const OPENCODE_TEAM_NOT_RECOMMENDED_MODELS = new Map([ 'openrouter/openrouter/free', 'Aggregator routing was unstable in real Agent Teams E2E and timed out during peer relay.', ], + [ + 'openrouter/x-ai/grok-code-fast-1', + 'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay by delegating to the lead instead of messaging the requested teammate.', + ], [ 'openrouter/z-ai/glm-4.5-air:free', 'Real OpenCode Agent Teams E2E was slow and failed peer relay with empty assistant turns.', @@ -84,6 +160,23 @@ export function getOpenCodeTeamModelRecommendation( }; } + if (OPENCODE_TEAM_RECOMMENDED_WITH_LIMITS_MODELS.has(normalizedModelId)) { + return { + level: 'recommended-with-limits', + label: 'Recommended with limits', + reason: PASSED_FREE_ROUTE_REAL_AGENT_TEAMS_E2E_REASON, + }; + } + + const unavailableReason = OPENCODE_TEAM_UNAVAILABLE_MODELS.get(normalizedModelId); + if (unavailableReason) { + return { + level: 'unavailable-in-opencode', + label: 'Unavailable in OpenCode', + reason: unavailableReason, + }; + } + const notRecommendedReason = OPENCODE_TEAM_NOT_RECOMMENDED_MODELS.get(normalizedModelId); if (notRecommendedReason) { return { @@ -97,7 +190,10 @@ export function getOpenCodeTeamModelRecommendation( } export function isOpenCodeTeamModelRecommended(modelId: string | null | undefined): boolean { - return getOpenCodeTeamModelRecommendation(modelId)?.level === 'recommended'; + const recommendation = getOpenCodeTeamModelRecommendation(modelId); + return ( + recommendation?.level === 'recommended' || recommendation?.level === 'recommended-with-limits' + ); } export function getOpenCodeTeamModelRecommendationSortRank( @@ -107,10 +203,16 @@ export function getOpenCodeTeamModelRecommendationSortRank( if (recommendation?.level === 'recommended') { return 0; } - if (recommendation?.level === 'not-recommended') { - return 2; + if (recommendation?.level === 'recommended-with-limits') { + return 1; } - return 1; + if (recommendation?.level === 'unavailable-in-opencode') { + return 3; + } + if (recommendation?.level === 'not-recommended') { + return 4; + } + return 2; } export function compareOpenCodeTeamModelRecommendations( diff --git a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts index 87b2a528..f9c5a3d8 100644 --- a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts +++ b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; const buildProviderAwareCliEnvMock = vi.fn(); const resolveBinaryMock = vi.fn(); @@ -6,6 +7,43 @@ const execCliMock = vi.fn(); const spawnCliMock = vi.fn(); const resolveInteractiveShellEnvMock = vi.fn(); +function createSpawnProcess(stdoutPayload: unknown, exitCode = 0): { + child: { + stdout: EventEmitter; + stderr: EventEmitter; + stdin: { + write: ReturnType; + end: ReturnType; + }; + once: EventEmitter['once']; + }; + stdinWrite: ReturnType; +} { + const processEvents = new EventEmitter(); + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const stdinWrite = vi.fn(); + const stdinEnd = vi.fn(() => { + queueMicrotask(() => { + stdout.emit('data', Buffer.from(JSON.stringify(stdoutPayload))); + processEvents.emit('close', exitCode); + }); + }); + + return { + child: { + stdout, + stderr, + stdin: { + write: stdinWrite, + end: stdinEnd, + }, + once: processEvents.once.bind(processEvents), + }, + stdinWrite, + }; +} + vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ buildProviderAwareCliEnv: (...args: unknown[]) => buildProviderAwareCliEnvMock(...args), })); @@ -209,4 +247,121 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { ); expect(JSON.stringify(execCliMock.mock.calls[0])).not.toContain('undefined'); }); + + it('loads provider setup forms through the CLI contract', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + setupForm: { + runtimeId: 'opencode', + providerId: 'openrouter', + displayName: 'OpenRouter', + method: 'api', + supported: true, + title: 'Connect OpenRouter', + description: null, + submitLabel: 'Connect', + disabledReason: null, + source: 'curated', + secret: { + key: 'key', + label: 'API key', + placeholder: 'Paste API key', + required: true, + }, + prompts: [], + }, + }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadSetupForm({ + runtimeId: 'opencode', + providerId: 'openrouter', + projectPath: '/Users/test/project', + }); + + expect(response.setupForm?.providerId).toBe('openrouter'); + expect(execCliMock).toHaveBeenCalledWith( + '/repo/cli-dev', + [ + 'runtime', + 'providers', + 'setup-form', + '--runtime', + 'opencode', + '--provider', + 'openrouter', + '--json', + '--project-path', + '/Users/test/project', + ], + expect.objectContaining({ cwd: '/Users/test/project' }) + ); + }); + + it('passes generic provider setup payload through stdin JSON only', async () => { + const { child, stdinWrite } = createSpawnProcess({ + schemaVersion: 1, + runtimeId: 'opencode', + provider: { + providerId: 'cloudflare-ai-gateway', + displayName: 'Cloudflare AI Gateway', + state: 'connected', + ownership: ['managed'], + recommended: false, + modelCount: 0, + defaultModelId: null, + authMethods: ['api'], + actions: [], + detail: null, + }, + }); + spawnCliMock.mockReturnValue(child); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.connectProvider({ + runtimeId: 'opencode', + providerId: 'cloudflare-ai-gateway', + method: 'api', + apiKey: 'sk-secret-value', + metadata: { + accountId: 'account-123', + gatewayId: 'gateway-456', + }, + projectPath: '/Users/test/project', + }); + + expect(response.provider?.providerId).toBe('cloudflare-ai-gateway'); + expect(spawnCliMock).toHaveBeenCalledWith( + '/repo/cli-dev', + [ + 'runtime', + 'providers', + 'connect', + '--runtime', + 'opencode', + '--provider', + 'cloudflare-ai-gateway', + '--stdin-json', + '--json', + '--project-path', + '/Users/test/project', + ], + expect.objectContaining({ cwd: '/Users/test/project' }) + ); + expect(JSON.stringify(spawnCliMock.mock.calls[0])).not.toContain('sk-secret-value'); + expect(stdinWrite).toHaveBeenCalledWith( + JSON.stringify({ + method: 'api', + apiKey: 'sk-secret-value', + metadata: { + accountId: 'account-123', + gatewayId: 'gateway-456', + }, + }) + ); + }); }); diff --git a/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts b/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts index 886371a3..df517664 100644 --- a/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts +++ b/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it, vi } from 'vitest'; import { registerRuntimeProviderManagementIpc } from '../../../../src/features/runtime-provider-management/main'; import { + RUNTIME_PROVIDER_MANAGEMENT_CONNECT, RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY, RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY, RUNTIME_PROVIDER_MANAGEMENT_MODELS, + RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM, RUNTIME_PROVIDER_MANAGEMENT_VIEW, } from '../../../../src/features/runtime-provider-management/contracts'; @@ -12,6 +14,7 @@ import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/fea import type { RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementProviderResponse, + RuntimeProviderManagementSetupFormResponse, RuntimeProviderManagementViewResponse, RuntimeProviderManagementModelsResponse, RuntimeProviderManagementModelTestResponse, @@ -118,9 +121,34 @@ describe('registerRuntimeProviderManagementIpc', () => { diagnostics: [], }, }; + const setupFormResponse: RuntimeProviderManagementSetupFormResponse = { + schemaVersion: 1, + runtimeId: 'opencode', + setupForm: { + runtimeId: 'opencode', + providerId: 'openrouter', + displayName: 'OpenRouter', + method: 'api', + supported: true, + title: 'Connect OpenRouter', + description: null, + submitLabel: 'Connect', + disabledReason: null, + source: 'curated', + secret: { + key: 'key', + label: 'API key', + placeholder: 'Paste API key', + required: true, + }, + prompts: [], + }, + }; const feature: RuntimeProviderManagementFeatureFacade = { loadView: vi.fn(() => Promise.resolve(viewResponse)), loadProviderDirectory: vi.fn(() => Promise.resolve(directoryResponse)), + loadSetupForm: vi.fn(() => Promise.resolve(setupFormResponse)), + connectProvider: vi.fn(() => Promise.resolve(connectedResponse)), connectWithApiKey: vi.fn(() => Promise.resolve(connectedResponse)), forgetCredential: vi.fn(() => Promise.resolve(forgottenResponse)), loadModels: vi.fn(() => Promise.resolve(modelsResponse)), @@ -147,6 +175,38 @@ describe('registerRuntimeProviderManagementIpc', () => { limit: 10, }); + await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM)?.( + {}, + { + runtimeId: 'opencode', + providerId: 'openrouter', + } + ); + expect(feature.loadSetupForm).toHaveBeenCalledWith({ + runtimeId: 'opencode', + providerId: 'openrouter', + }); + + const genericConnectResponse = await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_CONNECT)?.( + {}, + { + runtimeId: 'opencode', + providerId: 'openrouter', + method: 'api', + apiKey: 'sk-secret-value', + metadata: {}, + } + ); + + expect(feature.connectProvider).toHaveBeenCalledWith({ + runtimeId: 'opencode', + providerId: 'openrouter', + method: 'api', + apiKey: 'sk-secret-value', + metadata: {}, + }); + expect(JSON.stringify(genericConnectResponse)).not.toContain('sk-secret-value'); + const response = await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY)?.( {}, { diff --git a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts index f4bc92b5..4d1d2d2f 100644 --- a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts +++ b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts @@ -286,6 +286,72 @@ describe('OpenCodePromptDeliveryLedger', () => { expect(observed.observedAssistantPreview).toBe('Понял'); }); + it('does not keep responded live deliveries active when no inbox commit is needed', async () => { + const store = createStore(); + const direct = await store.ensurePending({ + teamName: 'team-a', + memberName: 'bob', + laneId: 'secondary:opencode:bob', + inboxMessageId: 'direct-ui-send', + inboxTimestamp: '2026-04-25T09:59:00.000Z', + source: 'ui-send', + replyRecipient: 'user', + actionMode: 'ask', + taskRefs: [], + payloadHash: 'sha256:direct', + now: '2026-04-25T10:00:00.000Z', + }); + + const responded = await store.applyDeliveryResult({ + id: direct.id, + accepted: true, + attempted: true, + responseObservation: { + state: 'responded_visible_message', + deliveredUserMessageId: 'oc-user-direct', + assistantMessageId: 'oc-assistant-direct', + toolCallNames: ['agent-teams_message_send'], + visibleMessageToolCallId: 'tool-call-direct', + visibleReplyMessageId: 'reply-direct', + visibleReplyCorrelation: 'direct_child_message_send', + latestAssistantPreview: 'I will send the requested update.', + reason: null, + }, + now: '2026-04-25T10:00:05.000Z', + }); + expect(responded.status).toBe('responded'); + expect(responded.inboxReadCommittedAt).toBeNull(); + + await expect(store.getActiveForMember({ + teamName: 'team-a', + memberName: 'bob', + laneId: 'secondary:opencode:bob', + })).resolves.toBeNull(); + + const peer = await store.ensurePending({ + teamName: 'team-a', + memberName: 'bob', + laneId: 'secondary:opencode:bob', + inboxMessageId: 'peer-relay', + inboxTimestamp: '2026-04-25T10:01:00.000Z', + source: 'manual', + replyRecipient: 'jack', + actionMode: 'delegate', + taskRefs: [], + payloadHash: 'sha256:peer', + now: '2026-04-25T10:01:00.000Z', + }); + + await expect(store.getActiveForMember({ + teamName: 'team-a', + memberName: 'bob', + laneId: 'secondary:opencode:bob', + })).resolves.toMatchObject({ + id: peer.id, + inboxMessageId: 'peer-relay', + }); + }); + it('lists due nonterminal records in deterministic due order', async () => { const store = createStore(); const first = await store.ensurePending({ diff --git a/test/main/services/team/OpenCodeSemanticModelMatrix.live.test.ts b/test/main/services/team/OpenCodeSemanticModelMatrix.live.test.ts index fd256e22..c9a14492 100644 --- a/test/main/services/team/OpenCodeSemanticModelMatrix.live.test.ts +++ b/test/main/services/team/OpenCodeSemanticModelMatrix.live.test.ts @@ -17,6 +17,7 @@ import { import { createOpenCodeLiveHarness, getRuntimeTranscript, + waitForOpenCodeMemberIdle, type InboxMessage, waitForMemberInboxMessage, waitForOpenCodeLanesStopped, @@ -95,6 +96,7 @@ async function runModelScenario(input: { const projectPath = path.join(tempDir, 'project'); const teamName = `${input.scenario.teamNamePrefix}-${sanitizeModelForTeamName(input.model)}-${Date.now()}`; let harness: Awaited> | null = null; + let keepTempDir = false; try { await fs.mkdir(tempClaudeRoot, { recursive: true }); @@ -160,6 +162,7 @@ async function runModelScenario(input: { source: 'manual', text: input.scenario.directDelivery.textLines.join('\n'), }); + diagnostics.push(`directDelivery=${formatDeliveryDiagnostic(directDelivery)}`); if (!directDelivery.delivered) { throw new Error(`Direct OpenCode delivery failed: ${JSON.stringify(directDelivery, null, 2)}`); } @@ -178,6 +181,13 @@ async function runModelScenario(input: { }); stages.directReply = true; stages.taskRefs = hasTaskRef(directReply, directTaskRef); + await waitForOpenCodeMemberIdle({ + bridgeClient: harness.bridgeClient, + teamName, + memberName: input.scenario.directDelivery.memberName, + projectPath, + timeoutMs: 90_000, + }); const peerTaskRef = taskRefForScenario( input.scenario, @@ -193,17 +203,44 @@ async function runModelScenario(input: { source: 'manual', text: input.scenario.peerDelivery.textLines.join('\n'), }); + diagnostics.push(`peerDelivery=${formatDeliveryDiagnostic(peerDelivery)}`); if (!peerDelivery.delivered) { throw new Error(`Peer OpenCode delivery failed: ${JSON.stringify(peerDelivery, null, 2)}`); } + if (peerDelivery.accepted === false || peerDelivery.queuedBehindMessageId) { + throw new Error( + `Peer OpenCode delivery was not accepted immediately: ${JSON.stringify( + peerDelivery, + null, + 2 + )}` + ); + } - const peerMessage = await waitForMemberInboxMessage( - teamName, - input.scenario.peerDelivery.recipientName, - input.scenario.peerDelivery.senderName, - input.scenario.peerDelivery.peerToken, - 180_000 - ); + let peerMessage: Awaited>; + try { + peerMessage = await waitForMemberInboxMessage( + teamName, + input.scenario.peerDelivery.recipientName, + input.scenario.peerDelivery.senderName, + input.scenario.peerDelivery.peerToken, + 180_000 + ); + } catch (error) { + const transcript = await getRuntimeTranscript({ + bridgeClient: harness.bridgeClient, + teamName, + memberName: input.scenario.peerDelivery.senderName, + projectPath, + }); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\nSender transcript: ${JSON.stringify( + transcript, + null, + 2 + )}` + ); + } assertVisibleReplyContract(peerMessage, { expectedFrom: input.scenario.peerDelivery.senderName, expectedTo: input.scenario.peerDelivery.recipientName, @@ -240,6 +277,10 @@ async function runModelScenario(input: { diagnostics, }; } catch (error) { + if (process.env.OPENCODE_E2E_KEEP_FAILED === '1') { + keepTempDir = true; + diagnostics.push(`tempDir=${tempDir}`); + } diagnostics.push(error instanceof Error ? error.message : String(error)); return { model: input.model, @@ -256,7 +297,9 @@ async function runModelScenario(input: { await waitForOpenCodeLanesStopped(teamName).catch(() => undefined); } setClaudeBasePathOverride(null); - await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + if (!keepTempDir) { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + } } } @@ -336,6 +379,34 @@ function scoreModel(stages: ModelResult['stages']): number { ); } +function formatDeliveryDiagnostic(delivery: { + delivered?: unknown; + accepted?: unknown; + responsePending?: unknown; + responseState?: unknown; + ledgerStatus?: unknown; + queuedBehindMessageId?: unknown; + reason?: unknown; + visibleReplyMessageId?: unknown; + visibleReplyCorrelation?: unknown; + diagnostics?: unknown; +}): string { + return JSON.stringify({ + delivered: delivery.delivered, + accepted: delivery.accepted, + responsePending: delivery.responsePending, + responseState: delivery.responseState, + ledgerStatus: delivery.ledgerStatus, + queuedBehindMessageId: delivery.queuedBehindMessageId, + reason: delivery.reason, + visibleReplyMessageId: delivery.visibleReplyMessageId, + visibleReplyCorrelation: delivery.visibleReplyCorrelation, + diagnostics: Array.isArray(delivery.diagnostics) + ? delivery.diagnostics.slice(0, 5) + : delivery.diagnostics, + }); +} + async function writeModelMatrixReport(report: ModelMatrixReport): Promise { const outputDir = process.env.OPENCODE_E2E_REPORT_DIR?.trim() ? path.resolve(process.env.OPENCODE_E2E_REPORT_DIR.trim()) diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 7f294d2d..e753e7a3 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -4068,6 +4068,153 @@ describe('TeamProvisioningService', () => { expect(sendMessageToMember).toHaveBeenCalledTimes(1); }); + it('unblocks newer OpenCode deliveries when the previous pending delivery now has visible proof', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'empty_assistant_turn' as const, + deliveredUserMessageId: 'oc-user-empty', + assistantMessageId: 'oc-assistant-empty', + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'empty_assistant_turn', + }, + diagnostics: [], + })); + const observeMessageDelivery = vi.fn(async () => ({ + ok: true, + providerId: 'opencode', + memberName: 'bob', + responseObservation: { + state: 'empty_assistant_turn' as const, + deliveredUserMessageId: 'oc-user-empty', + assistantMessageId: 'oc-assistant-empty', + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'empty_assistant_turn', + }, + diagnostics: [], + })); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + observeMessageDelivery, + } as any, + ]) + ); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'First prompt.', + messageId: 'msg-active-old', + replyRecipient: 'user', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: true, + responseState: 'empty_assistant_turn', + }); + + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'user.json'), + `${JSON.stringify( + [ + { + from: 'bob', + to: 'user', + text: 'Delayed but sufficient answer.', + timestamp: '2026-04-25T10:00:03.000Z', + read: false, + messageId: 'reply-old-1', + relayOfMessageId: 'msg-active-old', + source: 'runtime_delivery', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Second prompt.', + messageId: 'msg-active-new', + replyRecipient: 'user', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:05.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: true, + responseState: 'empty_assistant_turn', + }); + expect(sendMessageToMember).toHaveBeenCalledTimes(2); + expect(observeMessageDelivery).not.toHaveBeenCalled(); + }); + it('uses lane-scoped manifest activeRunId for OpenCode member delivery after restart', async () => { const svc = new TeamProvisioningService(); const teamName = 'team-a'; diff --git a/test/main/services/team/openCodeLiveTestHarness.ts b/test/main/services/team/openCodeLiveTestHarness.ts index 4fbf6c96..82e15ead 100644 --- a/test/main/services/team/openCodeLiveTestHarness.ts +++ b/test/main/services/team/openCodeLiveTestHarness.ts @@ -275,6 +275,44 @@ export async function getRuntimeTranscript(input: { })); } +export async function waitForOpenCodeMemberIdle(input: { + bridgeClient: OpenCodeBridgeCommandClient; + teamName: string; + memberName: string; + projectPath: string; + timeoutMs: number; +}): Promise { + const deadline = Date.now() + input.timeoutMs; + let lastState: string | null = null; + + while (Date.now() < deadline) { + const transcript = await getRuntimeTranscript(input); + lastState = getTranscriptDurableState(transcript); + if (lastState === 'idle') { + return; + } + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + + throw new Error( + `Timed out waiting for OpenCode member ${input.memberName} to become idle. Last durableState: ${ + lastState ?? 'unknown' + }` + ); +} + +function getTranscriptDurableState(transcript: unknown): string | null { + if (!transcript || typeof transcript !== 'object') { + return null; + } + const data = (transcript as { data?: unknown }).data; + if (!data || typeof data !== 'object') { + return null; + } + const durableState = (data as { durableState?: unknown }).durableState; + return typeof durableState === 'string' ? durableState : null; +} + async function startLiveTeamControlApi(svc: TeamProvisioningService): Promise<{ baseUrl: string; close: () => Promise; diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index c4b897da..45f85136 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -251,7 +251,9 @@ describe('TeamModelSelector disabled Codex models', () => { }, models: [ 'openrouter/openai/gpt-oss-20b:free', + 'openrouter/qwen/qwen3-coder-plus', 'opencode/big-pickle', + 'openrouter/openai/gpt-oss-120b:free', 'openrouter/qwen/qwen3-coder-flash', ], modelVerificationState: 'idle', @@ -279,7 +281,11 @@ describe('TeamModelSelector disabled Codex models', () => { expect(host.textContent).toContain('Recommended only'); expect(host.textContent).toContain('qwen/qwen3-coder-flash'); expect(host.textContent).toContain('Recommended'); + expect(host.textContent).toContain('openai/gpt-oss-120b:free'); + expect(host.textContent).toContain('Recommended with limits'); expect(host.textContent).toContain('big-pickle'); + expect(host.textContent).toContain('qwen/qwen3-coder-plus'); + expect(host.textContent).toContain('Unavailable in OpenCode'); expect(host.textContent).toContain('openai/gpt-oss-20b:free'); expect(host.textContent).toContain('Not recommended'); @@ -290,12 +296,20 @@ describe('TeamModelSelector disabled Codex models', () => { text.includes('qwen/qwen3-coder-flash') ); const neutralIndex = buttonTexts.findIndex((text) => text.includes('big-pickle')); + const limitedIndex = buttonTexts.findIndex((text) => + text.includes('openai/gpt-oss-120b:free') + ); const notRecommendedIndex = buttonTexts.findIndex((text) => text.includes('openai/gpt-oss-20b:free') ); + const unavailableIndex = buttonTexts.findIndex((text) => + text.includes('qwen/qwen3-coder-plus') + ); expect(recommendedIndex).toBeGreaterThanOrEqual(0); - expect(neutralIndex).toBeGreaterThan(recommendedIndex); - expect(notRecommendedIndex).toBeGreaterThan(neutralIndex); + expect(limitedIndex).toBeGreaterThan(recommendedIndex); + expect(neutralIndex).toBeGreaterThan(limitedIndex); + expect(unavailableIndex).toBeGreaterThan(neutralIndex); + expect(notRecommendedIndex).toBeGreaterThan(unavailableIndex); await act(async () => { const checkbox = Array.from(host.querySelectorAll('button')).find( @@ -306,7 +320,9 @@ describe('TeamModelSelector disabled Codex models', () => { }); expect(host.textContent).toContain('qwen/qwen3-coder-flash'); + expect(host.textContent).toContain('openai/gpt-oss-120b:free'); expect(host.textContent).not.toContain('big-pickle'); + expect(host.textContent).not.toContain('qwen/qwen3-coder-plus'); expect(host.textContent).not.toContain('openai/gpt-oss-20b:free'); await act(async () => { diff --git a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts index 4ae35787..a128face 100644 --- a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts +++ b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts @@ -66,6 +66,11 @@ function createState( directorySelectedProviderId: null, directorySupported: true, activeFormProviderId: null, + setupForm: null, + setupFormLoading: false, + setupFormError: null, + setupSubmitError: null, + setupMetadata: {}, apiKeyValue: '', modelPickerProviderId: null, modelPickerMode: null, @@ -101,6 +106,7 @@ function createActions(): RuntimeProviderManagementActions { startConnect: vi.fn(), cancelConnect: vi.fn(), setApiKeyValue: vi.fn(), + setSetupMetadataValue: vi.fn(), submitConnect: vi.fn(() => Promise.resolve()), forgetProvider: vi.fn(() => Promise.resolve()), openModelPicker: vi.fn(), @@ -188,7 +194,10 @@ describe('RuntimeProviderManagementPanelView', () => { await Promise.resolve(); }); - expect(actions.selectProvider).toHaveBeenCalledWith('openrouter'); + expect(actions.startConnect).toHaveBeenCalledWith('openrouter'); + expect(actions.selectProvider).not.toHaveBeenCalled(); + + vi.mocked(actions.startConnect).mockClear(); await act(async () => { const connect = Array.from(host.querySelectorAll('button')).find((button) => @@ -208,6 +217,25 @@ describe('RuntimeProviderManagementPanelView', () => { providers: state.view?.providers ?? [], activeFormProviderId: 'openrouter', apiKeyValue: 'sk-secret-value', + setupForm: { + runtimeId: 'opencode', + providerId: 'openrouter', + displayName: 'OpenRouter', + method: 'api', + supported: true, + title: 'Connect OpenRouter', + description: null, + submitLabel: 'Connect', + disabledReason: null, + source: 'curated', + secret: { + key: 'key', + label: 'API key', + placeholder: 'Paste API key', + required: true, + }, + prompts: [], + }, }, actions, disabled: false, @@ -359,6 +387,36 @@ describe('RuntimeProviderManagementPanelView', () => { supportedInlineAuth: false, }, }, + { + providerId: 'cloudflare-workers-ai', + displayName: 'Cloudflare Workers AI', + state: 'not-connected', + setupKind: 'connect-api-key', + ownership: [], + recommended: false, + modelCount: 8, + defaultModelId: null, + authMethods: ['api'], + actions: [ + { + id: 'connect', + label: 'Connect', + enabled: true, + disabledReason: null, + requiresSecret: true, + ownershipScope: 'managed', + }, + ], + sources: ['opencode-provider'], + sourceLabel: 'OpenCode catalog', + providerSource: 'models.dev', + detail: 'App-managed API-key setup is available for this provider', + metadata: { + hasKnownModels: true, + requiresManualConfig: false, + supportedInlineAuth: true, + }, + }, ], }), actions, @@ -370,6 +428,7 @@ describe('RuntimeProviderManagementPanelView', () => { expect(host.textContent).toContain('115 OpenCode providers'); expect(host.textContent).toContain('DeepSeek'); + expect(host.textContent).toContain('Cloudflare Workers AI'); expect(host.textContent).toContain('62 models'); expect(host.textContent).toContain('OpenCode catalog'); expect(host.querySelector('[data-testid="runtime-provider-search"]')).not.toBeNull(); @@ -381,7 +440,18 @@ describe('RuntimeProviderManagementPanelView', () => { await Promise.resolve(); }); - expect(actions.selectDirectoryProvider).toHaveBeenCalledWith('deepseek'); + expect(actions.selectDirectoryProvider).not.toHaveBeenCalled(); + expect(actions.startConnect).not.toHaveBeenCalled(); + + await act(async () => { + host + .querySelector('[data-testid="runtime-provider-directory-row-cloudflare-workers-ai"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(actions.startConnect).toHaveBeenCalledWith('cloudflare-workers-ai'); + expect(actions.selectDirectoryProvider).not.toHaveBeenCalled(); }); it('uses the unified provider search when compact search has no matches', async () => { @@ -496,6 +566,24 @@ describe('RuntimeProviderManagementPanelView', () => { default: false, availability: 'untested', }, + { + providerId: 'openrouter', + modelId: 'openrouter/qwen/qwen3-coder-plus', + displayName: 'qwen/qwen3-coder-plus', + sourceLabel: 'OpenRouter', + free: false, + default: false, + availability: 'untested', + }, + { + providerId: 'openrouter', + modelId: 'openrouter/openai/gpt-oss-120b:free', + displayName: 'openai/gpt-oss-120b:free', + sourceLabel: 'OpenRouter', + free: true, + default: false, + availability: 'untested', + }, { providerId: 'openrouter', modelId: 'openrouter/qwen/qwen3-coder-flash', @@ -534,8 +622,10 @@ describe('RuntimeProviderManagementPanelView', () => { expect(host.textContent).toContain('Used for new teams'); expect(host.textContent).toContain('Model probe passed'); expect(host.textContent).toContain('Not recommended'); + expect(host.textContent).toContain('Unavailable in OpenCode'); expect(host.textContent).toContain('Recommended only'); expect(host.textContent).toContain('Recommended'); + expect(host.textContent).toContain('Recommended with limits'); expect(host.textContent).not.toContain('Set OpenCode default'); expect( Array.from(host.querySelectorAll('button')).some( @@ -570,9 +660,15 @@ describe('RuntimeProviderManagementPanelView', () => { ) as HTMLElement | null; expect(modelResult?.style.color).toBe('#86efac'); expect((host.textContent ?? '').indexOf('qwen/qwen3-coder-flash')).toBeLessThan( + (host.textContent ?? '').indexOf('openai/gpt-oss-120b:free') + ); + expect((host.textContent ?? '').indexOf('openai/gpt-oss-120b:free')).toBeLessThan( (host.textContent ?? '').indexOf('opencode/big-pickle') ); expect((host.textContent ?? '').indexOf('opencode/big-pickle')).toBeLessThan( + (host.textContent ?? '').indexOf('qwen/qwen3-coder-plus') + ); + expect((host.textContent ?? '').indexOf('qwen/qwen3-coder-plus')).toBeLessThan( (host.textContent ?? '').indexOf('openrouter/openai/gpt-oss-20b:free') ); @@ -585,7 +681,9 @@ describe('RuntimeProviderManagementPanelView', () => { }); expect(host.textContent).toContain('qwen/qwen3-coder-flash'); + expect(host.textContent).toContain('openai/gpt-oss-120b:free'); expect(host.textContent).not.toContain('opencode/big-pickle'); + expect(host.textContent).not.toContain('qwen/qwen3-coder-plus'); expect(host.textContent).not.toContain('openrouter/openai/gpt-oss-20b:free'); await act(async () => { @@ -606,6 +704,7 @@ describe('RuntimeProviderManagementPanelView', () => { }); expect(actions.useModelForNewTeams).toHaveBeenCalledWith('openrouter/openai/gpt-oss-20b:free'); + expect(actions.selectProvider).not.toHaveBeenCalled(); vi.mocked(actions.useModelForNewTeams).mockClear(); await act(async () => { @@ -626,6 +725,81 @@ describe('RuntimeProviderManagementPanelView', () => { expect(actions.useModelForNewTeams).not.toHaveBeenCalled(); }); + it('keeps directory provider models visible when a model row is selected', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const actions = createActions(); + const provider = { + providerId: 'openrouter', + displayName: 'OpenRouter', + state: 'connected' as const, + ownership: ['managed'] as const, + recommended: true, + modelCount: 174, + defaultModelId: null, + authMethods: ['api'] as const, + actions: [], + sources: ['opencode-provider'] as const, + sourceLabel: 'OpenCode catalog', + providerSource: 'models.dev', + detail: 'Connected via app-managed OpenCode credential', + setupKind: 'connected' as const, + metadata: { + hasKnownModels: true, + requiresManualConfig: false, + supportedInlineAuth: true, + }, + }; + const state = createState({ + providers: [], + directoryLoaded: true, + directoryEntries: [provider], + directoryTotalCount: 1, + selectedProviderId: 'openrouter', + modelPickerProviderId: 'openrouter', + modelPickerMode: 'use', + models: [ + { + providerId: 'openrouter', + modelId: 'openrouter/google/gemini-3-flash-preview', + displayName: 'google/gemini-3-flash-preview', + sourceLabel: 'OpenRouter', + free: false, + default: false, + availability: 'untested', + }, + ], + }); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state, + actions, + disabled: false, + }) + ); + await Promise.resolve(); + }); + + await act(async () => { + host + .querySelector( + '[data-testid="runtime-provider-model-row-openrouter/google/gemini-3-flash-preview"]' + ) + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(actions.useModelForNewTeams).toHaveBeenCalledWith( + 'openrouter/google/gemini-3-flash-preview' + ); + expect(actions.selectDirectoryProvider).not.toHaveBeenCalled(); + expect(host.textContent).toContain('google/gemini-3-flash-preview'); + expect(host.textContent).not.toContain('No models found.'); + }); + it('renders verified brand icons for common OpenCode providers', async () => { const host = document.createElement('div'); document.body.appendChild(host); @@ -676,29 +850,40 @@ describe('RuntimeProviderManagementPanelView', () => { for (const provider of providers) { const logo = host.querySelector( `[data-testid="runtime-provider-logo-${provider.providerId}"]` - ); + ) as HTMLElement | null; expect(logo).not.toBeNull(); + expect(logo?.className).toContain('runtime-provider-brand-icon'); expect(logo?.querySelector('svg,img')).not.toBeNull(); + expect(logo?.getAttribute('style')).toContain( + '--runtime-provider-brand-fallback-background' + ); + expect(logo?.getAttribute('style')).toContain('--runtime-provider-brand-fallback-border'); + if (logo?.querySelector('svg')) { + expect(logo.getAttribute('style')).toContain( + '--runtime-provider-brand-fallback-color' + ); + } } }); - it('uses branded initials for popular providers without verified compact logo assets', async () => { + it('uses Models.dev logos only for verified providers and initials for unknown providers', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const actions = createActions(); const baseProvider = createState().view!.providers[0]; const providers = [ - { providerId: 'xai', displayName: 'xAI', label: 'xAI' }, - { providerId: 'groq', displayName: 'Groq', label: 'G' }, - { providerId: 'deepseek', displayName: 'DeepSeek', label: 'DS' }, - { providerId: 'deepinfra', displayName: 'Deep Infra', label: 'DI' }, - { providerId: 'fireworks-ai', displayName: 'Fireworks AI', label: 'FW' }, - { providerId: 'togetherai', displayName: 'Together AI', label: 'TA' }, - { providerId: 'amazon-bedrock', displayName: 'Amazon Bedrock', label: 'AWS' }, - { providerId: 'azure', displayName: 'Azure', label: 'AZ' }, - { providerId: 'cohere', displayName: 'Cohere', label: 'CO' }, - { providerId: 'ollama-cloud', displayName: 'Ollama Cloud', label: 'OL' }, + { providerId: 'xai', displayName: 'xAI', logo: 'xai' }, + { providerId: 'groq', displayName: 'Groq', logo: 'groq' }, + { providerId: 'deepseek', displayName: 'DeepSeek', logo: 'deepseek' }, + { providerId: 'cohere', displayName: 'Cohere', logo: 'cohere' }, + { + providerId: 'cloudferro-sherlock', + displayName: 'CloudFerro Sherlock', + logo: 'cloudferro-sherlock', + }, + { providerId: 'clarifai', displayName: 'Clarifai', label: 'CL' }, + { providerId: 'unknown-provider', displayName: 'Unknown Provider', label: 'UN' }, ].map((provider) => ({ ...baseProvider, ...provider, @@ -727,7 +912,13 @@ describe('RuntimeProviderManagementPanelView', () => { const logo = host.querySelector( `[data-testid="runtime-provider-logo-${provider.providerId}"]` ); - expect(logo?.textContent).toBe(provider.label); + if ('logo' in provider) { + const image = logo?.querySelector('img') as HTMLImageElement | null; + expect(image?.src).toContain(`https://models.dev/logos/${provider.logo}.svg`); + expect(logo?.className).toContain('runtime-provider-brand-icon'); + } else { + expect(logo?.textContent).toBe(provider.label); + } } }); }); diff --git a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts index b37e4f20..87948fab 100644 --- a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts +++ b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts @@ -292,7 +292,32 @@ describe('useRuntimeProviderManagement', () => { }); it('keeps the API key draft when provider connect fails', async () => { - const connectWithApiKey = vi.fn(() => + const loadSetupForm = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + setupForm: { + runtimeId: 'opencode', + providerId: 'openrouter', + displayName: 'OpenRouter', + method: 'api', + supported: true, + title: 'Connect OpenRouter', + description: null, + submitLabel: 'Connect', + disabledReason: null, + source: 'curated', + secret: { + key: 'key', + label: 'API key', + placeholder: 'Paste API key', + required: true, + }, + prompts: [], + }, + }) + ); + const connectProvider = vi.fn(() => Promise.resolve({ schemaVersion: 1, runtimeId: 'opencode', @@ -306,7 +331,8 @@ describe('useRuntimeProviderManagement', () => { configurable: true, value: { runtimeProviderManagement: { - connectWithApiKey, + loadSetupForm, + connectProvider, }, } as unknown as ElectronAPI, }); @@ -322,20 +348,25 @@ describe('useRuntimeProviderManagement', () => { actions?.setApiKeyValue('sk-bad-value'); }); await act(async () => { - await Promise.resolve(); + await vi.waitFor(() => { + expect(loadSetupForm).toHaveBeenCalled(); + }); }); await act(async () => { await actions?.submitConnect('openrouter'); }); - expect(connectWithApiKey).toHaveBeenCalledWith({ + expect(connectProvider).toHaveBeenCalledWith({ runtimeId: 'opencode', providerId: 'openrouter', + method: 'api', apiKey: 'sk-bad-value', + metadata: {}, projectPath: null, }); - expect(state?.error).toBe('Invalid API key'); + expect(state?.error).toBeNull(); + expect(state?.setupSubmitError).toBe('Invalid API key'); expect(state?.apiKeyValue).toBe('sk-bad-value'); }); diff --git a/test/renderer/utils/openCodeModelRecommendations.test.ts b/test/renderer/utils/openCodeModelRecommendations.test.ts index a5cc7c36..73c86344 100644 --- a/test/renderer/utils/openCodeModelRecommendations.test.ts +++ b/test/renderer/utils/openCodeModelRecommendations.test.ts @@ -13,7 +13,87 @@ describe('getOpenCodeTeamModelRecommendation', () => { label: 'Recommended', }); expect( - getOpenCodeTeamModelRecommendation(' OPENROUTER/GOOGLE/GEMINI-2.5-FLASH-LITE ') + getOpenCodeTeamModelRecommendation(' OPENROUTER/GOOGLE/GEMINI-3-FLASH-PREVIEW ') + ).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/moonshotai/kimi-k2.6')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/z-ai/glm-5.1')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/z-ai/glm-5')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-m2.7')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect( + getOpenCodeTeamModelRecommendation('openrouter/google/gemini-3.1-pro-preview') + ).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect( + getOpenCodeTeamModelRecommendation('openrouter/anthropic/claude-sonnet-4.6') + ).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/anthropic/claude-opus-4.6')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/anthropic/claude-opus-4.7')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/mistralai/devstral-2512')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5.4')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5.3-codex')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4-fast')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4.1-fast')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/xiaomi/mimo-v2-pro')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5.1-codex')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-max')).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect( + getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-medium-3.1') + ).toMatchObject({ + level: 'recommended', + label: 'Recommended', + }); + expect( + getOpenCodeTeamModelRecommendation('openrouter/google/gemini-3.1-flash-lite-preview') ).toMatchObject({ level: 'recommended', label: 'Recommended', @@ -23,7 +103,8 @@ describe('getOpenCodeTeamModelRecommendation', () => { it('keeps similarly named models distinct when real E2E disagreed', () => { expect(getOpenCodeTeamModelRecommendation('opencode/minimax-m2.5-free')).toMatchObject({ - level: 'recommended', + level: 'recommended-with-limits', + label: 'Recommended with limits', }); expect( getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-m2.5:free') @@ -32,6 +113,16 @@ describe('getOpenCodeTeamModelRecommendation', () => { }); }); + it('marks passing free routes as recommended with limits', () => { + expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-oss-120b:free')).toMatchObject( + { + level: 'recommended-with-limits', + label: 'Recommended with limits', + } + ); + expect(isOpenCodeTeamModelRecommended('openrouter/openai/gpt-oss-120b:free')).toBe(true); + }); + it('marks models with real launch or messaging failures as not recommended', () => { expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-oss-20b:free')).toMatchObject({ level: 'not-recommended', @@ -43,18 +134,74 @@ describe('getOpenCodeTeamModelRecommendation', () => { level: 'not-recommended', label: 'Not recommended', }); + expect( + getOpenCodeTeamModelRecommendation('openrouter/google/gemini-2.5-flash-lite') + ).toMatchObject({ + level: 'not-recommended', + label: 'Not recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/deepseek/deepseek-v3.2')).toMatchObject({ + level: 'not-recommended', + label: 'Not recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-code-fast-1')).toMatchObject({ + level: 'not-recommended', + label: 'Not recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5.2-codex')).toMatchObject({ + level: 'not-recommended', + label: 'Not recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/moonshotai/kimi-k2-thinking')).toMatchObject({ + level: 'not-recommended', + label: 'Not recommended', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5.1-chat')).toMatchObject({ + level: 'not-recommended', + label: 'Not recommended', + }); + }); + + it('marks OpenRouter routes missing from the OpenCode catalog as unavailable, not bad', () => { + expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder-plus')).toMatchObject({ + level: 'unavailable-in-opencode', + label: 'Unavailable in OpenCode', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder-next')).toMatchObject({ + level: 'unavailable-in-opencode', + label: 'Unavailable in OpenCode', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-max-thinking')).toMatchObject({ + level: 'unavailable-in-opencode', + label: 'Unavailable in OpenCode', + }); + expect( + getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-large-2512') + ).toMatchObject({ + level: 'unavailable-in-opencode', + label: 'Unavailable in OpenCode', + }); + expect(getOpenCodeTeamModelRecommendation('openrouter/mistralai/devstral-medium')).toMatchObject( + { + level: 'unavailable-in-opencode', + label: 'Unavailable in OpenCode', + } + ); + expect(isOpenCodeTeamModelRecommended('openrouter/qwen/qwen3-coder-plus')).toBe(false); }); it('does not label noisy or unproven models as good or bad', () => { expect(getOpenCodeTeamModelRecommendation('opencode/big-pickle')).toBeNull(); - expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-code-fast-1')).toBeNull(); + expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4.20')).toBeNull(); expect(getOpenCodeTeamModelRecommendation('')).toBeNull(); }); - it('sorts recommended routes before neutral routes and not-recommended routes last', () => { + it('sorts recommended, limited, neutral, unavailable, and not-recommended routes by status', () => { const models = [ 'openrouter/openai/gpt-oss-20b:free', + 'openrouter/qwen/qwen3-coder-plus', 'opencode/big-pickle', + 'openrouter/openai/gpt-oss-120b:free', 'openrouter/qwen/qwen3-coder-flash', ]; @@ -62,7 +209,9 @@ describe('getOpenCodeTeamModelRecommendation', () => { [...models].sort((left, right) => compareOpenCodeTeamModelRecommendations(left, right)) ).toEqual([ 'openrouter/qwen/qwen3-coder-flash', + 'openrouter/openai/gpt-oss-120b:free', 'opencode/big-pickle', + 'openrouter/qwen/qwen3-coder-plus', 'openrouter/openai/gpt-oss-20b:free', ]); });