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-runtime'
| 'unsupported-action' | 'unsupported-action'
| 'runtime-missing' | 'runtime-missing'
| 'runtime-misconfigured'
| 'runtime-unhealthy' | 'runtime-unhealthy'
| 'provider-missing' | 'provider-missing'
| 'auth-required' | 'auth-required'
@ -201,10 +202,24 @@ export type RuntimeProviderManagementErrorCodeDto =
| 'model-test-failed' | 'model-test-failed'
| 'unsupported-auth-method'; | '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 { export interface RuntimeProviderManagementErrorDto {
code: RuntimeProviderManagementErrorCodeDto; code: RuntimeProviderManagementErrorCodeDto;
message: string; message: string;
recoverable: boolean; recoverable: boolean;
diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null;
} }
export interface RuntimeProviderManagementViewResponse { export interface RuntimeProviderManagementViewResponse {

View file

@ -16,6 +16,7 @@ import type {
RuntimeProviderManagementConnectApiKeyInput, RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput, RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementErrorDto,
RuntimeProviderManagementForgetInput, RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput, RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadModelsInput,
@ -32,6 +33,85 @@ import type {
import type { IpcMain } from 'electron'; import type { IpcMain } from 'electron';
const logger = createLogger('Feature:RuntimeProviderManagement:IPC'); 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( export function registerRuntimeProviderManagementIpc(
ipcMain: IpcMain, ipcMain: IpcMain,
@ -46,15 +126,12 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.loadView(input); return await feature.loadView(input);
} catch (error) { } 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 { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load providers',
recoverable: true,
},
}; };
} }
} }
@ -69,15 +146,15 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.loadProviderDirectory(input); return await feature.loadProviderDirectory(input);
} catch (error) { } 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 { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider directory',
recoverable: true,
},
}; };
} }
} }
@ -92,15 +169,15 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.loadSetupForm(input); return await feature.loadSetupForm(input);
} catch (error) { } 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 { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider setup form',
recoverable: true,
},
}; };
} }
} }
@ -115,18 +192,15 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.connectProvider(input); return await feature.connectProvider(input);
} catch (error) { } catch (error) {
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider');
logger.error( logger.error(
'Failed to connect runtime provider', 'Failed to connect runtime provider',
error instanceof Error ? error.name : error getRuntimeProviderIpcConnectLogDetail(error)
); );
return { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('auth-failed', message),
code: 'auth-failed',
message: 'Failed to connect provider',
recoverable: true,
},
}; };
} }
} }
@ -141,18 +215,15 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.connectWithApiKey(input); return await feature.connectWithApiKey(input);
} catch (error) { } catch (error) {
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider');
logger.error( logger.error(
'Failed to connect runtime provider', 'Failed to connect runtime provider',
error instanceof Error ? error.name : error getRuntimeProviderIpcConnectLogDetail(error)
); );
return { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('auth-failed', message),
code: 'auth-failed',
message: 'Failed to connect provider',
recoverable: true,
},
}; };
} }
} }
@ -167,15 +238,12 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.forgetCredential(input); return await feature.forgetCredential(input);
} catch (error) { } 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 { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('unsupported-action', message),
code: 'unsupported-action',
message: error instanceof Error ? error.message : 'Failed to forget provider',
recoverable: true,
},
}; };
} }
} }
@ -190,15 +258,12 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.loadModels(input); return await feature.loadModels(input);
} catch (error) { } 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 { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider models',
recoverable: true,
},
}; };
} }
} }
@ -213,15 +278,12 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.testModel(input); return await feature.testModel(input);
} catch (error) { } 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 { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message),
code: 'model-test-failed',
message: error instanceof Error ? error.message : 'Failed to test model',
recoverable: true,
},
}; };
} }
} }
@ -236,15 +298,12 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.setDefaultModel(input); return await feature.setDefaultModel(input);
} catch (error) { } 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 { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message),
code: 'model-test-failed',
message: error instanceof Error ? error.message : 'Failed to set default model',
recoverable: true,
},
}; };
} }
} }

View file

@ -13,6 +13,7 @@ import type {
RuntimeProviderDefaultScopeDto, RuntimeProviderDefaultScopeDto,
RuntimeProviderDirectoryEntryDto, RuntimeProviderDirectoryEntryDto,
RuntimeProviderDirectoryFilterDto, RuntimeProviderDirectoryFilterDto,
RuntimeProviderManagementErrorDiagnosticsDto,
RuntimeProviderManagementRuntimeId, RuntimeProviderManagementRuntimeId,
RuntimeProviderManagementViewDto, RuntimeProviderManagementViewDto,
RuntimeProviderModelDto, RuntimeProviderModelDto,
@ -46,6 +47,7 @@ export interface RuntimeProviderManagementState {
directoryLoading: boolean; directoryLoading: boolean;
directoryRefreshing: boolean; directoryRefreshing: boolean;
directoryError: string | null; directoryError: string | null;
directoryErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
directoryEntries: readonly RuntimeProviderDirectoryEntryDto[]; directoryEntries: readonly RuntimeProviderDirectoryEntryDto[];
directoryTotalCount: number | null; directoryTotalCount: number | null;
directoryNextCursor: string | null; directoryNextCursor: string | null;
@ -56,7 +58,9 @@ export interface RuntimeProviderManagementState {
setupForm: RuntimeProviderSetupFormDto | null; setupForm: RuntimeProviderSetupFormDto | null;
setupFormLoading: boolean; setupFormLoading: boolean;
setupFormError: string | null; setupFormError: string | null;
setupFormErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
setupSubmitError: string | null; setupSubmitError: string | null;
setupSubmitErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
setupMetadata: Readonly<Record<string, string>>; setupMetadata: Readonly<Record<string, string>>;
apiKeyValue: string; apiKeyValue: string;
modelPickerProviderId: string | null; modelPickerProviderId: string | null;
@ -65,6 +69,7 @@ export interface RuntimeProviderManagementState {
models: readonly RuntimeProviderModelDto[]; models: readonly RuntimeProviderModelDto[];
modelsLoading: boolean; modelsLoading: boolean;
modelsError: string | null; modelsError: string | null;
modelsErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
selectedModelId: string | null; selectedModelId: string | null;
testingModelIds: readonly string[]; testingModelIds: readonly string[];
savingDefaultModelId: string | null; savingDefaultModelId: string | null;
@ -72,6 +77,7 @@ export interface RuntimeProviderManagementState {
loading: boolean; loading: boolean;
savingProviderId: string | null; savingProviderId: string | null;
error: string | null; error: string | null;
errorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
successMessage: string | null; successMessage: string | null;
} }
@ -219,6 +225,8 @@ export function useRuntimeProviderManagement(
const [directoryLoading, setDirectoryLoading] = useState(false); const [directoryLoading, setDirectoryLoading] = useState(false);
const [directoryRefreshing, setDirectoryRefreshing] = useState(false); const [directoryRefreshing, setDirectoryRefreshing] = useState(false);
const [directoryError, setDirectoryError] = useState<string | null>(null); const [directoryError, setDirectoryError] = useState<string | null>(null);
const [directoryErrorDiagnostics, setDirectoryErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [directoryEntries, setDirectoryEntries] = useState< const [directoryEntries, setDirectoryEntries] = useState<
readonly RuntimeProviderDirectoryEntryDto[] readonly RuntimeProviderDirectoryEntryDto[]
>([]); >([]);
@ -234,7 +242,11 @@ export function useRuntimeProviderManagement(
const [setupForm, setSetupForm] = useState<RuntimeProviderSetupFormDto | null>(null); const [setupForm, setSetupForm] = useState<RuntimeProviderSetupFormDto | null>(null);
const [setupFormLoading, setSetupFormLoading] = useState(false); const [setupFormLoading, setSetupFormLoading] = useState(false);
const [setupFormError, setSetupFormError] = useState<string | null>(null); const [setupFormError, setSetupFormError] = useState<string | null>(null);
const [setupFormErrorDiagnostics, setSetupFormErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [setupSubmitError, setSetupSubmitError] = useState<string | null>(null); const [setupSubmitError, setSetupSubmitError] = useState<string | null>(null);
const [setupSubmitErrorDiagnostics, setSetupSubmitErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [setupMetadata, setSetupMetadata] = useState<Record<string, string>>({}); const [setupMetadata, setSetupMetadata] = useState<Record<string, string>>({});
const [apiKeyValue, setApiKeyValue] = useState(''); const [apiKeyValue, setApiKeyValue] = useState('');
const [modelPickerProviderId, setModelPickerProviderId] = useState<string | null>(null); const [modelPickerProviderId, setModelPickerProviderId] = useState<string | null>(null);
@ -245,6 +257,8 @@ export function useRuntimeProviderManagement(
const [models, setModels] = useState<readonly RuntimeProviderModelDto[]>([]); const [models, setModels] = useState<readonly RuntimeProviderModelDto[]>([]);
const [modelsLoading, setModelsLoading] = useState(false); const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null); const [modelsError, setModelsError] = useState<string | null>(null);
const [modelsErrorDiagnostics, setModelsErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null); const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [testingModelIds, setTestingModelIds] = useState<readonly string[]>([]); const [testingModelIds, setTestingModelIds] = useState<readonly string[]>([]);
const [savingDefaultModelId, setSavingDefaultModelId] = useState<string | null>(null); const [savingDefaultModelId, setSavingDefaultModelId] = useState<string | null>(null);
@ -254,6 +268,8 @@ export function useRuntimeProviderManagement(
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [savingProviderId, setSavingProviderId] = useState<string | null>(null); const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
const [error, setError] = 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 [successMessage, setSuccessMessage] = useState<string | null>(null);
const viewLoadRequestSeq = useRef(0); const viewLoadRequestSeq = useRef(0);
const directoryRequestSeq = useRef(0); const directoryRequestSeq = useRef(0);
@ -296,6 +312,7 @@ export function useRuntimeProviderManagement(
setModels([]); setModels([]);
setModelsLoading(false); setModelsLoading(false);
setModelsError(null); setModelsError(null);
setModelsErrorDiagnostics(null);
setSelectedModelId(null); setSelectedModelId(null);
setModelResults({}); setModelResults({});
setTestingModelIds([]); setTestingModelIds([]);
@ -313,6 +330,7 @@ export function useRuntimeProviderManagement(
setModels([]); setModels([]);
setModelsLoading(false); setModelsLoading(false);
setModelsError(null); setModelsError(null);
setModelsErrorDiagnostics(null);
setSelectedModelId(null); setSelectedModelId(null);
setModelResults({}); setModelResults({});
setTestingModelIds([]); setTestingModelIds([]);
@ -323,24 +341,31 @@ export function useRuntimeProviderManagement(
setupFormRequestSeq.current += 1; setupFormRequestSeq.current += 1;
modelLoadRequestSeq.current += 1; modelLoadRequestSeq.current += 1;
modelProbeGenerationRef.current += 1; modelProbeGenerationRef.current += 1;
setDirectoryLoading(false);
setDirectoryRefreshing(false);
setDirectoryEntries([]); setDirectoryEntries([]);
setDirectoryTotalCount(null); setDirectoryTotalCount(null);
setDirectoryNextCursor(null); setDirectoryNextCursor(null);
setDirectoryError(null); setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
setDirectorySelectedProviderId(null); setDirectorySelectedProviderId(null);
setDirectoryLoaded(false); setDirectoryLoaded(false);
setSetupForm(null); setSetupForm(null);
setSetupFormLoading(false); setSetupFormLoading(false);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setActiveFormProviderId(null); setActiveFormProviderId(null);
setApiKeyValue(''); setApiKeyValue('');
setSetupMetadata({}); setSetupMetadata({});
setModels([]); setModels([]);
setModelsLoading(false); setModelsLoading(false);
setModelsError(null); setModelsError(null);
setModelsErrorDiagnostics(null);
setSelectedModelId(null); setSelectedModelId(null);
setTestingModelIds([]); setTestingModelIds([]);
setSavingProviderId(null);
setSavingDefaultModelId(null); setSavingDefaultModelId(null);
setModelResults({}); setModelResults({});
setSuccessMessage(null); setSuccessMessage(null);
@ -361,6 +386,7 @@ export function useRuntimeProviderManagement(
setLoading(true); setLoading(true);
} }
setError(null); setError(null);
setErrorDiagnostics(null);
try { try {
const response = await api.runtimeProviderManagement.loadView({ const response = await api.runtimeProviderManagement.loadView({
runtimeId: options.runtimeId, runtimeId: options.runtimeId,
@ -374,6 +400,7 @@ export function useRuntimeProviderManagement(
setView(null); setView(null);
} }
setError(response.error.message); setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics ?? null);
return; return;
} }
const nextView = response.view ?? null; const nextView = response.view ?? null;
@ -392,6 +419,7 @@ export function useRuntimeProviderManagement(
setView(null); setView(null);
} }
setError(loadError instanceof Error ? loadError.message : 'Failed to load providers'); setError(loadError instanceof Error ? loadError.message : 'Failed to load providers');
setErrorDiagnostics(null);
} finally { } finally {
if (!silent && requestIsCurrent()) { if (!silent && requestIsCurrent()) {
setLoading(false); setLoading(false);
@ -434,6 +462,7 @@ export function useRuntimeProviderManagement(
setDirectoryLoading(true); setDirectoryLoading(true);
} }
setDirectoryError(null); setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
try { try {
const response = await api.runtimeProviderManagement.loadProviderDirectory({ const response = await api.runtimeProviderManagement.loadProviderDirectory({
@ -450,6 +479,7 @@ export function useRuntimeProviderManagement(
} }
if (response.error) { if (response.error) {
setDirectoryError(response.error.message); setDirectoryError(response.error.message);
setDirectoryErrorDiagnostics(response.error.diagnostics ?? null);
if ( if (
response.error.code === 'unsupported-action' || response.error.code === 'unsupported-action' ||
response.error.message.toLowerCase().includes('unknown command') response.error.message.toLowerCase().includes('unknown command')
@ -461,6 +491,7 @@ export function useRuntimeProviderManagement(
const directory = response.directory; const directory = response.directory;
if (!directory) { if (!directory) {
setDirectoryError('Provider directory response was empty'); setDirectoryError('Provider directory response was empty');
setDirectoryErrorDiagnostics(null);
return; return;
} }
setDirectoryLoaded(true); setDirectoryLoaded(true);
@ -474,6 +505,7 @@ export function useRuntimeProviderManagement(
setDirectoryError( setDirectoryError(
loadError instanceof Error ? loadError.message : 'Failed to load provider directory' loadError instanceof Error ? loadError.message : 'Failed to load provider directory'
); );
setDirectoryErrorDiagnostics(null);
} }
} finally { } finally {
if (requestIsCurrent()) { if (requestIsCurrent()) {
@ -495,23 +527,37 @@ export function useRuntimeProviderManagement(
useEffect(() => { useEffect(() => {
if (!options.enabled) { if (!options.enabled) {
viewLoadRequestSeq.current += 1; viewLoadRequestSeq.current += 1;
directoryRequestSeq.current += 1;
setupFormRequestSeq.current += 1;
appliedInitialProviderRef.current = null; appliedInitialProviderRef.current = null;
setView(null);
setSelectedProviderId(null);
setProviderQuery(''); setProviderQuery('');
setLoading(false);
setSavingProviderId(null);
setSavingDefaultModelId(null);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
setDirectoryLoading(false); setDirectoryLoading(false);
setDirectoryRefreshing(false); setDirectoryRefreshing(false);
setDirectoryError(null); setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
setDirectoryEntries([]); setDirectoryEntries([]);
setDirectoryTotalCount(null); setDirectoryTotalCount(null);
setDirectoryNextCursor(null); setDirectoryNextCursor(null);
setDirectoryQuery(''); setDirectoryQuery('');
setDirectoryLoaded(false); setDirectoryLoaded(false);
setDirectorySelectedProviderId(null); setDirectorySelectedProviderId(null);
setDirectorySupported(true);
setApiKeyValue(''); setApiKeyValue('');
setSetupMetadata({}); setSetupMetadata({});
setSetupForm(null); setSetupForm(null);
setSetupFormLoading(false); setSetupFormLoading(false);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setActiveFormProviderId(null); setActiveFormProviderId(null);
closeModelPickerState(); closeModelPickerState();
return; return;
@ -537,12 +583,20 @@ export function useRuntimeProviderManagement(
); );
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [directoryLoaded, directoryQuery, directorySupported, loadDirectoryPage, options.enabled]); }, [
currentProjectPath,
directoryLoaded,
directoryQuery,
directorySupported,
loadDirectoryPage,
options.enabled,
]);
useEffect(() => { useEffect(() => {
if (!options.enabled || !modelPickerProviderId) { if (!options.enabled || !modelPickerProviderId) {
modelLoadRequestSeq.current += 1; modelLoadRequestSeq.current += 1;
setModelsLoading(false); setModelsLoading(false);
setModelsErrorDiagnostics(null);
return; return;
} }
@ -557,6 +611,7 @@ export function useRuntimeProviderManagement(
let cancelled = false; let cancelled = false;
setModelsLoading(true); setModelsLoading(true);
setModelsError(null); setModelsError(null);
setModelsErrorDiagnostics(null);
void withUiTimeout( void withUiTimeout(
api.runtimeProviderManagement.loadModels({ api.runtimeProviderManagement.loadModels({
runtimeId: options.runtimeId, runtimeId: options.runtimeId,
@ -574,6 +629,7 @@ export function useRuntimeProviderManagement(
if (response.error) { if (response.error) {
setModels([]); setModels([]);
setModelsError(response.error.message); setModelsError(response.error.message);
setModelsErrorDiagnostics(response.error.diagnostics ?? null);
return; return;
} }
const nextModels = response.models?.models ?? []; const nextModels = response.models?.models ?? [];
@ -593,6 +649,7 @@ export function useRuntimeProviderManagement(
? modelsLoadError.message ? modelsLoadError.message
: 'Failed to load provider models' : 'Failed to load provider models'
); );
setModelsErrorDiagnostics(null);
} }
}) })
.finally(() => { .finally(() => {
@ -678,7 +735,9 @@ export function useRuntimeProviderManagement(
setActiveFormProviderId(null); setActiveFormProviderId(null);
setSetupForm(null); setSetupForm(null);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSetupMetadata({}); setSetupMetadata({});
setApiKeyValue(''); setApiKeyValue('');
@ -704,6 +763,7 @@ export function useRuntimeProviderManagement(
const searchAllProviders = useCallback((query: string): void => { const searchAllProviders = useCallback((query: string): void => {
setDirectoryQuery(query); setDirectoryQuery(query);
setDirectoryError(null); setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
setDirectoryNextCursor(null); setDirectoryNextCursor(null);
}, []); }, []);
@ -716,9 +776,12 @@ export function useRuntimeProviderManagement(
setSetupMetadata({}); setSetupMetadata({});
setSetupForm(null); setSetupForm(null);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSetupFormLoading(true); setSetupFormLoading(true);
setError(null); setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null); setSuccessMessage(null);
const projectContext = getProjectContextSnapshot(); const projectContext = getProjectContextSnapshot();
const requestSeq = setupFormRequestSeq.current + 1; const requestSeq = setupFormRequestSeq.current + 1;
@ -740,11 +803,13 @@ export function useRuntimeProviderManagement(
} }
if (response.error) { if (response.error) {
setSetupFormError(response.error.message); setSetupFormError(response.error.message);
setSetupFormErrorDiagnostics(response.error.diagnostics ?? null);
return; return;
} }
setSetupForm(response.setupForm ?? null); setSetupForm(response.setupForm ?? null);
if (!response.setupForm) { if (!response.setupForm) {
setSetupFormError('Provider setup form response was empty'); setSetupFormError('Provider setup form response was empty');
setSetupFormErrorDiagnostics(null);
} }
}) })
.catch((setupError) => { .catch((setupError) => {
@ -754,6 +819,7 @@ export function useRuntimeProviderManagement(
setSetupFormError( setSetupFormError(
setupError instanceof Error ? setupError.message : 'Failed to load provider setup form' setupError instanceof Error ? setupError.message : 'Failed to load provider setup form'
); );
setSetupFormErrorDiagnostics(null);
}) })
.finally(() => { .finally(() => {
if (requestIsCurrent()) { if (requestIsCurrent()) {
@ -784,13 +850,17 @@ export function useRuntimeProviderManagement(
setSetupForm(null); setSetupForm(null);
setSetupFormLoading(false); setSetupFormLoading(false);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setError(null); setError(null);
setErrorDiagnostics(null);
}, []); }, []);
const updateApiKeyValue = useCallback((value: string): void => { const updateApiKeyValue = useCallback((value: string): void => {
setApiKeyValue(value); setApiKeyValue(value);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
}, []); }, []);
const setSetupMetadataValue = useCallback((key: string, value: string): void => { const setSetupMetadataValue = useCallback((key: string, value: string): void => {
@ -799,29 +869,35 @@ export function useRuntimeProviderManagement(
[key]: value, [key]: value,
})); }));
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
}, []); }, []);
const submitConnect = useCallback( const submitConnect = useCallback(
async (providerId: string): Promise<void> => { async (providerId: string): Promise<void> => {
if (!setupForm) { if (!setupForm) {
setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded'); setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded');
setSetupSubmitErrorDiagnostics(setupFormErrorDiagnostics ?? null);
return; return;
} }
if (!setupForm.supported) { if (!setupForm.supported) {
setSetupSubmitError( setSetupSubmitError(
setupForm.disabledReason ?? 'Provider setup is not supported in the app' setupForm.disabledReason ?? 'Provider setup is not supported in the app'
); );
setSetupSubmitErrorDiagnostics(null);
return; return;
} }
const apiKey = apiKeyValue.trim(); const apiKey = apiKeyValue.trim();
if (setupForm.secret?.required && !apiKey) { if (setupForm.secret?.required && !apiKey) {
setSetupSubmitError(`${setupForm.secret.label} is required`); setSetupSubmitError(`${setupForm.secret.label} is required`);
setSetupSubmitErrorDiagnostics(null);
return; return;
} }
setSavingProviderId(providerId); setSavingProviderId(providerId);
setError(null); setError(null);
setErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSuccessMessage(null); setSuccessMessage(null);
const projectContext = getProjectContextSnapshot(); const projectContext = getProjectContextSnapshot();
try { try {
@ -841,6 +917,7 @@ export function useRuntimeProviderManagement(
} }
if (response.error) { if (response.error) {
setSetupSubmitError(response.error.message); setSetupSubmitError(response.error.message);
setSetupSubmitErrorDiagnostics(response.error.diagnostics ?? null);
return; return;
} }
if (response.provider) { if (response.provider) {
@ -852,7 +929,9 @@ export function useRuntimeProviderManagement(
setSetupMetadata({}); setSetupMetadata({});
setSetupForm(null); setSetupForm(null);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
try { try {
await options.onProviderChanged?.(); await options.onProviderChanged?.();
if (!isProjectContextCurrent(projectContext)) { if (!isProjectContextCurrent(projectContext)) {
@ -869,6 +948,7 @@ export function useRuntimeProviderManagement(
setError( setError(
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
); );
setErrorDiagnostics(null);
} }
} catch (connectError) { } catch (connectError) {
if (!isProjectContextCurrent(projectContext)) { if (!isProjectContextCurrent(projectContext)) {
@ -877,6 +957,7 @@ export function useRuntimeProviderManagement(
setSetupSubmitError( setSetupSubmitError(
connectError instanceof Error ? connectError.message : 'Failed to connect provider' connectError instanceof Error ? connectError.message : 'Failed to connect provider'
); );
setSetupSubmitErrorDiagnostics(null);
} finally { } finally {
if (isProjectContextCurrent(projectContext)) { if (isProjectContextCurrent(projectContext)) {
setSavingProviderId(null); setSavingProviderId(null);
@ -892,6 +973,7 @@ export function useRuntimeProviderManagement(
refresh, refresh,
setupForm, setupForm,
setupFormError, setupFormError,
setupFormErrorDiagnostics,
setupMetadata, setupMetadata,
] ]
); );
@ -900,6 +982,7 @@ export function useRuntimeProviderManagement(
async (providerId: string): Promise<void> => { async (providerId: string): Promise<void> => {
setSavingProviderId(providerId); setSavingProviderId(providerId);
setError(null); setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null); setSuccessMessage(null);
const projectContext = getProjectContextSnapshot(); const projectContext = getProjectContextSnapshot();
try { try {
@ -916,6 +999,7 @@ export function useRuntimeProviderManagement(
} }
if (response.error) { if (response.error) {
setError(response.error.message); setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics ?? null);
return; return;
} }
if (response.provider) { if (response.provider) {
@ -938,6 +1022,7 @@ export function useRuntimeProviderManagement(
setError( setError(
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
); );
setErrorDiagnostics(null);
} }
if (!isProjectContextCurrent(projectContext)) { if (!isProjectContextCurrent(projectContext)) {
return; return;
@ -950,6 +1035,7 @@ export function useRuntimeProviderManagement(
setError( setError(
forgetError instanceof Error ? forgetError.message : 'Failed to forget credential' forgetError instanceof Error ? forgetError.message : 'Failed to forget credential'
); );
setErrorDiagnostics(null);
} finally { } finally {
if (isProjectContextCurrent(projectContext)) { if (isProjectContextCurrent(projectContext)) {
setSavingProviderId(null); setSavingProviderId(null);
@ -965,6 +1051,7 @@ export function useRuntimeProviderManagement(
setActiveFormProviderId(null); setActiveFormProviderId(null);
openModelPickerState(providerId, mode); openModelPickerState(providerId, mode);
setError(null); setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null); setSuccessMessage(null);
}, },
[openModelPickerState] [openModelPickerState]
@ -979,6 +1066,7 @@ export function useRuntimeProviderManagement(
setSelectedModelId(modelId); setSelectedModelId(modelId);
setSuccessMessage(null); setSuccessMessage(null);
setError(null); setError(null);
setErrorDiagnostics(null);
}, []); }, []);
const testModel = useCallback( const testModel = useCallback(
@ -994,6 +1082,7 @@ export function useRuntimeProviderManagement(
current.includes(modelId) ? current : [...current, modelId] current.includes(modelId) ? current : [...current, modelId]
); );
setError(null); setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null); setSuccessMessage(null);
try { try {
const response = await withUiTimeout( const response = await withUiTimeout(
@ -1007,6 +1096,10 @@ export function useRuntimeProviderManagement(
100_000 100_000
); );
if (response.error) { if (response.error) {
if (response.error.diagnostics && shouldRecordProbeResult()) {
setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics);
}
if (shouldRecordProbeResult()) { if (shouldRecordProbeResult()) {
const result = buildFailedModelTestResult(providerId, modelId, response.error.message); const result = buildFailedModelTestResult(providerId, modelId, response.error.message);
setModelResults((current) => ({ setModelResults((current) => ({
@ -1064,6 +1157,7 @@ export function useRuntimeProviderManagement(
): Promise<void> => { ): Promise<void> => {
setSavingDefaultModelId(modelId); setSavingDefaultModelId(modelId);
setError(null); setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null); setSuccessMessage(null);
const projectContext = getProjectContextSnapshot(); const projectContext = getProjectContextSnapshot();
try { try {
@ -1084,6 +1178,7 @@ export function useRuntimeProviderManagement(
} }
if (response.error) { if (response.error) {
setError(response.error.message); setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics ?? null);
return; return;
} }
const proofResult: RuntimeProviderModelTestResultDto = { const proofResult: RuntimeProviderModelTestResultDto = {
@ -1130,6 +1225,7 @@ export function useRuntimeProviderManagement(
setError( setError(
defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default' defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default'
); );
setErrorDiagnostics(null);
} finally { } finally {
if (isProjectContextCurrent(projectContext)) { if (isProjectContextCurrent(projectContext)) {
setSavingDefaultModelId(null); setSavingDefaultModelId(null);
@ -1146,7 +1242,9 @@ export function useRuntimeProviderManagement(
setActiveFormProviderId(null); setActiveFormProviderId(null);
setSetupForm(null); setSetupForm(null);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSetupMetadata({}); setSetupMetadata({});
setApiKeyValue(''); setApiKeyValue('');
if (activeModelPickerProviderRef.current !== providerId) { if (activeModelPickerProviderRef.current !== providerId) {
@ -1199,6 +1297,7 @@ export function useRuntimeProviderManagement(
directoryLoading, directoryLoading,
directoryRefreshing, directoryRefreshing,
directoryError, directoryError,
directoryErrorDiagnostics,
directoryEntries, directoryEntries,
directoryTotalCount, directoryTotalCount,
directoryNextCursor, directoryNextCursor,
@ -1209,7 +1308,9 @@ export function useRuntimeProviderManagement(
setupForm, setupForm,
setupFormLoading, setupFormLoading,
setupFormError, setupFormError,
setupFormErrorDiagnostics,
setupSubmitError, setupSubmitError,
setupSubmitErrorDiagnostics,
setupMetadata, setupMetadata,
apiKeyValue, apiKeyValue,
modelPickerProviderId, modelPickerProviderId,
@ -1218,6 +1319,7 @@ export function useRuntimeProviderManagement(
models, models,
modelsLoading, modelsLoading,
modelsError, modelsError,
modelsErrorDiagnostics,
selectedModelId, selectedModelId,
testingModelIds, testingModelIds,
savingDefaultModelId, savingDefaultModelId,
@ -1225,18 +1327,22 @@ export function useRuntimeProviderManagement(
loading, loading,
savingProviderId, savingProviderId,
error, error,
errorDiagnostics,
successMessage, successMessage,
}), }),
[ [
activeFormProviderId, activeFormProviderId,
apiKeyValue, apiKeyValue,
setupForm, setupForm,
setupFormErrorDiagnostics,
setupFormError, setupFormError,
setupFormLoading, setupFormLoading,
setupSubmitErrorDiagnostics,
setupSubmitError, setupSubmitError,
setupMetadata, setupMetadata,
directoryEntries, directoryEntries,
directoryError, directoryError,
directoryErrorDiagnostics,
directoryLoaded, directoryLoaded,
directoryLoading, directoryLoading,
directoryNextCursor, directoryNextCursor,
@ -1245,12 +1351,14 @@ export function useRuntimeProviderManagement(
directorySupported, directorySupported,
directoryTotalCount, directoryTotalCount,
error, error,
errorDiagnostics,
loading, loading,
modelPickerMode, modelPickerMode,
modelPickerProviderId, modelPickerProviderId,
modelQuery, modelQuery,
modelResults, modelResults,
models, models,
modelsErrorDiagnostics,
modelsError, modelsError,
modelsLoading, modelsLoading,
providerQuery, 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 { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
@ -20,7 +20,9 @@ import {
} from '@renderer/utils/openCodeModelRecommendations'; } from '@renderer/utils/openCodeModelRecommendations';
import { import {
AlertTriangle, AlertTriangle,
Check,
CheckCircle2, CheckCircle2,
ClipboardList,
KeyRound, KeyRound,
Loader2, Loader2,
RefreshCcw, RefreshCcw,
@ -47,6 +49,7 @@ import type {
RuntimeProviderDefaultModelSourceDto, RuntimeProviderDefaultModelSourceDto,
RuntimeProviderDefaultScopeDto, RuntimeProviderDefaultScopeDto,
RuntimeProviderDirectoryEntryDto, RuntimeProviderDirectoryEntryDto,
RuntimeProviderManagementErrorDiagnosticsDto,
RuntimeProviderModelDto, RuntimeProviderModelDto,
RuntimeProviderModelTestResultDto, RuntimeProviderModelTestResultDto,
RuntimeProviderSetupPromptDto, RuntimeProviderSetupPromptDto,
@ -84,6 +87,12 @@ interface ProviderRowProps {
readonly actions: RuntimeProviderManagementActions; readonly actions: RuntimeProviderManagementActions;
} }
interface RuntimeProviderErrorAlertProps {
readonly message: string;
readonly diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null;
readonly testId: string;
}
type OpenCodeSettingsSection = 'models' | 'providers'; type OpenCodeSettingsSection = 'models' | 'providers';
const NO_PROJECT_CONTEXT_VALUE = '__runtime-provider-no-project-context__'; 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 form = state.setupForm?.providerId === provider.providerId ? state.setupForm : null;
const loading = state.setupFormLoading && state.activeFormProviderId === provider.providerId; const loading = state.setupFormLoading && state.activeFormProviderId === provider.providerId;
const error = state.setupFormError; const error = state.setupFormError;
const errorDiagnostics = state.setupFormErrorDiagnostics;
const submitError = const submitError =
state.activeFormProviderId === provider.providerId ? state.setupSubmitError : null; state.activeFormProviderId === provider.providerId ? state.setupSubmitError : null;
const submitErrorDiagnostics =
state.activeFormProviderId === provider.providerId ? state.setupSubmitErrorDiagnostics : null;
const canSubmit = setupFormCanSubmit(state, provider.providerId); const canSubmit = setupFormCanSubmit(state, provider.providerId);
return ( return (
@ -356,9 +368,11 @@ function ProviderSetupFormPanel({
) : null} ) : null}
{!loading && error ? ( {!loading && error ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200"> <RuntimeProviderErrorAlert
{error} message={error}
</div> diagnostics={errorDiagnostics}
testId="runtime-provider-setup-form-error"
/>
) : null} ) : null}
{!loading && form ? ( {!loading && form ? (
@ -445,8 +459,12 @@ function ProviderSetupFormPanel({
) : null} ) : null}
{submitError ? ( {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"> <div className="mt-3">
{submitError} <RuntimeProviderErrorAlert
message={submitError}
diagnostics={submitErrorDiagnostics}
testId="runtime-provider-setup-submit-error"
/>
</div> </div>
) : null} ) : 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 { function RuntimeProviderModelLoadingSkeleton(): JSX.Element {
return ( return (
<div className="space-y-2" data-testid="runtime-provider-model-loading-skeleton"> <div className="space-y-2" data-testid="runtime-provider-model-loading-skeleton">
@ -1756,9 +1996,11 @@ function ProviderModelList({
</div> </div>
{state.modelsError ? ( {state.modelsError ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200"> <RuntimeProviderErrorAlert
{state.modelsError} message={state.modelsError}
</div> diagnostics={state.modelsErrorDiagnostics}
testId="runtime-provider-models-error"
/>
) : null} ) : null}
<div <div
@ -1843,17 +2085,11 @@ export function RuntimeProviderManagementPanelView({
<RuntimeSummary state={state} disabled={disabled} onRefresh={() => void actions.refresh()} /> <RuntimeSummary state={state} disabled={disabled} onRefresh={() => void actions.refresh()} />
{state.error ? ( {state.error ? (
<div <RuntimeProviderErrorAlert
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs" message={state.error}
style={{ diagnostics={state.errorDiagnostics}
borderColor: 'rgba(248, 113, 113, 0.25)', testId="runtime-provider-error"
backgroundColor: 'rgba(248, 113, 113, 0.06)', />
color: '#fca5a5',
}}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{state.error}</span>
</div>
) : null} ) : null}
{state.successMessage ? ( {state.successMessage ? (
@ -1988,9 +2224,11 @@ export function RuntimeProviderManagementPanelView({
) : null} ) : null}
{state.directoryError ? ( {state.directoryError ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200"> <RuntimeProviderErrorAlert
{state.directoryError} message={state.directoryError}
</div> diagnostics={state.directoryErrorDiagnostics}
testId="runtime-provider-directory-error"
/>
) : null} ) : null}
<div className="max-h-[min(52vh,640px)] space-y-2 overflow-y-auto pr-1"> <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 { describe, expect, it, vi } from 'vitest';
import { registerRuntimeProviderManagementIpc } from '../../../../src/features/runtime-provider-management/main';
import { import {
RUNTIME_PROVIDER_MANAGEMENT_CONNECT, RUNTIME_PROVIDER_MANAGEMENT_CONNECT,
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY, RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
@ -9,16 +8,17 @@ import {
RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM, RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM,
RUNTIME_PROVIDER_MANAGEMENT_VIEW, RUNTIME_PROVIDER_MANAGEMENT_VIEW,
} from '../../../../src/features/runtime-provider-management/contracts'; } 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 { import type {
RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementProviderResponse, RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetupFormResponse, RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementViewResponse, RuntimeProviderManagementViewResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementModelTestResponse,
} from '../../../../src/features/runtime-provider-management/contracts'; } from '../../../../src/features/runtime-provider-management/contracts';
import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/features/runtime-provider-management/main';
import type { IpcMain } from 'electron'; import type { IpcMain } from 'electron';
describe('registerRuntimeProviderManagementIpc', () => { describe('registerRuntimeProviderManagementIpc', () => {
@ -234,4 +234,151 @@ describe('registerRuntimeProviderManagementIpc', () => {
limit: 10, 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, directoryLoading: false,
directoryRefreshing: false, directoryRefreshing: false,
directoryError: null, directoryError: null,
directoryErrorDiagnostics: null,
directoryEntries: [], directoryEntries: [],
directoryTotalCount: null, directoryTotalCount: null,
directoryNextCursor: null, directoryNextCursor: null,
@ -67,7 +68,9 @@ function createState(
setupForm: null, setupForm: null,
setupFormLoading: false, setupFormLoading: false,
setupFormError: null, setupFormError: null,
setupFormErrorDiagnostics: null,
setupSubmitError: null, setupSubmitError: null,
setupSubmitErrorDiagnostics: null,
setupMetadata: {}, setupMetadata: {},
apiKeyValue: '', apiKeyValue: '',
modelPickerProviderId: null, modelPickerProviderId: null,
@ -76,6 +79,7 @@ function createState(
models: [], models: [],
modelsLoading: false, modelsLoading: false,
modelsError: null, modelsError: null,
modelsErrorDiagnostics: null,
selectedModelId: null, selectedModelId: null,
testingModelIds: [], testingModelIds: [],
savingDefaultModelId: null, savingDefaultModelId: null,
@ -83,6 +87,7 @@ function createState(
loading: false, loading: false,
savingProviderId: null, savingProviderId: null,
error: null, error: null,
errorDiagnostics: null,
successMessage: null, successMessage: null,
...overrides, ...overrides,
}; };
@ -170,6 +175,397 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(host.textContent).not.toContain('No launchable OpenCode model routes were reported yet'); 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 () => { it('keeps project context out of the runtime summary and labels it as validation context', async () => {
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
@ -554,6 +950,57 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(duplicateKeyWarnings).toHaveLength(0); 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 () => { it('renders provider actions and opens API-key form state without exposing a raw secret', async () => {
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);

View file

@ -16,7 +16,10 @@ import {
import type { import type {
RuntimeProviderConnectionDto, RuntimeProviderConnectionDto,
RuntimeProviderDirectoryEntryDto, RuntimeProviderDirectoryEntryDto,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementViewDto, RuntimeProviderManagementViewDto,
RuntimeProviderManagementViewResponse, RuntimeProviderManagementViewResponse,
} from '../../../../src/features/runtime-provider-management/contracts'; } from '../../../../src/features/runtime-provider-management/contracts';
@ -112,6 +115,20 @@ describe('useRuntimeProviderManagement', () => {
return React.createElement('div'); 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(() => { beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
host = document.createElement('div'); 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 () => { it('ignores stale provider views after project context changes', async () => {
let resolveProjectA: let resolveProjectA:
| ((response: { | ((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 () => { it('drops stale model probe results after project context changes', async () => {
const modelId = 'llama.cpp/qwen-test:0.5b'; const modelId = 'llama.cpp/qwen-test:0.5b';
let resolveProbe: ((value: RuntimeProviderManagementModelTestResponse) => void) | null = null; 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 () => { it('refreshes view and catalog after forgetting managed auth while local auth remains', async () => {
const localProvider = createOpenAiLocalProvider(); const localProvider = createOpenAiLocalProvider();
const loadView = vi.fn(() => const loadView = vi.fn(() =>
@ -1040,6 +1509,68 @@ describe('useRuntimeProviderManagement', () => {
expect(state?.apiKeyValue).toBe('sk-bad-value'); 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 () => { it('submits a supported setup form without a secret as a null API key', async () => {
const loadSetupForm = vi.fn(() => const loadSetupForm = vi.fn(() =>
Promise.resolve({ Promise.resolve({
@ -1443,6 +1974,47 @@ describe('useRuntimeProviderManagement', () => {
expect(state?.modelResults[modelId]?.message).toBe(message); 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 () => { 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'; const modelId = 'openrouter/openai/gpt-oss-20b:free';
installRuntimeProviderManagementApi({ installRuntimeProviderManagementApi({