fix(runtime-provider-management): surface provider diagnostics

This commit is contained in:
777genius 2026-05-22 15:42:25 +03:00
parent f8ceb85601
commit 6fb0c714ef
9 changed files with 3728 additions and 274 deletions

View file

@ -193,6 +193,7 @@ export type RuntimeProviderManagementErrorCodeDto =
| 'unsupported-runtime'
| 'unsupported-action'
| 'runtime-missing'
| 'runtime-misconfigured'
| 'runtime-unhealthy'
| 'provider-missing'
| 'auth-required'
@ -201,10 +202,24 @@ export type RuntimeProviderManagementErrorCodeDto =
| 'model-test-failed'
| 'unsupported-auth-method';
export interface RuntimeProviderManagementErrorDiagnosticsDto {
errorCode?: RuntimeProviderManagementErrorCodeDto | null;
summary: string | null;
likelyCause: string | null;
binaryPath: string | null;
command: string | null;
projectPath: string | null;
exitCode: number | null;
stderrPreview: string | null;
stdoutPreview: string | null;
hints: readonly string[];
}
export interface RuntimeProviderManagementErrorDto {
code: RuntimeProviderManagementErrorCodeDto;
message: string;
recoverable: boolean;
diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null;
}
export interface RuntimeProviderManagementViewResponse {

View file

@ -16,6 +16,7 @@ import type {
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementErrorDto,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadModelsInput,
@ -32,6 +33,85 @@ import type {
import type { IpcMain } from 'electron';
const logger = createLogger('Feature:RuntimeProviderManagement:IPC');
const RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT = 1_600;
const ESCAPE_CHARACTER = String.fromCharCode(27);
const BELL_CHARACTER = String.fromCharCode(7);
const ANSI_ESCAPE_PATTERN = new RegExp(`${ESCAPE_CHARACTER}\\[[0-?]*[ -/]*[@-~]`, 'g');
const OSC_ESCAPE_PATTERN = new RegExp(
`${ESCAPE_CHARACTER}\\][\\s\\S]*?(?:${BELL_CHARACTER}|${ESCAPE_CHARACTER}\\\\)`,
'g'
);
function truncateRuntimeProviderIpcErrorDetail(message: string): string {
if (message.length <= RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT) {
return message;
}
return `${message.slice(0, RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT).trimEnd()}...`;
}
function sanitizeRuntimeProviderIpcErrorMessage(message: string): string {
const sanitized = message
.replace(OSC_ESCAPE_PATTERN, '')
.replace(ANSI_ESCAPE_PATTERN, '')
.replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, 'sk-...redacted')
.replace(/\b(or-[A-Za-z0-9_-]{12,})\b/g, 'or-...redacted')
.replace(/\b(AIza[A-Za-z0-9_-]{20,})\b/g, 'AIza...redacted')
.replace(
/\b([A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|[_-]key)["'\s:=]+)([A-Za-z0-9._~+/=_-]{12,})/gi,
'$1...redacted'
)
.replace(/\b(key["'\s:=]+)([A-Za-z0-9._~+/=_-]{12,})/gi, '$1...redacted')
.replace(/\b(bearer\s+)([A-Za-z0-9._~+/=_-]{12,})/gi, '$1...redacted')
.trim();
return truncateRuntimeProviderIpcErrorDetail(sanitized);
}
function getRuntimeProviderIpcErrorMessage(error: unknown, fallback: string): string {
if (typeof error === 'string') {
return sanitizeRuntimeProviderIpcErrorMessage(error) || fallback;
}
if (!(error instanceof Error) || !error.message.trim()) {
return fallback;
}
return sanitizeRuntimeProviderIpcErrorMessage(error.message) || fallback;
}
function getRuntimeProviderIpcConnectLogDetail(error: unknown): string {
if (error instanceof Error) {
return sanitizeRuntimeProviderIpcErrorMessage(error.message) || error.name || 'Error';
}
if (typeof error === 'string') {
return sanitizeRuntimeProviderIpcErrorMessage(error) || 'Non-Error throw';
}
return 'Non-Error throw';
}
function createUnexpectedRuntimeProviderIpcError(
code: RuntimeProviderManagementErrorDto['code'],
message: string
): RuntimeProviderManagementErrorDto {
return {
code,
message,
recoverable: true,
diagnostics: {
errorCode: code,
summary: message,
likelyCause:
'The desktop app runtime provider management handler failed before it returned a normal response.',
binaryPath: null,
command: null,
projectPath: null,
exitCode: null,
stderrPreview: message,
stdoutPreview: null,
hints: [
'Retry the action once after refreshing provider settings.',
'If it repeats, copy diagnostics and attach the app logs from the same session.',
],
},
};
}
export function registerRuntimeProviderManagementIpc(
ipcMain: IpcMain,
@ -46,15 +126,12 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.loadView(input);
} catch (error) {
logger.error('Failed to load runtime provider management view', error);
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to load providers');
logger.error('Failed to load runtime provider management view', message);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load providers',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
};
}
}
@ -69,15 +146,15 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.loadProviderDirectory(input);
} catch (error) {
logger.error('Failed to load runtime provider directory', error);
const message = getRuntimeProviderIpcErrorMessage(
error,
'Failed to load provider directory'
);
logger.error('Failed to load runtime provider directory', message);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider directory',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
};
}
}
@ -92,15 +169,15 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.loadSetupForm(input);
} catch (error) {
logger.error('Failed to load runtime provider setup form', error);
const message = getRuntimeProviderIpcErrorMessage(
error,
'Failed to load provider setup form'
);
logger.error('Failed to load runtime provider setup form', message);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider setup form',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
};
}
}
@ -115,18 +192,15 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.connectProvider(input);
} catch (error) {
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider');
logger.error(
'Failed to connect runtime provider',
error instanceof Error ? error.name : error
getRuntimeProviderIpcConnectLogDetail(error)
);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'auth-failed',
message: 'Failed to connect provider',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('auth-failed', message),
};
}
}
@ -141,18 +215,15 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.connectWithApiKey(input);
} catch (error) {
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider');
logger.error(
'Failed to connect runtime provider',
error instanceof Error ? error.name : error
getRuntimeProviderIpcConnectLogDetail(error)
);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'auth-failed',
message: 'Failed to connect provider',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('auth-failed', message),
};
}
}
@ -167,15 +238,12 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.forgetCredential(input);
} catch (error) {
logger.error('Failed to forget runtime provider credential', error);
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to forget provider');
logger.error('Failed to forget runtime provider credential', message);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'unsupported-action',
message: error instanceof Error ? error.message : 'Failed to forget provider',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('unsupported-action', message),
};
}
}
@ -190,15 +258,12 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.loadModels(input);
} catch (error) {
logger.error('Failed to load runtime provider models', error);
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to load provider models');
logger.error('Failed to load runtime provider models', message);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider models',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
};
}
}
@ -213,15 +278,12 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.testModel(input);
} catch (error) {
logger.error('Failed to test runtime provider model', error);
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to test model');
logger.error('Failed to test runtime provider model', message);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'model-test-failed',
message: error instanceof Error ? error.message : 'Failed to test model',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message),
};
}
}
@ -236,15 +298,12 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.setDefaultModel(input);
} catch (error) {
logger.error('Failed to set runtime provider default model', error);
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to set default model');
logger.error('Failed to set runtime provider default model', message);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'model-test-failed',
message: error instanceof Error ? error.message : 'Failed to set default model',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message),
};
}
}

View file

@ -13,6 +13,7 @@ import type {
RuntimeProviderDefaultScopeDto,
RuntimeProviderDirectoryEntryDto,
RuntimeProviderDirectoryFilterDto,
RuntimeProviderManagementErrorDiagnosticsDto,
RuntimeProviderManagementRuntimeId,
RuntimeProviderManagementViewDto,
RuntimeProviderModelDto,
@ -46,6 +47,7 @@ export interface RuntimeProviderManagementState {
directoryLoading: boolean;
directoryRefreshing: boolean;
directoryError: string | null;
directoryErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
directoryEntries: readonly RuntimeProviderDirectoryEntryDto[];
directoryTotalCount: number | null;
directoryNextCursor: string | null;
@ -56,7 +58,9 @@ export interface RuntimeProviderManagementState {
setupForm: RuntimeProviderSetupFormDto | null;
setupFormLoading: boolean;
setupFormError: string | null;
setupFormErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
setupSubmitError: string | null;
setupSubmitErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
setupMetadata: Readonly<Record<string, string>>;
apiKeyValue: string;
modelPickerProviderId: string | null;
@ -65,6 +69,7 @@ export interface RuntimeProviderManagementState {
models: readonly RuntimeProviderModelDto[];
modelsLoading: boolean;
modelsError: string | null;
modelsErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
selectedModelId: string | null;
testingModelIds: readonly string[];
savingDefaultModelId: string | null;
@ -72,6 +77,7 @@ export interface RuntimeProviderManagementState {
loading: boolean;
savingProviderId: string | null;
error: string | null;
errorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
successMessage: string | null;
}
@ -219,6 +225,8 @@ export function useRuntimeProviderManagement(
const [directoryLoading, setDirectoryLoading] = useState(false);
const [directoryRefreshing, setDirectoryRefreshing] = useState(false);
const [directoryError, setDirectoryError] = useState<string | null>(null);
const [directoryErrorDiagnostics, setDirectoryErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [directoryEntries, setDirectoryEntries] = useState<
readonly RuntimeProviderDirectoryEntryDto[]
>([]);
@ -234,7 +242,11 @@ export function useRuntimeProviderManagement(
const [setupForm, setSetupForm] = useState<RuntimeProviderSetupFormDto | null>(null);
const [setupFormLoading, setSetupFormLoading] = useState(false);
const [setupFormError, setSetupFormError] = useState<string | null>(null);
const [setupFormErrorDiagnostics, setSetupFormErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [setupSubmitError, setSetupSubmitError] = useState<string | null>(null);
const [setupSubmitErrorDiagnostics, setSetupSubmitErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [setupMetadata, setSetupMetadata] = useState<Record<string, string>>({});
const [apiKeyValue, setApiKeyValue] = useState('');
const [modelPickerProviderId, setModelPickerProviderId] = useState<string | null>(null);
@ -245,6 +257,8 @@ export function useRuntimeProviderManagement(
const [models, setModels] = useState<readonly RuntimeProviderModelDto[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const [modelsErrorDiagnostics, setModelsErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [testingModelIds, setTestingModelIds] = useState<readonly string[]>([]);
const [savingDefaultModelId, setSavingDefaultModelId] = useState<string | null>(null);
@ -254,6 +268,8 @@ export function useRuntimeProviderManagement(
const [loading, setLoading] = useState(false);
const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [errorDiagnostics, setErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const viewLoadRequestSeq = useRef(0);
const directoryRequestSeq = useRef(0);
@ -296,6 +312,7 @@ export function useRuntimeProviderManagement(
setModels([]);
setModelsLoading(false);
setModelsError(null);
setModelsErrorDiagnostics(null);
setSelectedModelId(null);
setModelResults({});
setTestingModelIds([]);
@ -313,6 +330,7 @@ export function useRuntimeProviderManagement(
setModels([]);
setModelsLoading(false);
setModelsError(null);
setModelsErrorDiagnostics(null);
setSelectedModelId(null);
setModelResults({});
setTestingModelIds([]);
@ -323,24 +341,31 @@ export function useRuntimeProviderManagement(
setupFormRequestSeq.current += 1;
modelLoadRequestSeq.current += 1;
modelProbeGenerationRef.current += 1;
setDirectoryLoading(false);
setDirectoryRefreshing(false);
setDirectoryEntries([]);
setDirectoryTotalCount(null);
setDirectoryNextCursor(null);
setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
setDirectorySelectedProviderId(null);
setDirectoryLoaded(false);
setSetupForm(null);
setSetupFormLoading(false);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setActiveFormProviderId(null);
setApiKeyValue('');
setSetupMetadata({});
setModels([]);
setModelsLoading(false);
setModelsError(null);
setModelsErrorDiagnostics(null);
setSelectedModelId(null);
setTestingModelIds([]);
setSavingProviderId(null);
setSavingDefaultModelId(null);
setModelResults({});
setSuccessMessage(null);
@ -361,6 +386,7 @@ export function useRuntimeProviderManagement(
setLoading(true);
}
setError(null);
setErrorDiagnostics(null);
try {
const response = await api.runtimeProviderManagement.loadView({
runtimeId: options.runtimeId,
@ -374,6 +400,7 @@ export function useRuntimeProviderManagement(
setView(null);
}
setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics ?? null);
return;
}
const nextView = response.view ?? null;
@ -392,6 +419,7 @@ export function useRuntimeProviderManagement(
setView(null);
}
setError(loadError instanceof Error ? loadError.message : 'Failed to load providers');
setErrorDiagnostics(null);
} finally {
if (!silent && requestIsCurrent()) {
setLoading(false);
@ -434,6 +462,7 @@ export function useRuntimeProviderManagement(
setDirectoryLoading(true);
}
setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
try {
const response = await api.runtimeProviderManagement.loadProviderDirectory({
@ -450,6 +479,7 @@ export function useRuntimeProviderManagement(
}
if (response.error) {
setDirectoryError(response.error.message);
setDirectoryErrorDiagnostics(response.error.diagnostics ?? null);
if (
response.error.code === 'unsupported-action' ||
response.error.message.toLowerCase().includes('unknown command')
@ -461,6 +491,7 @@ export function useRuntimeProviderManagement(
const directory = response.directory;
if (!directory) {
setDirectoryError('Provider directory response was empty');
setDirectoryErrorDiagnostics(null);
return;
}
setDirectoryLoaded(true);
@ -474,6 +505,7 @@ export function useRuntimeProviderManagement(
setDirectoryError(
loadError instanceof Error ? loadError.message : 'Failed to load provider directory'
);
setDirectoryErrorDiagnostics(null);
}
} finally {
if (requestIsCurrent()) {
@ -495,23 +527,37 @@ export function useRuntimeProviderManagement(
useEffect(() => {
if (!options.enabled) {
viewLoadRequestSeq.current += 1;
directoryRequestSeq.current += 1;
setupFormRequestSeq.current += 1;
appliedInitialProviderRef.current = null;
setView(null);
setSelectedProviderId(null);
setProviderQuery('');
setLoading(false);
setSavingProviderId(null);
setSavingDefaultModelId(null);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
setDirectoryLoading(false);
setDirectoryRefreshing(false);
setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
setDirectoryEntries([]);
setDirectoryTotalCount(null);
setDirectoryNextCursor(null);
setDirectoryQuery('');
setDirectoryLoaded(false);
setDirectorySelectedProviderId(null);
setDirectorySupported(true);
setApiKeyValue('');
setSetupMetadata({});
setSetupForm(null);
setSetupFormLoading(false);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setActiveFormProviderId(null);
closeModelPickerState();
return;
@ -537,12 +583,20 @@ export function useRuntimeProviderManagement(
);
return () => window.clearTimeout(timeout);
}, [directoryLoaded, directoryQuery, directorySupported, loadDirectoryPage, options.enabled]);
}, [
currentProjectPath,
directoryLoaded,
directoryQuery,
directorySupported,
loadDirectoryPage,
options.enabled,
]);
useEffect(() => {
if (!options.enabled || !modelPickerProviderId) {
modelLoadRequestSeq.current += 1;
setModelsLoading(false);
setModelsErrorDiagnostics(null);
return;
}
@ -557,6 +611,7 @@ export function useRuntimeProviderManagement(
let cancelled = false;
setModelsLoading(true);
setModelsError(null);
setModelsErrorDiagnostics(null);
void withUiTimeout(
api.runtimeProviderManagement.loadModels({
runtimeId: options.runtimeId,
@ -574,6 +629,7 @@ export function useRuntimeProviderManagement(
if (response.error) {
setModels([]);
setModelsError(response.error.message);
setModelsErrorDiagnostics(response.error.diagnostics ?? null);
return;
}
const nextModels = response.models?.models ?? [];
@ -593,6 +649,7 @@ export function useRuntimeProviderManagement(
? modelsLoadError.message
: 'Failed to load provider models'
);
setModelsErrorDiagnostics(null);
}
})
.finally(() => {
@ -678,7 +735,9 @@ export function useRuntimeProviderManagement(
setActiveFormProviderId(null);
setSetupForm(null);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSetupMetadata({});
setApiKeyValue('');
@ -704,6 +763,7 @@ export function useRuntimeProviderManagement(
const searchAllProviders = useCallback((query: string): void => {
setDirectoryQuery(query);
setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
setDirectoryNextCursor(null);
}, []);
@ -716,9 +776,12 @@ export function useRuntimeProviderManagement(
setSetupMetadata({});
setSetupForm(null);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSetupFormLoading(true);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
const projectContext = getProjectContextSnapshot();
const requestSeq = setupFormRequestSeq.current + 1;
@ -740,11 +803,13 @@ export function useRuntimeProviderManagement(
}
if (response.error) {
setSetupFormError(response.error.message);
setSetupFormErrorDiagnostics(response.error.diagnostics ?? null);
return;
}
setSetupForm(response.setupForm ?? null);
if (!response.setupForm) {
setSetupFormError('Provider setup form response was empty');
setSetupFormErrorDiagnostics(null);
}
})
.catch((setupError) => {
@ -754,6 +819,7 @@ export function useRuntimeProviderManagement(
setSetupFormError(
setupError instanceof Error ? setupError.message : 'Failed to load provider setup form'
);
setSetupFormErrorDiagnostics(null);
})
.finally(() => {
if (requestIsCurrent()) {
@ -784,13 +850,17 @@ export function useRuntimeProviderManagement(
setSetupForm(null);
setSetupFormLoading(false);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setError(null);
setErrorDiagnostics(null);
}, []);
const updateApiKeyValue = useCallback((value: string): void => {
setApiKeyValue(value);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
}, []);
const setSetupMetadataValue = useCallback((key: string, value: string): void => {
@ -799,29 +869,35 @@ export function useRuntimeProviderManagement(
[key]: value,
}));
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
}, []);
const submitConnect = useCallback(
async (providerId: string): Promise<void> => {
if (!setupForm) {
setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded');
setSetupSubmitErrorDiagnostics(setupFormErrorDiagnostics ?? null);
return;
}
if (!setupForm.supported) {
setSetupSubmitError(
setupForm.disabledReason ?? 'Provider setup is not supported in the app'
);
setSetupSubmitErrorDiagnostics(null);
return;
}
const apiKey = apiKeyValue.trim();
if (setupForm.secret?.required && !apiKey) {
setSetupSubmitError(`${setupForm.secret.label} is required`);
setSetupSubmitErrorDiagnostics(null);
return;
}
setSavingProviderId(providerId);
setError(null);
setErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSuccessMessage(null);
const projectContext = getProjectContextSnapshot();
try {
@ -841,6 +917,7 @@ export function useRuntimeProviderManagement(
}
if (response.error) {
setSetupSubmitError(response.error.message);
setSetupSubmitErrorDiagnostics(response.error.diagnostics ?? null);
return;
}
if (response.provider) {
@ -852,7 +929,9 @@ export function useRuntimeProviderManagement(
setSetupMetadata({});
setSetupForm(null);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
try {
await options.onProviderChanged?.();
if (!isProjectContextCurrent(projectContext)) {
@ -869,6 +948,7 @@ export function useRuntimeProviderManagement(
setError(
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
);
setErrorDiagnostics(null);
}
} catch (connectError) {
if (!isProjectContextCurrent(projectContext)) {
@ -877,6 +957,7 @@ export function useRuntimeProviderManagement(
setSetupSubmitError(
connectError instanceof Error ? connectError.message : 'Failed to connect provider'
);
setSetupSubmitErrorDiagnostics(null);
} finally {
if (isProjectContextCurrent(projectContext)) {
setSavingProviderId(null);
@ -892,6 +973,7 @@ export function useRuntimeProviderManagement(
refresh,
setupForm,
setupFormError,
setupFormErrorDiagnostics,
setupMetadata,
]
);
@ -900,6 +982,7 @@ export function useRuntimeProviderManagement(
async (providerId: string): Promise<void> => {
setSavingProviderId(providerId);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
const projectContext = getProjectContextSnapshot();
try {
@ -916,6 +999,7 @@ export function useRuntimeProviderManagement(
}
if (response.error) {
setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics ?? null);
return;
}
if (response.provider) {
@ -938,6 +1022,7 @@ export function useRuntimeProviderManagement(
setError(
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
);
setErrorDiagnostics(null);
}
if (!isProjectContextCurrent(projectContext)) {
return;
@ -950,6 +1035,7 @@ export function useRuntimeProviderManagement(
setError(
forgetError instanceof Error ? forgetError.message : 'Failed to forget credential'
);
setErrorDiagnostics(null);
} finally {
if (isProjectContextCurrent(projectContext)) {
setSavingProviderId(null);
@ -965,6 +1051,7 @@ export function useRuntimeProviderManagement(
setActiveFormProviderId(null);
openModelPickerState(providerId, mode);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
},
[openModelPickerState]
@ -979,6 +1066,7 @@ export function useRuntimeProviderManagement(
setSelectedModelId(modelId);
setSuccessMessage(null);
setError(null);
setErrorDiagnostics(null);
}, []);
const testModel = useCallback(
@ -994,6 +1082,7 @@ export function useRuntimeProviderManagement(
current.includes(modelId) ? current : [...current, modelId]
);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
try {
const response = await withUiTimeout(
@ -1007,6 +1096,10 @@ export function useRuntimeProviderManagement(
100_000
);
if (response.error) {
if (response.error.diagnostics && shouldRecordProbeResult()) {
setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics);
}
if (shouldRecordProbeResult()) {
const result = buildFailedModelTestResult(providerId, modelId, response.error.message);
setModelResults((current) => ({
@ -1064,6 +1157,7 @@ export function useRuntimeProviderManagement(
): Promise<void> => {
setSavingDefaultModelId(modelId);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
const projectContext = getProjectContextSnapshot();
try {
@ -1084,6 +1178,7 @@ export function useRuntimeProviderManagement(
}
if (response.error) {
setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics ?? null);
return;
}
const proofResult: RuntimeProviderModelTestResultDto = {
@ -1130,6 +1225,7 @@ export function useRuntimeProviderManagement(
setError(
defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default'
);
setErrorDiagnostics(null);
} finally {
if (isProjectContextCurrent(projectContext)) {
setSavingDefaultModelId(null);
@ -1146,7 +1242,9 @@ export function useRuntimeProviderManagement(
setActiveFormProviderId(null);
setSetupForm(null);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSetupMetadata({});
setApiKeyValue('');
if (activeModelPickerProviderRef.current !== providerId) {
@ -1199,6 +1297,7 @@ export function useRuntimeProviderManagement(
directoryLoading,
directoryRefreshing,
directoryError,
directoryErrorDiagnostics,
directoryEntries,
directoryTotalCount,
directoryNextCursor,
@ -1209,7 +1308,9 @@ export function useRuntimeProviderManagement(
setupForm,
setupFormLoading,
setupFormError,
setupFormErrorDiagnostics,
setupSubmitError,
setupSubmitErrorDiagnostics,
setupMetadata,
apiKeyValue,
modelPickerProviderId,
@ -1218,6 +1319,7 @@ export function useRuntimeProviderManagement(
models,
modelsLoading,
modelsError,
modelsErrorDiagnostics,
selectedModelId,
testingModelIds,
savingDefaultModelId,
@ -1225,18 +1327,22 @@ export function useRuntimeProviderManagement(
loading,
savingProviderId,
error,
errorDiagnostics,
successMessage,
}),
[
activeFormProviderId,
apiKeyValue,
setupForm,
setupFormErrorDiagnostics,
setupFormError,
setupFormLoading,
setupSubmitErrorDiagnostics,
setupSubmitError,
setupMetadata,
directoryEntries,
directoryError,
directoryErrorDiagnostics,
directoryLoaded,
directoryLoading,
directoryNextCursor,
@ -1245,12 +1351,14 @@ export function useRuntimeProviderManagement(
directorySupported,
directoryTotalCount,
error,
errorDiagnostics,
loading,
modelPickerMode,
modelPickerProviderId,
modelQuery,
modelResults,
models,
modelsErrorDiagnostics,
modelsError,
modelsLoading,
providerQuery,

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@ -20,7 +20,9 @@ import {
} from '@renderer/utils/openCodeModelRecommendations';
import {
AlertTriangle,
Check,
CheckCircle2,
ClipboardList,
KeyRound,
Loader2,
RefreshCcw,
@ -47,6 +49,7 @@ import type {
RuntimeProviderDefaultModelSourceDto,
RuntimeProviderDefaultScopeDto,
RuntimeProviderDirectoryEntryDto,
RuntimeProviderManagementErrorDiagnosticsDto,
RuntimeProviderModelDto,
RuntimeProviderModelTestResultDto,
RuntimeProviderSetupPromptDto,
@ -84,6 +87,12 @@ interface ProviderRowProps {
readonly actions: RuntimeProviderManagementActions;
}
interface RuntimeProviderErrorAlertProps {
readonly message: string;
readonly diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null;
readonly testId: string;
}
type OpenCodeSettingsSection = 'models' | 'providers';
const NO_PROJECT_CONTEXT_VALUE = '__runtime-provider-no-project-context__';
@ -338,8 +347,11 @@ function ProviderSetupFormPanel({
const form = state.setupForm?.providerId === provider.providerId ? state.setupForm : null;
const loading = state.setupFormLoading && state.activeFormProviderId === provider.providerId;
const error = state.setupFormError;
const errorDiagnostics = state.setupFormErrorDiagnostics;
const submitError =
state.activeFormProviderId === provider.providerId ? state.setupSubmitError : null;
const submitErrorDiagnostics =
state.activeFormProviderId === provider.providerId ? state.setupSubmitErrorDiagnostics : null;
const canSubmit = setupFormCanSubmit(state, provider.providerId);
return (
@ -356,9 +368,11 @@ function ProviderSetupFormPanel({
) : null}
{!loading && error ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{error}
</div>
<RuntimeProviderErrorAlert
message={error}
diagnostics={errorDiagnostics}
testId="runtime-provider-setup-form-error"
/>
) : null}
{!loading && form ? (
@ -445,8 +459,12 @@ function ProviderSetupFormPanel({
) : null}
{submitError ? (
<div className="mt-3 rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{submitError}
<div className="mt-3">
<RuntimeProviderErrorAlert
message={submitError}
diagnostics={submitErrorDiagnostics}
testId="runtime-provider-setup-submit-error"
/>
</div>
) : null}
@ -668,6 +686,228 @@ function RuntimeProviderLoadingPlaceholder(): JSX.Element {
);
}
function formatRuntimeProviderDiagnosticsCopyText(
message: string,
diagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null | undefined
): string {
const lines = ['OpenCode provider settings diagnostics', '', 'Message:', message.trim()];
if (!diagnostics) {
return lines.join('\n');
}
const hints = diagnostics.hints ?? [];
const fields: Array<[string, string | number | null]> = [
['Error code', diagnostics.errorCode ?? null],
['Summary', diagnostics.summary],
['Likely cause', diagnostics.likelyCause],
['Resolved runtime binary', diagnostics.binaryPath],
['Command', diagnostics.command],
['Project path', diagnostics.projectPath],
['Exit code', diagnostics.exitCode],
];
lines.push('', 'Structured diagnostics:');
for (const [label, value] of fields) {
if (value !== null && value !== '') {
lines.push(`${label}: ${String(value)}`);
}
}
if (hints.length > 0) {
lines.push('', 'Hints:', ...hints.map((hint) => `- ${hint}`));
}
if (diagnostics.stderrPreview) {
lines.push('', 'stderr preview:', diagnostics.stderrPreview);
}
if (diagnostics.stdoutPreview) {
lines.push('', 'stdout preview:', diagnostics.stdoutPreview);
}
return lines.join('\n');
}
function getRuntimeProviderDiagnosticRows(
diagnostics: RuntimeProviderManagementErrorDiagnosticsDto
): Array<[string, string]> {
const rows: Array<[string, string | number | null]> = [
['Code', diagnostics.errorCode ?? null],
['Binary', diagnostics.binaryPath],
['Command', diagnostics.command],
['Project', diagnostics.projectPath],
['Exit', diagnostics.exitCode],
];
return rows
.filter(([, value]) => value !== null && value !== '')
.map(([label, value]) => [label, String(value)]);
}
async function writeRuntimeProviderDiagnosticsToClipboard(text: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall back to the selection API below.
}
}
return copyRuntimeProviderDiagnosticsWithSelection(text);
}
function copyRuntimeProviderDiagnosticsWithSelection(text: string): boolean {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
return document.execCommand('copy');
} catch {
return false;
} finally {
textarea.remove();
}
}
const RuntimeProviderErrorAlert = ({
message,
diagnostics = null,
testId,
}: RuntimeProviderErrorAlertProps): JSX.Element => {
const [copied, setCopied] = useState(false);
const [headline = message, ...detailLines] = message.trim().split(/\r?\n/);
const fallbackDetails = detailLines.join('\n').trim();
const hints = diagnostics?.hints ?? [];
const copyText = useMemo(
() => formatRuntimeProviderDiagnosticsCopyText(message, diagnostics),
[diagnostics, message]
);
const diagnosticRows = diagnostics ? getRuntimeProviderDiagnosticRows(diagnostics) : [];
const copyDiagnostics = useCallback(async (): Promise<void> => {
setCopied(await writeRuntimeProviderDiagnosticsToClipboard(copyText));
}, [copyText]);
useEffect(() => {
if (!copied) {
return;
}
const timeout = window.setTimeout(() => setCopied(false), 1_500);
return () => window.clearTimeout(timeout);
}, [copied]);
return (
<div
data-testid={testId}
role="alert"
className="flex min-w-0 items-start gap-2 rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'rgba(248, 113, 113, 0.25)',
backgroundColor: 'rgba(248, 113, 113, 0.06)',
color: '#fca5a5',
}}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
<div className="min-w-0 whitespace-pre-wrap break-words font-medium leading-5">
{headline || message}
</div>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 shrink-0 px-2 text-[11px]"
title={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
aria-label={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
onClick={(event) => {
event.stopPropagation();
void copyDiagnostics();
}}
>
{copied ? <Check className="mr-1 size-3" /> : <ClipboardList className="mr-1 size-3" />}
{copied ? 'Copied' : 'Copy diagnostics'}
</Button>
</div>
{diagnostics ? (
<div className="mt-2 space-y-2">
{diagnostics.likelyCause ? (
<div className="whitespace-pre-wrap break-words leading-5 text-red-100">
<span className="font-medium text-red-100">Likely cause: </span>
{diagnostics.likelyCause}
</div>
) : null}
{diagnosticRows.length > 0 ? (
<dl className="grid gap-1 rounded border px-2 py-1.5 text-[11px] leading-4 sm:grid-cols-[92px_minmax(0,1fr)]">
{diagnosticRows.map(([label, value]) => (
<div key={label} className="contents">
<dt className="text-red-200/75">{label}</dt>
<dd className="min-w-0 break-words font-mono text-red-100">{value}</dd>
</div>
))}
</dl>
) : null}
{hints.length > 0 ? (
<div>
<div className="mb-1 font-medium text-red-100">Hints</div>
<ul className="space-y-1 pl-4">
{hints.map((hint, index) => (
<li
key={`${hint}-${index}`}
className="list-disc whitespace-pre-wrap break-words"
>
{hint}
</li>
))}
</ul>
</div>
) : null}
{diagnostics.stderrPreview ? (
<pre
data-testid={`${testId}-stderr-preview`}
className="m-0 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded border px-2 py-1.5 font-mono text-[11px] leading-4"
style={{
borderColor: 'rgba(248, 113, 113, 0.2)',
backgroundColor: 'rgba(15, 23, 42, 0.38)',
color: '#fecaca',
}}
>
{`stderr preview:\n${diagnostics.stderrPreview}`}
</pre>
) : null}
{diagnostics.stdoutPreview ? (
<pre
data-testid={`${testId}-stdout-preview`}
className="m-0 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded border px-2 py-1.5 font-mono text-[11px] leading-4"
style={{
borderColor: 'rgba(248, 113, 113, 0.2)',
backgroundColor: 'rgba(15, 23, 42, 0.38)',
color: '#fecaca',
}}
>
{`stdout preview:\n${diagnostics.stdoutPreview}`}
</pre>
) : null}
</div>
) : fallbackDetails ? (
<pre
className="m-0 mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded border px-2 py-1.5 font-mono text-[11px] leading-4"
style={{
borderColor: 'rgba(248, 113, 113, 0.2)',
backgroundColor: 'rgba(15, 23, 42, 0.38)',
color: '#fecaca',
}}
>
{fallbackDetails}
</pre>
) : null}
</div>
</div>
);
};
function RuntimeProviderModelLoadingSkeleton(): JSX.Element {
return (
<div className="space-y-2" data-testid="runtime-provider-model-loading-skeleton">
@ -1756,9 +1996,11 @@ function ProviderModelList({
</div>
{state.modelsError ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{state.modelsError}
</div>
<RuntimeProviderErrorAlert
message={state.modelsError}
diagnostics={state.modelsErrorDiagnostics}
testId="runtime-provider-models-error"
/>
) : null}
<div
@ -1843,17 +2085,11 @@ export function RuntimeProviderManagementPanelView({
<RuntimeSummary state={state} disabled={disabled} onRefresh={() => void actions.refresh()} />
{state.error ? (
<div
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'rgba(248, 113, 113, 0.25)',
backgroundColor: 'rgba(248, 113, 113, 0.06)',
color: '#fca5a5',
}}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{state.error}</span>
</div>
<RuntimeProviderErrorAlert
message={state.error}
diagnostics={state.errorDiagnostics}
testId="runtime-provider-error"
/>
) : null}
{state.successMessage ? (
@ -1988,9 +2224,11 @@ export function RuntimeProviderManagementPanelView({
) : null}
{state.directoryError ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{state.directoryError}
</div>
<RuntimeProviderErrorAlert
message={state.directoryError}
diagnostics={state.directoryErrorDiagnostics}
testId="runtime-provider-directory-error"
/>
) : null}
<div className="max-h-[min(52vh,640px)] space-y-2 overflow-y-auto pr-1">

View file

@ -1,6 +1,5 @@
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,
@ -9,16 +8,17 @@ import {
RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM,
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
} from '../../../../src/features/runtime-provider-management/contracts';
import { registerRuntimeProviderManagementIpc } from '../../../../src/features/runtime-provider-management/main';
import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/features/runtime-provider-management/main';
import type {
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementViewResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementModelTestResponse,
} from '../../../../src/features/runtime-provider-management/contracts';
import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/features/runtime-provider-management/main';
import type { IpcMain } from 'electron';
describe('registerRuntimeProviderManagementIpc', () => {
@ -234,4 +234,151 @@ describe('registerRuntimeProviderManagementIpc', () => {
limit: 10,
});
});
it('sanitizes unexpected IPC error messages before returning them to the renderer', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
const ipcMain = {
handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
handlers.set(channel, handler);
}),
removeHandler: vi.fn(),
} as unknown as IpcMain;
const feature: RuntimeProviderManagementFeatureFacade = {
loadView: vi.fn(() =>
Promise.reject(
new Error(
'\u001B]8;;https://logs.example/secret\u0007\u001B[31mProvider failed with api_key: sk-secret-value-123456 and Authorization: Bearer live-token-123456789 and key=AIzaSyD-test-secret-value-123456789 and OPENAI_API_KEY=plain_provider_secret_123456 and PROVIDER_TOKEN=provider_token_value_123456\u001B[0m\u001B]8;;\u0007'
)
)
),
loadProviderDirectory: vi.fn(),
loadSetupForm: vi.fn(),
connectProvider: vi.fn(),
connectWithApiKey: vi.fn(),
forgetCredential: vi.fn(),
loadModels: vi.fn(),
testModel: vi.fn(),
setDefaultModel: vi.fn(),
};
registerRuntimeProviderManagementIpc(ipcMain, feature);
const response = (await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_VIEW)?.(
{},
{ runtimeId: 'opencode' }
)) as RuntimeProviderManagementViewResponse;
expect(response.error?.message).toContain('api_key: ...redacted');
expect(response.error?.message).toContain('Authorization: Bearer ...redacted');
expect(response.error?.message).toContain('key=...redacted');
expect(response.error?.message).toContain('OPENAI_API_KEY=...redacted');
expect(response.error?.message).toContain('PROVIDER_TOKEN=...redacted');
expect(response.error?.message).not.toContain('sk-secret-value-123456');
expect(response.error?.message).not.toContain('live-token-123456789');
expect(response.error?.message).not.toContain('AIzaSyD-test-secret-value-123456789');
expect(response.error?.message).not.toContain('plain_provider_secret_123456');
expect(response.error?.message).not.toContain('provider_token_value_123456');
expect(response.error?.message).not.toContain('logs.example/secret');
expect(response.error?.message).not.toContain('[31m');
expect(response.error?.message).not.toContain(']8;;');
expect(response.error?.diagnostics?.summary).toContain('api_key: ...redacted');
expect(response.error?.diagnostics?.errorCode).toBe('runtime-unhealthy');
expect(response.error?.diagnostics?.stderrPreview).toContain(
'Authorization: Bearer ...redacted'
);
expect(JSON.stringify(response.error?.diagnostics)).not.toContain('sk-secret-value-123456');
expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('api_key: ...redacted');
expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('key=...redacted');
expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('sk-secret-value-123456');
expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('live-token-123456789');
expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain(
'AIzaSyD-test-secret-value-123456789'
);
consoleErrorSpy.mockRestore();
});
it('bounds unexpected IPC diagnostics before returning them to the renderer', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
const ipcMain = {
handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
handlers.set(channel, handler);
}),
removeHandler: vi.fn(),
} as unknown as IpcMain;
const feature: RuntimeProviderManagementFeatureFacade = {
loadView: vi.fn(() => Promise.reject(new Error(`x${'y'.repeat(3_000)}`))),
loadProviderDirectory: vi.fn(),
loadSetupForm: vi.fn(),
connectProvider: vi.fn(),
connectWithApiKey: vi.fn(),
forgetCredential: vi.fn(),
loadModels: vi.fn(),
testModel: vi.fn(),
setDefaultModel: vi.fn(),
};
registerRuntimeProviderManagementIpc(ipcMain, feature);
const response = (await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_VIEW)?.(
{},
{ runtimeId: 'opencode' }
)) as RuntimeProviderManagementViewResponse;
expect(response.error?.message.endsWith('...')).toBe(true);
expect(response.error?.message.length).toBeLessThanOrEqual(1_603);
expect(response.error?.diagnostics?.stderrPreview).toBe(response.error?.message);
consoleErrorSpy.mockRestore();
});
it('does not log raw secrets when connect handlers throw non-Error values', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
const ipcMain = {
handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
handlers.set(channel, handler);
}),
removeHandler: vi.fn(),
} as unknown as IpcMain;
const feature: RuntimeProviderManagementFeatureFacade = {
loadView: vi.fn(),
loadProviderDirectory: vi.fn(),
loadSetupForm: vi.fn(),
connectProvider: vi.fn(() =>
Promise.reject(
'Provider failed with api_key: sk-secret-value-123456 and token=provider-token-123456789'
)
),
connectWithApiKey: vi.fn(),
forgetCredential: vi.fn(),
loadModels: vi.fn(),
testModel: vi.fn(),
setDefaultModel: vi.fn(),
};
registerRuntimeProviderManagementIpc(ipcMain, feature);
const response = (await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_CONNECT)?.(
{},
{
runtimeId: 'opencode',
providerId: 'openrouter',
method: 'api',
apiKey: 'sk-input-secret-value',
metadata: {},
}
)) as RuntimeProviderManagementProviderResponse;
expect(response.error?.message).toContain('api_key: ...redacted');
expect(response.error?.message).toContain('token=...redacted');
expect(response.error?.diagnostics?.errorCode).toBe('auth-failed');
expect(response.error?.diagnostics?.stderrPreview).toContain('token=...redacted');
expect(JSON.stringify(response)).not.toContain('sk-input-secret-value');
expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('api_key: ...redacted');
expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('token=...redacted');
expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('sk-secret-value-123456');
expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('provider-token-123456789');
consoleErrorSpy.mockRestore();
});
});

View file

@ -57,6 +57,7 @@ function createState(
directoryLoading: false,
directoryRefreshing: false,
directoryError: null,
directoryErrorDiagnostics: null,
directoryEntries: [],
directoryTotalCount: null,
directoryNextCursor: null,
@ -67,7 +68,9 @@ function createState(
setupForm: null,
setupFormLoading: false,
setupFormError: null,
setupFormErrorDiagnostics: null,
setupSubmitError: null,
setupSubmitErrorDiagnostics: null,
setupMetadata: {},
apiKeyValue: '',
modelPickerProviderId: null,
@ -76,6 +79,7 @@ function createState(
models: [],
modelsLoading: false,
modelsError: null,
modelsErrorDiagnostics: null,
selectedModelId: null,
testingModelIds: [],
savingDefaultModelId: null,
@ -83,6 +87,7 @@ function createState(
loading: false,
savingProviderId: null,
error: null,
errorDiagnostics: null,
successMessage: null,
...overrides,
};
@ -170,6 +175,397 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(host.textContent).not.toContain('No launchable OpenCode model routes were reported yet');
});
it('renders runtime command errors with a readable headline and multiline details', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const message = [
'OpenCode provider settings could not read the runtime response.',
'Expected a JSON object from the Agent Teams runtime provider command.',
'Resolved runtime binary: /opt/homebrew/bin/opencode',
'Command: /opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact',
'stdout preview:',
'Commands:',
' opencode providers',
].join('\n');
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({ error: message }),
actions: createActions(),
disabled: false,
})
);
await Promise.resolve();
});
const alert = host.querySelector<HTMLElement>('[data-testid="runtime-provider-error"]');
const details = alert?.querySelector('pre');
expect(alert?.getAttribute('role')).toBe('alert');
expect(alert?.textContent).toContain(
'OpenCode provider settings could not read the runtime response.'
);
expect(details?.textContent).toContain('Resolved runtime binary: /opt/homebrew/bin/opencode');
expect(details?.textContent).toContain(' opencode providers');
expect(details?.className).toContain('whitespace-pre-wrap');
expect(details?.className).toContain('font-mono');
});
it('copies fallback error text when structured diagnostics are unavailable', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const writeText = vi.fn((_text: string) => Promise.resolve());
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
error: 'Runtime provider crashed\nstderr preview:\nmissing bun',
errorDiagnostics: null,
}),
actions: createActions(),
disabled: false,
})
);
await Promise.resolve();
});
await act(async () => {
Array.from(host.querySelectorAll('button'))
.find((button) => button.textContent?.includes('Copy diagnostics'))
?.click();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith(
'OpenCode provider settings diagnostics\n\nMessage:\nRuntime provider crashed\nstderr preview:\nmissing bun'
);
if (clipboardDescriptor) {
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
});
it('copies diagnostics with the selection fallback when clipboard API is unavailable', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
const execCommandDescriptor = Object.getOwnPropertyDescriptor(document, 'execCommand');
const execCommand = vi.fn(() => true);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
});
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: execCommand,
});
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
error: 'Runtime provider crashed\nstderr preview:\nmissing bun',
errorDiagnostics: null,
}),
actions: createActions(),
disabled: false,
})
);
await Promise.resolve();
});
await act(async () => {
Array.from(host.querySelectorAll('button'))
.find((button) => button.textContent?.includes('Copy diagnostics'))
?.click();
await Promise.resolve();
});
expect(execCommand).toHaveBeenCalledWith('copy');
expect(host.textContent).toContain('Copied');
expect(document.querySelector('textarea')).toBeNull();
if (clipboardDescriptor) {
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
if (execCommandDescriptor) {
Object.defineProperty(document, 'execCommand', execCommandDescriptor);
} else {
Reflect.deleteProperty(document, 'execCommand');
}
});
it('renders structured runtime diagnostics and copies the full redacted report', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const writeText = vi.fn((_text: string) => Promise.resolve());
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
error: 'OpenCode provider settings could not read the runtime response.',
errorDiagnostics: {
errorCode: 'runtime-unhealthy',
summary: 'OpenCode provider settings could not read the runtime response.',
likelyCause:
'The app is launching the OpenCode CLI itself instead of the Agent Teams runtime.',
binaryPath: '/opt/homebrew/bin/opencode',
command:
'/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact',
projectPath: '/Users/test/project',
exitCode: 1,
stderrPreview: 'Command failed before JSON',
stdoutPreview: 'Commands:\n opencode providers',
hints: [
'Check CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH and CLAUDE_CLI_PATH.',
'Those environment variables must not point to opencode.',
],
},
}),
actions: createActions(),
disabled: false,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Likely cause');
expect(host.textContent).toContain('/opt/homebrew/bin/opencode');
expect(host.textContent).toContain('Command failed before JSON');
expect(
host.querySelector('[data-testid="runtime-provider-error-stderr-preview"]')?.textContent
).toContain('stderr preview');
expect(
host.querySelector('[data-testid="runtime-provider-error-stdout-preview"]')?.textContent
).toContain('opencode providers');
await act(async () => {
Array.from(host.querySelectorAll('button'))
.find((button) => button.textContent?.includes('Copy diagnostics'))
?.click();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledTimes(1);
expect(writeText.mock.calls[0][0]).toContain('OpenCode provider settings diagnostics');
expect(writeText.mock.calls[0][0]).toContain('Error code: runtime-unhealthy');
expect(writeText.mock.calls[0][0]).toContain('Resolved runtime binary: /opt/homebrew/bin/opencode');
expect(writeText.mock.calls[0][0]).toContain('stderr preview:');
expect(writeText.mock.calls[0][0]).toContain('stdout preview:');
expect(host.textContent).toContain('Copied');
if (clipboardDescriptor) {
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
});
it('does not activate a provider row when copying model diagnostics', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const writeText = vi.fn((_text: string) => Promise.resolve());
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const actions = createActions();
const base = createState();
const provider = {
...base.view!.providers[0],
state: 'connected' as const,
modelCount: 2,
actions: [
{
id: 'test' as const,
label: 'Test',
enabled: true,
disabledReason: null,
requiresSecret: false,
ownershipScope: 'runtime' as const,
},
],
};
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
view: {
...base.view!,
providers: [provider],
},
providers: [provider],
selectedProviderId: provider.providerId,
modelPickerProviderId: provider.providerId,
modelPickerMode: 'use',
modelsError: 'Model list failed',
modelsErrorDiagnostics: {
summary: 'Model list failed',
likelyCause: 'The runtime returned a malformed models response.',
binaryPath: '/repo/cli-dev',
command: '/repo/cli-dev runtime providers models --runtime opencode',
projectPath: '/Users/test/project',
exitCode: 1,
stderrPreview: 'bad models payload',
stdoutPreview: null,
hints: ['Retry after refreshing the runtime.'],
},
}),
actions,
disabled: false,
})
);
await Promise.resolve();
});
await act(async () => {
Array.from(host.querySelectorAll('button'))
.find((button) => button.textContent?.includes('Copy diagnostics'))
?.click();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledTimes(1);
expect(actions.selectProvider).not.toHaveBeenCalled();
expect(actions.startConnect).not.toHaveBeenCalled();
if (clipboardDescriptor) {
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
});
it('renders structured diagnostics in provider form and model picker errors', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const provider = {
...createState().view!.providers[0],
state: 'connected' as const,
modelCount: 4,
actions: [
{
id: 'test' as const,
label: 'Test',
enabled: true,
disabledReason: null,
requiresSecret: false,
ownershipScope: 'runtime' as const,
},
],
};
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
providers: [provider],
selectedProviderId: provider.providerId,
activeFormProviderId: provider.providerId,
modelPickerProviderId: provider.providerId,
modelPickerMode: 'use',
setupSubmitError: 'Provider connect failed before JSON.',
setupSubmitErrorDiagnostics: {
summary: 'Provider connect failed before JSON.',
likelyCause: 'The runtime command printed CLI help instead of JSON.',
binaryPath: '/opt/homebrew/bin/opencode',
command: '/opt/homebrew/bin/opencode runtime providers connect',
projectPath: null,
exitCode: 1,
stderrPreview: 'unknown command',
stdoutPreview: 'Commands:\n opencode providers',
hints: ['Check the resolved runtime binary.'],
},
modelsError: 'Provider models failed before JSON.',
modelsErrorDiagnostics: {
summary: 'Provider models failed before JSON.',
likelyCause: 'The runtime command printed CLI help instead of JSON.',
binaryPath: '/opt/homebrew/bin/opencode',
command: '/opt/homebrew/bin/opencode runtime providers models',
projectPath: null,
exitCode: 1,
stderrPreview: 'unknown command',
stdoutPreview: 'Commands:\n opencode providers',
hints: ['Check the resolved runtime binary.'],
},
}),
actions: createActions(),
disabled: false,
})
);
await Promise.resolve();
});
expect(
host.querySelector('[data-testid="runtime-provider-setup-submit-error"]')?.textContent
).toContain('Provider connect failed before JSON.');
expect(
host.querySelector('[data-testid="runtime-provider-setup-submit-error"]')?.textContent
).toContain('/opt/homebrew/bin/opencode');
expect(host.querySelector('[data-testid="runtime-provider-models-error"]')?.textContent).toContain(
'Provider models failed before JSON.'
);
expect(host.querySelector('[data-testid="runtime-provider-models-error"]')?.textContent).toContain(
'opencode providers'
);
});
it('renders provider directory errors with preserved multiline details', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const message = [
'OpenCode provider settings could not read the runtime response.',
'stderr preview:',
'runtime crashed before JSON',
].join('\n');
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
directoryError: message,
directoryLoaded: true,
}),
actions: createActions(),
disabled: false,
})
);
await Promise.resolve();
});
const alert = host.querySelector<HTMLElement>(
'[data-testid="runtime-provider-directory-error"]'
);
const details = alert?.querySelector('pre');
expect(alert?.getAttribute('role')).toBe('alert');
expect(details?.textContent).toContain('stderr preview:');
expect(details?.textContent).toContain('runtime crashed before JSON');
expect(details?.className).toContain('whitespace-pre-wrap');
});
it('keeps project context out of the runtime summary and labels it as validation context', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
@ -554,6 +950,57 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(duplicateKeyWarnings).toHaveLength(0);
});
it('renders duplicate structured diagnostic hints without React key warnings', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
error: 'OpenCode provider settings are using the wrong runtime binary.',
errorDiagnostics: {
summary: 'OpenCode provider settings are using the wrong runtime binary.',
likelyCause:
'The app resolved the OpenCode CLI itself as the Agent Teams runtime binary.',
binaryPath: '/opt/homebrew/bin/opencode',
command:
'/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact',
projectPath: null,
exitCode: null,
stderrPreview: null,
stdoutPreview: null,
hints: [
'Those environment variables must not point to opencode.',
'Those environment variables must not point to opencode.',
],
},
}),
actions: createActions(),
disabled: false,
})
);
await Promise.resolve();
});
const duplicateHints = host.textContent?.match(
/Those environment variables must not point to opencode\./g
);
const duplicateKeyWarnings = consoleError.mock.calls.filter((call) =>
call.some(
(argument) =>
typeof argument === 'string' &&
argument.includes('Encountered two children with the same key')
)
);
consoleError.mockRestore();
expect(duplicateHints).toHaveLength(2);
expect(duplicateKeyWarnings).toHaveLength(0);
});
it('renders provider actions and opens API-key form state without exposing a raw secret', async () => {
const host = document.createElement('div');
document.body.appendChild(host);

View file

@ -16,7 +16,10 @@ import {
import type {
RuntimeProviderConnectionDto,
RuntimeProviderDirectoryEntryDto,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementViewDto,
RuntimeProviderManagementViewResponse,
} from '../../../../src/features/runtime-provider-management/contracts';
@ -112,6 +115,20 @@ describe('useRuntimeProviderManagement', () => {
return React.createElement('div');
}
function ConfigurableHarness(props: {
enabled: boolean;
projectPath?: string | null;
}): React.ReactElement {
const hook = useRuntimeProviderManagement({
runtimeId: 'opencode',
enabled: props.enabled,
projectPath: props.projectPath,
});
state = hook[0];
actions = hook[1];
return React.createElement('div');
}
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
host = document.createElement('div');
@ -174,6 +191,174 @@ describe('useRuntimeProviderManagement', () => {
});
});
it('clears structured errors and stale provider state when disabled', async () => {
const loadView = vi.fn(() =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
error: {
code: 'runtime-misconfigured',
message: 'OpenCode provider settings are using the wrong runtime binary.',
recoverable: true,
diagnostics: {
summary: 'OpenCode provider settings are using the wrong runtime binary.',
likelyCause:
'The app resolved the OpenCode CLI itself as the Agent Teams runtime binary.',
binaryPath: '/opt/homebrew/bin/opencode',
command:
'/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact',
projectPath: null,
exitCode: null,
stderrPreview: null,
stdoutPreview: null,
hints: ['Those environment variables must not point to opencode.'],
},
},
})
);
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
runtimeProviderManagement: {
loadView,
},
} as unknown as ElectronAPI,
});
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ConfigurableHarness, { enabled: true }));
await Promise.resolve();
await Promise.resolve();
});
expect(state?.error).toContain('wrong runtime binary');
expect(state?.errorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode');
await act(async () => {
root.render(React.createElement(ConfigurableHarness, { enabled: false }));
await Promise.resolve();
});
expect(state?.view).toBeNull();
expect(state?.selectedProviderId).toBeNull();
expect(state?.error).toBeNull();
expect(state?.errorDiagnostics).toBeNull();
expect(state?.loading).toBe(false);
});
it('ignores pending directory and setup-form responses after being disabled', async () => {
let resolveDirectory:
| ((response: RuntimeProviderManagementDirectoryResponse) => void)
| null = null;
let resolveSetupForm:
| ((response: RuntimeProviderManagementSetupFormResponse) => void)
| null = null;
const directoryResponse = new Promise<RuntimeProviderManagementDirectoryResponse>(
(resolve) => {
resolveDirectory = resolve;
}
);
const setupFormResponse = new Promise<RuntimeProviderManagementSetupFormResponse>(
(resolve) => {
resolveSetupForm = resolve;
}
);
const loadView = vi.fn(() =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
view: createRuntimeView(),
})
);
const loadProviderDirectory = vi.fn(() => directoryResponse);
const loadSetupForm = vi.fn(() => setupFormResponse);
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
runtimeProviderManagement: {
loadView,
loadProviderDirectory,
loadSetupForm,
},
} as unknown as ElectronAPI,
});
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ConfigurableHarness, { enabled: true }));
await Promise.resolve();
});
await act(async () => {
await vi.waitFor(() => {
expect(loadProviderDirectory).toHaveBeenCalled();
});
actions?.startConnect('openrouter');
});
await act(async () => {
await vi.waitFor(() => {
expect(loadSetupForm).toHaveBeenCalled();
});
});
await act(async () => {
root.render(React.createElement(ConfigurableHarness, { enabled: false }));
await Promise.resolve();
});
await act(async () => {
resolveDirectory?.({
schemaVersion: 1,
runtimeId: 'opencode',
directory: {
runtimeId: 'opencode',
totalCount: 1,
returnedCount: 1,
query: null,
filter: 'all',
limit: 50,
cursor: null,
nextCursor: null,
entries: [createOpenAiLocalDirectoryEntry()],
diagnostics: [],
fetchedAt: '2026-05-22T00:00:00.000Z',
},
});
resolveSetupForm?.({
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: [],
},
});
await Promise.resolve();
await Promise.resolve();
});
expect(state?.directoryEntries).toEqual([]);
expect(state?.directoryLoaded).toBe(false);
expect(state?.setupForm).toBeNull();
expect(state?.activeFormProviderId).toBeNull();
expect(state?.setupFormLoading).toBe(false);
});
it('ignores stale provider views after project context changes', async () => {
let resolveProjectA:
| ((response: {
@ -246,6 +431,143 @@ describe('useRuntimeProviderManagement', () => {
});
});
it('restarts provider directory loading when project context changes while loading', async () => {
let resolveProjectADirectory:
| ((response: RuntimeProviderManagementDirectoryResponse) => void)
| null = null;
let resolveProjectBDirectory:
| ((response: RuntimeProviderManagementDirectoryResponse) => void)
| null = null;
const projectBEntry: RuntimeProviderDirectoryEntryDto = {
...createOpenAiLocalDirectoryEntry(),
providerId: 'project-b-provider',
displayName: 'Project B Provider',
};
const loadView = vi.fn((input: { projectPath?: string | null }) =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
view: {
...createRuntimeView(),
projectPath: input.projectPath ?? null,
},
})
);
const loadProviderDirectory = vi.fn((input: { projectPath?: string | null }) => {
if (input.projectPath === '/tmp/project-a') {
return new Promise<RuntimeProviderManagementDirectoryResponse>((resolve) => {
resolveProjectADirectory = resolve;
});
}
return new Promise<RuntimeProviderManagementDirectoryResponse>((resolve) => {
resolveProjectBDirectory = resolve;
});
});
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
runtimeProviderManagement: {
loadView,
loadProviderDirectory,
},
} as unknown as ElectronAPI,
});
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' }));
await Promise.resolve();
});
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 10));
await vi.waitFor(() => {
expect(loadProviderDirectory).toHaveBeenCalledWith({
runtimeId: 'opencode',
projectPath: '/tmp/project-a',
query: null,
filter: 'all',
limit: 50,
cursor: null,
refresh: false,
});
});
});
await act(async () => {
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' }));
await Promise.resolve();
});
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 10));
await vi.waitFor(() => {
expect(loadProviderDirectory).toHaveBeenCalledWith({
runtimeId: 'opencode',
projectPath: '/tmp/project-b',
query: null,
filter: 'all',
limit: 50,
cursor: null,
refresh: false,
});
});
});
await act(async () => {
resolveProjectBDirectory?.({
schemaVersion: 1,
runtimeId: 'opencode',
directory: {
runtimeId: 'opencode',
totalCount: 1,
returnedCount: 1,
query: null,
filter: 'all',
limit: 50,
cursor: null,
nextCursor: null,
fetchedAt: '2026-05-22T00:00:00.000Z',
entries: [projectBEntry],
diagnostics: [],
},
});
await Promise.resolve();
});
expect(state?.directoryEntries.map((entry) => entry.providerId)).toEqual([
'project-b-provider',
]);
await act(async () => {
resolveProjectADirectory?.({
schemaVersion: 1,
runtimeId: 'opencode',
directory: {
runtimeId: 'opencode',
totalCount: 1,
returnedCount: 1,
query: null,
filter: 'all',
limit: 50,
cursor: null,
nextCursor: null,
fetchedAt: '2026-05-22T00:00:00.000Z',
entries: [createOpenAiLocalDirectoryEntry()],
diagnostics: [],
},
});
await Promise.resolve();
});
expect(state?.directoryEntries.map((entry) => entry.providerId)).toEqual([
'project-b-provider',
]);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('drops stale model probe results after project context changes', async () => {
const modelId = 'llama.cpp/qwen-test:0.5b';
let resolveProbe: ((value: RuntimeProviderManagementModelTestResponse) => void) | null = null;
@ -413,6 +735,153 @@ describe('useRuntimeProviderManagement', () => {
});
});
it('clears pending provider save state after project context changes', async () => {
const connectedProvider: RuntimeProviderConnectionDto = {
...createOpenAiLocalProvider(),
ownership: ['managed'],
detail: 'Connected via managed OpenCode credential',
};
let resolveConnect: ((value: RuntimeProviderManagementProviderResponse) => void) | null =
null;
const loadView = vi.fn((input: { projectPath?: string | null }) =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
view: {
...createRuntimeView(),
projectPath: input.projectPath ?? null,
defaultModel: input.projectPath === '/tmp/project-b' ? 'opencode/project-b' : null,
},
})
);
const loadProviderDirectory = vi.fn(() =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
directory: {
runtimeId: 'opencode',
totalCount: 1,
returnedCount: 1,
query: null,
filter: 'all',
limit: 50,
cursor: null,
nextCursor: null,
fetchedAt: '2026-04-25T00:00:00.000Z',
entries: [createOpenAiLocalDirectoryEntry()],
diagnostics: [],
},
})
);
const loadSetupForm = vi.fn(() =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
setupForm: {
runtimeId: 'opencode',
providerId: 'openai',
displayName: 'OpenAI',
method: 'api',
supported: true,
title: 'Connect OpenAI',
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(
() =>
new Promise<RuntimeProviderManagementProviderResponse>((resolve) => {
resolveConnect = resolve;
})
);
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
runtimeProviderManagement: {
loadView,
loadProviderDirectory,
loadSetupForm,
connectProvider,
},
} as unknown as ElectronAPI,
});
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' }));
await Promise.resolve();
});
await act(async () => {
actions?.startConnect('openai');
actions?.setApiKeyValue('sk-project-a');
await vi.waitFor(() => {
expect(loadSetupForm).toHaveBeenCalled();
});
});
let submitPromise: Promise<void> | null = null;
await act(async () => {
submitPromise = actions?.submitConnect('openai') ?? null;
await vi.waitFor(() => {
expect(connectProvider).toHaveBeenCalledWith({
runtimeId: 'opencode',
providerId: 'openai',
method: 'api',
apiKey: 'sk-project-a',
metadata: {},
projectPath: '/tmp/project-a',
});
});
await Promise.resolve();
});
expect(state?.savingProviderId).toBe('openai');
await act(async () => {
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' }));
await Promise.resolve();
await Promise.resolve();
});
await vi.waitFor(() => {
expect(loadView).toHaveBeenCalledWith({
runtimeId: 'opencode',
projectPath: '/tmp/project-b',
});
});
expect(state?.savingProviderId).toBeNull();
expect(state?.activeFormProviderId).toBeNull();
await act(async () => {
resolveConnect?.({
schemaVersion: 1,
runtimeId: 'opencode',
provider: connectedProvider,
});
await submitPromise;
});
expect(state?.view?.providers).toEqual([]);
expect(state?.savingProviderId).toBeNull();
expect(state?.setupSubmitError).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('refreshes view and catalog after forgetting managed auth while local auth remains', async () => {
const localProvider = createOpenAiLocalProvider();
const loadView = vi.fn(() =>
@ -1040,6 +1509,68 @@ describe('useRuntimeProviderManagement', () => {
expect(state?.apiKeyValue).toBe('sk-bad-value');
});
it('keeps setup form diagnostics available when submit is attempted after form load failure', async () => {
const loadSetupForm = vi.fn(() =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
error: {
code: 'runtime-misconfigured',
message: 'OpenCode provider settings are using the wrong runtime binary.',
recoverable: true,
diagnostics: {
summary: 'OpenCode provider settings are using the wrong runtime binary.',
likelyCause: 'The app resolved the OpenCode CLI itself as the runtime binary.',
binaryPath: '/opt/homebrew/bin/opencode',
command: '/opt/homebrew/bin/opencode runtime providers setup-form',
projectPath: null,
exitCode: null,
stderrPreview: null,
stdoutPreview: null,
hints: ['Those environment variables must not point to opencode.'],
},
},
})
);
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
runtimeProviderManagement: {
loadSetupForm,
},
} as unknown as ElectronAPI,
});
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
act(() => {
actions?.startConnect('openrouter');
});
await act(async () => {
await vi.waitFor(() => {
expect(loadSetupForm).toHaveBeenCalled();
});
});
expect(state?.setupFormError).toBe(
'OpenCode provider settings are using the wrong runtime binary.'
);
expect(state?.setupFormErrorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode');
await act(async () => {
await actions?.submitConnect('openrouter');
});
expect(state?.setupSubmitError).toBe(
'OpenCode provider settings are using the wrong runtime binary.'
);
expect(state?.setupSubmitErrorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode');
});
it('submits a supported setup form without a secret as a null API key', async () => {
const loadSetupForm = vi.fn(() =>
Promise.resolve({
@ -1443,6 +1974,47 @@ describe('useRuntimeProviderManagement', () => {
expect(state?.modelResults[modelId]?.message).toBe(message);
});
it('promotes structured model probe failures to the global diagnostics alert state', async () => {
const modelId = 'openrouter/anthropic/claude-3.5-haiku';
installRuntimeProviderManagementApi({
schemaVersion: 1,
runtimeId: 'opencode',
error: {
code: 'runtime-misconfigured',
message: 'OpenCode provider settings are using the wrong runtime binary.',
recoverable: true,
diagnostics: {
summary: 'OpenCode provider settings are using the wrong runtime binary.',
likelyCause: 'The app resolved the OpenCode CLI itself as the runtime binary.',
binaryPath: '/opt/homebrew/bin/opencode',
command: '/opt/homebrew/bin/opencode runtime providers test-model',
projectPath: null,
exitCode: null,
stderrPreview: null,
stdoutPreview: null,
hints: ['Those environment variables must not point to opencode.'],
},
},
});
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
await act(async () => {
await actions?.testModel('openrouter', modelId);
});
expect(state?.error).toBe('OpenCode provider settings are using the wrong runtime binary.');
expect(state?.errorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode');
expect(state?.modelResults[modelId]).toMatchObject({
ok: false,
message: 'OpenCode provider settings are using the wrong runtime binary.',
});
});
it('keeps successful model probes scoped to the model card instead of a global success banner', async () => {
const modelId = 'openrouter/openai/gpt-oss-20b:free';
installRuntimeProviderManagementApi({