fix(runtime-provider-management): surface provider diagnostics
This commit is contained in:
parent
f8ceb85601
commit
6fb0c714ef
9 changed files with 3728 additions and 274 deletions
|
|
@ -193,6 +193,7 @@ export type RuntimeProviderManagementErrorCodeDto =
|
|||
| 'unsupported-runtime'
|
||||
| 'unsupported-action'
|
||||
| 'runtime-missing'
|
||||
| 'runtime-misconfigured'
|
||||
| 'runtime-unhealthy'
|
||||
| 'provider-missing'
|
||||
| 'auth-required'
|
||||
|
|
@ -201,10 +202,24 @@ export type RuntimeProviderManagementErrorCodeDto =
|
|||
| 'model-test-failed'
|
||||
| 'unsupported-auth-method';
|
||||
|
||||
export interface RuntimeProviderManagementErrorDiagnosticsDto {
|
||||
errorCode?: RuntimeProviderManagementErrorCodeDto | null;
|
||||
summary: string | null;
|
||||
likelyCause: string | null;
|
||||
binaryPath: string | null;
|
||||
command: string | null;
|
||||
projectPath: string | null;
|
||||
exitCode: number | null;
|
||||
stderrPreview: string | null;
|
||||
stdoutPreview: string | null;
|
||||
hints: readonly string[];
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementErrorDto {
|
||||
code: RuntimeProviderManagementErrorCodeDto;
|
||||
message: string;
|
||||
recoverable: boolean;
|
||||
diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementViewResponse {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
RuntimeProviderManagementConnectApiKeyInput,
|
||||
RuntimeProviderManagementConnectInput,
|
||||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementErrorDto,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadDirectoryInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
|
|
@ -32,6 +33,85 @@ import type {
|
|||
import type { IpcMain } from 'electron';
|
||||
|
||||
const logger = createLogger('Feature:RuntimeProviderManagement:IPC');
|
||||
const RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT = 1_600;
|
||||
const ESCAPE_CHARACTER = String.fromCharCode(27);
|
||||
const BELL_CHARACTER = String.fromCharCode(7);
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(`${ESCAPE_CHARACTER}\\[[0-?]*[ -/]*[@-~]`, 'g');
|
||||
const OSC_ESCAPE_PATTERN = new RegExp(
|
||||
`${ESCAPE_CHARACTER}\\][\\s\\S]*?(?:${BELL_CHARACTER}|${ESCAPE_CHARACTER}\\\\)`,
|
||||
'g'
|
||||
);
|
||||
|
||||
function truncateRuntimeProviderIpcErrorDetail(message: string): string {
|
||||
if (message.length <= RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT) {
|
||||
return message;
|
||||
}
|
||||
return `${message.slice(0, RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function sanitizeRuntimeProviderIpcErrorMessage(message: string): string {
|
||||
const sanitized = message
|
||||
.replace(OSC_ESCAPE_PATTERN, '')
|
||||
.replace(ANSI_ESCAPE_PATTERN, '')
|
||||
.replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, 'sk-...redacted')
|
||||
.replace(/\b(or-[A-Za-z0-9_-]{12,})\b/g, 'or-...redacted')
|
||||
.replace(/\b(AIza[A-Za-z0-9_-]{20,})\b/g, 'AIza...redacted')
|
||||
.replace(
|
||||
/\b([A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|[_-]key)["'\s:=]+)([A-Za-z0-9._~+/=_-]{12,})/gi,
|
||||
'$1...redacted'
|
||||
)
|
||||
.replace(/\b(key["'\s:=]+)([A-Za-z0-9._~+/=_-]{12,})/gi, '$1...redacted')
|
||||
.replace(/\b(bearer\s+)([A-Za-z0-9._~+/=_-]{12,})/gi, '$1...redacted')
|
||||
.trim();
|
||||
return truncateRuntimeProviderIpcErrorDetail(sanitized);
|
||||
}
|
||||
|
||||
function getRuntimeProviderIpcErrorMessage(error: unknown, fallback: string): string {
|
||||
if (typeof error === 'string') {
|
||||
return sanitizeRuntimeProviderIpcErrorMessage(error) || fallback;
|
||||
}
|
||||
if (!(error instanceof Error) || !error.message.trim()) {
|
||||
return fallback;
|
||||
}
|
||||
return sanitizeRuntimeProviderIpcErrorMessage(error.message) || fallback;
|
||||
}
|
||||
|
||||
function getRuntimeProviderIpcConnectLogDetail(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return sanitizeRuntimeProviderIpcErrorMessage(error.message) || error.name || 'Error';
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return sanitizeRuntimeProviderIpcErrorMessage(error) || 'Non-Error throw';
|
||||
}
|
||||
return 'Non-Error throw';
|
||||
}
|
||||
|
||||
function createUnexpectedRuntimeProviderIpcError(
|
||||
code: RuntimeProviderManagementErrorDto['code'],
|
||||
message: string
|
||||
): RuntimeProviderManagementErrorDto {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
recoverable: true,
|
||||
diagnostics: {
|
||||
errorCode: code,
|
||||
summary: message,
|
||||
likelyCause:
|
||||
'The desktop app runtime provider management handler failed before it returned a normal response.',
|
||||
binaryPath: null,
|
||||
command: null,
|
||||
projectPath: null,
|
||||
exitCode: null,
|
||||
stderrPreview: message,
|
||||
stdoutPreview: null,
|
||||
hints: [
|
||||
'Retry the action once after refreshing provider settings.',
|
||||
'If it repeats, copy diagnostics and attach the app logs from the same session.',
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerRuntimeProviderManagementIpc(
|
||||
ipcMain: IpcMain,
|
||||
|
|
@ -46,15 +126,12 @@ export function registerRuntimeProviderManagementIpc(
|
|||
try {
|
||||
return await feature.loadView(input);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load runtime provider management view', error);
|
||||
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to load providers');
|
||||
logger.error('Failed to load runtime provider management view', message);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'runtime-unhealthy',
|
||||
message: error instanceof Error ? error.message : 'Failed to load providers',
|
||||
recoverable: true,
|
||||
},
|
||||
error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -69,15 +146,15 @@ export function registerRuntimeProviderManagementIpc(
|
|||
try {
|
||||
return await feature.loadProviderDirectory(input);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load runtime provider directory', error);
|
||||
const message = getRuntimeProviderIpcErrorMessage(
|
||||
error,
|
||||
'Failed to load provider directory'
|
||||
);
|
||||
logger.error('Failed to load runtime provider directory', message);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'runtime-unhealthy',
|
||||
message: error instanceof Error ? error.message : 'Failed to load provider directory',
|
||||
recoverable: true,
|
||||
},
|
||||
error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -92,15 +169,15 @@ export function registerRuntimeProviderManagementIpc(
|
|||
try {
|
||||
return await feature.loadSetupForm(input);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load runtime provider setup form', error);
|
||||
const message = getRuntimeProviderIpcErrorMessage(
|
||||
error,
|
||||
'Failed to load provider setup form'
|
||||
);
|
||||
logger.error('Failed to load runtime provider setup form', message);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'runtime-unhealthy',
|
||||
message: error instanceof Error ? error.message : 'Failed to load provider setup form',
|
||||
recoverable: true,
|
||||
},
|
||||
error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -115,18 +192,15 @@ export function registerRuntimeProviderManagementIpc(
|
|||
try {
|
||||
return await feature.connectProvider(input);
|
||||
} catch (error) {
|
||||
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider');
|
||||
logger.error(
|
||||
'Failed to connect runtime provider',
|
||||
error instanceof Error ? error.name : error
|
||||
getRuntimeProviderIpcConnectLogDetail(error)
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'auth-failed',
|
||||
message: 'Failed to connect provider',
|
||||
recoverable: true,
|
||||
},
|
||||
error: createUnexpectedRuntimeProviderIpcError('auth-failed', message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -141,18 +215,15 @@ export function registerRuntimeProviderManagementIpc(
|
|||
try {
|
||||
return await feature.connectWithApiKey(input);
|
||||
} catch (error) {
|
||||
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider');
|
||||
logger.error(
|
||||
'Failed to connect runtime provider',
|
||||
error instanceof Error ? error.name : error
|
||||
getRuntimeProviderIpcConnectLogDetail(error)
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'auth-failed',
|
||||
message: 'Failed to connect provider',
|
||||
recoverable: true,
|
||||
},
|
||||
error: createUnexpectedRuntimeProviderIpcError('auth-failed', message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -167,15 +238,12 @@ export function registerRuntimeProviderManagementIpc(
|
|||
try {
|
||||
return await feature.forgetCredential(input);
|
||||
} catch (error) {
|
||||
logger.error('Failed to forget runtime provider credential', error);
|
||||
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to forget provider');
|
||||
logger.error('Failed to forget runtime provider credential', message);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'unsupported-action',
|
||||
message: error instanceof Error ? error.message : 'Failed to forget provider',
|
||||
recoverable: true,
|
||||
},
|
||||
error: createUnexpectedRuntimeProviderIpcError('unsupported-action', message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -190,15 +258,12 @@ export function registerRuntimeProviderManagementIpc(
|
|||
try {
|
||||
return await feature.loadModels(input);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load runtime provider models', error);
|
||||
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to load provider models');
|
||||
logger.error('Failed to load runtime provider models', message);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'runtime-unhealthy',
|
||||
message: error instanceof Error ? error.message : 'Failed to load provider models',
|
||||
recoverable: true,
|
||||
},
|
||||
error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -213,15 +278,12 @@ export function registerRuntimeProviderManagementIpc(
|
|||
try {
|
||||
return await feature.testModel(input);
|
||||
} catch (error) {
|
||||
logger.error('Failed to test runtime provider model', error);
|
||||
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to test model');
|
||||
logger.error('Failed to test runtime provider model', message);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'model-test-failed',
|
||||
message: error instanceof Error ? error.message : 'Failed to test model',
|
||||
recoverable: true,
|
||||
},
|
||||
error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -236,15 +298,12 @@ export function registerRuntimeProviderManagementIpc(
|
|||
try {
|
||||
return await feature.setDefaultModel(input);
|
||||
} catch (error) {
|
||||
logger.error('Failed to set runtime provider default model', error);
|
||||
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to set default model');
|
||||
logger.error('Failed to set runtime provider default model', message);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'model-test-failed',
|
||||
message: error instanceof Error ? error.message : 'Failed to set default model',
|
||||
recoverable: true,
|
||||
},
|
||||
error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,7 @@ import type {
|
|||
RuntimeProviderDefaultScopeDto,
|
||||
RuntimeProviderDirectoryEntryDto,
|
||||
RuntimeProviderDirectoryFilterDto,
|
||||
RuntimeProviderManagementErrorDiagnosticsDto,
|
||||
RuntimeProviderManagementRuntimeId,
|
||||
RuntimeProviderManagementViewDto,
|
||||
RuntimeProviderModelDto,
|
||||
|
|
@ -46,6 +47,7 @@ export interface RuntimeProviderManagementState {
|
|||
directoryLoading: boolean;
|
||||
directoryRefreshing: boolean;
|
||||
directoryError: string | null;
|
||||
directoryErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
|
||||
directoryEntries: readonly RuntimeProviderDirectoryEntryDto[];
|
||||
directoryTotalCount: number | null;
|
||||
directoryNextCursor: string | null;
|
||||
|
|
@ -56,7 +58,9 @@ export interface RuntimeProviderManagementState {
|
|||
setupForm: RuntimeProviderSetupFormDto | null;
|
||||
setupFormLoading: boolean;
|
||||
setupFormError: string | null;
|
||||
setupFormErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
|
||||
setupSubmitError: string | null;
|
||||
setupSubmitErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
|
||||
setupMetadata: Readonly<Record<string, string>>;
|
||||
apiKeyValue: string;
|
||||
modelPickerProviderId: string | null;
|
||||
|
|
@ -65,6 +69,7 @@ export interface RuntimeProviderManagementState {
|
|||
models: readonly RuntimeProviderModelDto[];
|
||||
modelsLoading: boolean;
|
||||
modelsError: string | null;
|
||||
modelsErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
|
||||
selectedModelId: string | null;
|
||||
testingModelIds: readonly string[];
|
||||
savingDefaultModelId: string | null;
|
||||
|
|
@ -72,6 +77,7 @@ export interface RuntimeProviderManagementState {
|
|||
loading: boolean;
|
||||
savingProviderId: string | null;
|
||||
error: string | null;
|
||||
errorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
|
||||
successMessage: string | null;
|
||||
}
|
||||
|
||||
|
|
@ -219,6 +225,8 @@ export function useRuntimeProviderManagement(
|
|||
const [directoryLoading, setDirectoryLoading] = useState(false);
|
||||
const [directoryRefreshing, setDirectoryRefreshing] = useState(false);
|
||||
const [directoryError, setDirectoryError] = useState<string | null>(null);
|
||||
const [directoryErrorDiagnostics, setDirectoryErrorDiagnostics] =
|
||||
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
|
||||
const [directoryEntries, setDirectoryEntries] = useState<
|
||||
readonly RuntimeProviderDirectoryEntryDto[]
|
||||
>([]);
|
||||
|
|
@ -234,7 +242,11 @@ export function useRuntimeProviderManagement(
|
|||
const [setupForm, setSetupForm] = useState<RuntimeProviderSetupFormDto | null>(null);
|
||||
const [setupFormLoading, setSetupFormLoading] = useState(false);
|
||||
const [setupFormError, setSetupFormError] = useState<string | null>(null);
|
||||
const [setupFormErrorDiagnostics, setSetupFormErrorDiagnostics] =
|
||||
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
|
||||
const [setupSubmitError, setSetupSubmitError] = useState<string | null>(null);
|
||||
const [setupSubmitErrorDiagnostics, setSetupSubmitErrorDiagnostics] =
|
||||
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
|
||||
const [setupMetadata, setSetupMetadata] = useState<Record<string, string>>({});
|
||||
const [apiKeyValue, setApiKeyValue] = useState('');
|
||||
const [modelPickerProviderId, setModelPickerProviderId] = useState<string | null>(null);
|
||||
|
|
@ -245,6 +257,8 @@ export function useRuntimeProviderManagement(
|
|||
const [models, setModels] = useState<readonly RuntimeProviderModelDto[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [modelsErrorDiagnostics, setModelsErrorDiagnostics] =
|
||||
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const [testingModelIds, setTestingModelIds] = useState<readonly string[]>([]);
|
||||
const [savingDefaultModelId, setSavingDefaultModelId] = useState<string | null>(null);
|
||||
|
|
@ -254,6 +268,8 @@ export function useRuntimeProviderManagement(
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorDiagnostics, setErrorDiagnostics] =
|
||||
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const viewLoadRequestSeq = useRef(0);
|
||||
const directoryRequestSeq = useRef(0);
|
||||
|
|
@ -296,6 +312,7 @@ export function useRuntimeProviderManagement(
|
|||
setModels([]);
|
||||
setModelsLoading(false);
|
||||
setModelsError(null);
|
||||
setModelsErrorDiagnostics(null);
|
||||
setSelectedModelId(null);
|
||||
setModelResults({});
|
||||
setTestingModelIds([]);
|
||||
|
|
@ -313,6 +330,7 @@ export function useRuntimeProviderManagement(
|
|||
setModels([]);
|
||||
setModelsLoading(false);
|
||||
setModelsError(null);
|
||||
setModelsErrorDiagnostics(null);
|
||||
setSelectedModelId(null);
|
||||
setModelResults({});
|
||||
setTestingModelIds([]);
|
||||
|
|
@ -323,24 +341,31 @@ export function useRuntimeProviderManagement(
|
|||
setupFormRequestSeq.current += 1;
|
||||
modelLoadRequestSeq.current += 1;
|
||||
modelProbeGenerationRef.current += 1;
|
||||
setDirectoryLoading(false);
|
||||
setDirectoryRefreshing(false);
|
||||
setDirectoryEntries([]);
|
||||
setDirectoryTotalCount(null);
|
||||
setDirectoryNextCursor(null);
|
||||
setDirectoryError(null);
|
||||
setDirectoryErrorDiagnostics(null);
|
||||
setDirectorySelectedProviderId(null);
|
||||
setDirectoryLoaded(false);
|
||||
setSetupForm(null);
|
||||
setSetupFormLoading(false);
|
||||
setSetupFormError(null);
|
||||
setSetupFormErrorDiagnostics(null);
|
||||
setSetupSubmitError(null);
|
||||
setSetupSubmitErrorDiagnostics(null);
|
||||
setActiveFormProviderId(null);
|
||||
setApiKeyValue('');
|
||||
setSetupMetadata({});
|
||||
setModels([]);
|
||||
setModelsLoading(false);
|
||||
setModelsError(null);
|
||||
setModelsErrorDiagnostics(null);
|
||||
setSelectedModelId(null);
|
||||
setTestingModelIds([]);
|
||||
setSavingProviderId(null);
|
||||
setSavingDefaultModelId(null);
|
||||
setModelResults({});
|
||||
setSuccessMessage(null);
|
||||
|
|
@ -361,6 +386,7 @@ export function useRuntimeProviderManagement(
|
|||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
setErrorDiagnostics(null);
|
||||
try {
|
||||
const response = await api.runtimeProviderManagement.loadView({
|
||||
runtimeId: options.runtimeId,
|
||||
|
|
@ -374,6 +400,7 @@ export function useRuntimeProviderManagement(
|
|||
setView(null);
|
||||
}
|
||||
setError(response.error.message);
|
||||
setErrorDiagnostics(response.error.diagnostics ?? null);
|
||||
return;
|
||||
}
|
||||
const nextView = response.view ?? null;
|
||||
|
|
@ -392,6 +419,7 @@ export function useRuntimeProviderManagement(
|
|||
setView(null);
|
||||
}
|
||||
setError(loadError instanceof Error ? loadError.message : 'Failed to load providers');
|
||||
setErrorDiagnostics(null);
|
||||
} finally {
|
||||
if (!silent && requestIsCurrent()) {
|
||||
setLoading(false);
|
||||
|
|
@ -434,6 +462,7 @@ export function useRuntimeProviderManagement(
|
|||
setDirectoryLoading(true);
|
||||
}
|
||||
setDirectoryError(null);
|
||||
setDirectoryErrorDiagnostics(null);
|
||||
|
||||
try {
|
||||
const response = await api.runtimeProviderManagement.loadProviderDirectory({
|
||||
|
|
@ -450,6 +479,7 @@ export function useRuntimeProviderManagement(
|
|||
}
|
||||
if (response.error) {
|
||||
setDirectoryError(response.error.message);
|
||||
setDirectoryErrorDiagnostics(response.error.diagnostics ?? null);
|
||||
if (
|
||||
response.error.code === 'unsupported-action' ||
|
||||
response.error.message.toLowerCase().includes('unknown command')
|
||||
|
|
@ -461,6 +491,7 @@ export function useRuntimeProviderManagement(
|
|||
const directory = response.directory;
|
||||
if (!directory) {
|
||||
setDirectoryError('Provider directory response was empty');
|
||||
setDirectoryErrorDiagnostics(null);
|
||||
return;
|
||||
}
|
||||
setDirectoryLoaded(true);
|
||||
|
|
@ -474,6 +505,7 @@ export function useRuntimeProviderManagement(
|
|||
setDirectoryError(
|
||||
loadError instanceof Error ? loadError.message : 'Failed to load provider directory'
|
||||
);
|
||||
setDirectoryErrorDiagnostics(null);
|
||||
}
|
||||
} finally {
|
||||
if (requestIsCurrent()) {
|
||||
|
|
@ -495,23 +527,37 @@ export function useRuntimeProviderManagement(
|
|||
useEffect(() => {
|
||||
if (!options.enabled) {
|
||||
viewLoadRequestSeq.current += 1;
|
||||
directoryRequestSeq.current += 1;
|
||||
setupFormRequestSeq.current += 1;
|
||||
appliedInitialProviderRef.current = null;
|
||||
setView(null);
|
||||
setSelectedProviderId(null);
|
||||
setProviderQuery('');
|
||||
setLoading(false);
|
||||
setSavingProviderId(null);
|
||||
setSavingDefaultModelId(null);
|
||||
setError(null);
|
||||
setErrorDiagnostics(null);
|
||||
setSuccessMessage(null);
|
||||
setDirectoryLoading(false);
|
||||
setDirectoryRefreshing(false);
|
||||
setDirectoryError(null);
|
||||
setDirectoryErrorDiagnostics(null);
|
||||
setDirectoryEntries([]);
|
||||
setDirectoryTotalCount(null);
|
||||
setDirectoryNextCursor(null);
|
||||
setDirectoryQuery('');
|
||||
setDirectoryLoaded(false);
|
||||
setDirectorySelectedProviderId(null);
|
||||
setDirectorySupported(true);
|
||||
setApiKeyValue('');
|
||||
setSetupMetadata({});
|
||||
setSetupForm(null);
|
||||
setSetupFormLoading(false);
|
||||
setSetupFormError(null);
|
||||
setSetupFormErrorDiagnostics(null);
|
||||
setSetupSubmitError(null);
|
||||
setSetupSubmitErrorDiagnostics(null);
|
||||
setActiveFormProviderId(null);
|
||||
closeModelPickerState();
|
||||
return;
|
||||
|
|
@ -537,12 +583,20 @@ export function useRuntimeProviderManagement(
|
|||
);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [directoryLoaded, directoryQuery, directorySupported, loadDirectoryPage, options.enabled]);
|
||||
}, [
|
||||
currentProjectPath,
|
||||
directoryLoaded,
|
||||
directoryQuery,
|
||||
directorySupported,
|
||||
loadDirectoryPage,
|
||||
options.enabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled || !modelPickerProviderId) {
|
||||
modelLoadRequestSeq.current += 1;
|
||||
setModelsLoading(false);
|
||||
setModelsErrorDiagnostics(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -557,6 +611,7 @@ export function useRuntimeProviderManagement(
|
|||
let cancelled = false;
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
setModelsErrorDiagnostics(null);
|
||||
void withUiTimeout(
|
||||
api.runtimeProviderManagement.loadModels({
|
||||
runtimeId: options.runtimeId,
|
||||
|
|
@ -574,6 +629,7 @@ export function useRuntimeProviderManagement(
|
|||
if (response.error) {
|
||||
setModels([]);
|
||||
setModelsError(response.error.message);
|
||||
setModelsErrorDiagnostics(response.error.diagnostics ?? null);
|
||||
return;
|
||||
}
|
||||
const nextModels = response.models?.models ?? [];
|
||||
|
|
@ -593,6 +649,7 @@ export function useRuntimeProviderManagement(
|
|||
? modelsLoadError.message
|
||||
: 'Failed to load provider models'
|
||||
);
|
||||
setModelsErrorDiagnostics(null);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
|
|
@ -678,7 +735,9 @@ export function useRuntimeProviderManagement(
|
|||
setActiveFormProviderId(null);
|
||||
setSetupForm(null);
|
||||
setSetupFormError(null);
|
||||
setSetupFormErrorDiagnostics(null);
|
||||
setSetupSubmitError(null);
|
||||
setSetupSubmitErrorDiagnostics(null);
|
||||
setSetupMetadata({});
|
||||
setApiKeyValue('');
|
||||
|
||||
|
|
@ -704,6 +763,7 @@ export function useRuntimeProviderManagement(
|
|||
const searchAllProviders = useCallback((query: string): void => {
|
||||
setDirectoryQuery(query);
|
||||
setDirectoryError(null);
|
||||
setDirectoryErrorDiagnostics(null);
|
||||
setDirectoryNextCursor(null);
|
||||
}, []);
|
||||
|
||||
|
|
@ -716,9 +776,12 @@ export function useRuntimeProviderManagement(
|
|||
setSetupMetadata({});
|
||||
setSetupForm(null);
|
||||
setSetupFormError(null);
|
||||
setSetupFormErrorDiagnostics(null);
|
||||
setSetupSubmitError(null);
|
||||
setSetupSubmitErrorDiagnostics(null);
|
||||
setSetupFormLoading(true);
|
||||
setError(null);
|
||||
setErrorDiagnostics(null);
|
||||
setSuccessMessage(null);
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
const requestSeq = setupFormRequestSeq.current + 1;
|
||||
|
|
@ -740,11 +803,13 @@ export function useRuntimeProviderManagement(
|
|||
}
|
||||
if (response.error) {
|
||||
setSetupFormError(response.error.message);
|
||||
setSetupFormErrorDiagnostics(response.error.diagnostics ?? null);
|
||||
return;
|
||||
}
|
||||
setSetupForm(response.setupForm ?? null);
|
||||
if (!response.setupForm) {
|
||||
setSetupFormError('Provider setup form response was empty');
|
||||
setSetupFormErrorDiagnostics(null);
|
||||
}
|
||||
})
|
||||
.catch((setupError) => {
|
||||
|
|
@ -754,6 +819,7 @@ export function useRuntimeProviderManagement(
|
|||
setSetupFormError(
|
||||
setupError instanceof Error ? setupError.message : 'Failed to load provider setup form'
|
||||
);
|
||||
setSetupFormErrorDiagnostics(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestIsCurrent()) {
|
||||
|
|
@ -784,13 +850,17 @@ export function useRuntimeProviderManagement(
|
|||
setSetupForm(null);
|
||||
setSetupFormLoading(false);
|
||||
setSetupFormError(null);
|
||||
setSetupFormErrorDiagnostics(null);
|
||||
setSetupSubmitError(null);
|
||||
setSetupSubmitErrorDiagnostics(null);
|
||||
setError(null);
|
||||
setErrorDiagnostics(null);
|
||||
}, []);
|
||||
|
||||
const updateApiKeyValue = useCallback((value: string): void => {
|
||||
setApiKeyValue(value);
|
||||
setSetupSubmitError(null);
|
||||
setSetupSubmitErrorDiagnostics(null);
|
||||
}, []);
|
||||
|
||||
const setSetupMetadataValue = useCallback((key: string, value: string): void => {
|
||||
|
|
@ -799,29 +869,35 @@ export function useRuntimeProviderManagement(
|
|||
[key]: value,
|
||||
}));
|
||||
setSetupSubmitError(null);
|
||||
setSetupSubmitErrorDiagnostics(null);
|
||||
}, []);
|
||||
|
||||
const submitConnect = useCallback(
|
||||
async (providerId: string): Promise<void> => {
|
||||
if (!setupForm) {
|
||||
setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded');
|
||||
setSetupSubmitErrorDiagnostics(setupFormErrorDiagnostics ?? null);
|
||||
return;
|
||||
}
|
||||
if (!setupForm.supported) {
|
||||
setSetupSubmitError(
|
||||
setupForm.disabledReason ?? 'Provider setup is not supported in the app'
|
||||
);
|
||||
setSetupSubmitErrorDiagnostics(null);
|
||||
return;
|
||||
}
|
||||
const apiKey = apiKeyValue.trim();
|
||||
if (setupForm.secret?.required && !apiKey) {
|
||||
setSetupSubmitError(`${setupForm.secret.label} is required`);
|
||||
setSetupSubmitErrorDiagnostics(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingProviderId(providerId);
|
||||
setError(null);
|
||||
setErrorDiagnostics(null);
|
||||
setSetupSubmitError(null);
|
||||
setSetupSubmitErrorDiagnostics(null);
|
||||
setSuccessMessage(null);
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
try {
|
||||
|
|
@ -841,6 +917,7 @@ export function useRuntimeProviderManagement(
|
|||
}
|
||||
if (response.error) {
|
||||
setSetupSubmitError(response.error.message);
|
||||
setSetupSubmitErrorDiagnostics(response.error.diagnostics ?? null);
|
||||
return;
|
||||
}
|
||||
if (response.provider) {
|
||||
|
|
@ -852,7 +929,9 @@ export function useRuntimeProviderManagement(
|
|||
setSetupMetadata({});
|
||||
setSetupForm(null);
|
||||
setSetupFormError(null);
|
||||
setSetupFormErrorDiagnostics(null);
|
||||
setSetupSubmitError(null);
|
||||
setSetupSubmitErrorDiagnostics(null);
|
||||
try {
|
||||
await options.onProviderChanged?.();
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
|
|
@ -869,6 +948,7 @@ export function useRuntimeProviderManagement(
|
|||
setError(
|
||||
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
||||
);
|
||||
setErrorDiagnostics(null);
|
||||
}
|
||||
} catch (connectError) {
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
|
|
@ -877,6 +957,7 @@ export function useRuntimeProviderManagement(
|
|||
setSetupSubmitError(
|
||||
connectError instanceof Error ? connectError.message : 'Failed to connect provider'
|
||||
);
|
||||
setSetupSubmitErrorDiagnostics(null);
|
||||
} finally {
|
||||
if (isProjectContextCurrent(projectContext)) {
|
||||
setSavingProviderId(null);
|
||||
|
|
@ -892,6 +973,7 @@ export function useRuntimeProviderManagement(
|
|||
refresh,
|
||||
setupForm,
|
||||
setupFormError,
|
||||
setupFormErrorDiagnostics,
|
||||
setupMetadata,
|
||||
]
|
||||
);
|
||||
|
|
@ -900,6 +982,7 @@ export function useRuntimeProviderManagement(
|
|||
async (providerId: string): Promise<void> => {
|
||||
setSavingProviderId(providerId);
|
||||
setError(null);
|
||||
setErrorDiagnostics(null);
|
||||
setSuccessMessage(null);
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
try {
|
||||
|
|
@ -916,6 +999,7 @@ export function useRuntimeProviderManagement(
|
|||
}
|
||||
if (response.error) {
|
||||
setError(response.error.message);
|
||||
setErrorDiagnostics(response.error.diagnostics ?? null);
|
||||
return;
|
||||
}
|
||||
if (response.provider) {
|
||||
|
|
@ -938,6 +1022,7 @@ export function useRuntimeProviderManagement(
|
|||
setError(
|
||||
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
||||
);
|
||||
setErrorDiagnostics(null);
|
||||
}
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
|
|
@ -950,6 +1035,7 @@ export function useRuntimeProviderManagement(
|
|||
setError(
|
||||
forgetError instanceof Error ? forgetError.message : 'Failed to forget credential'
|
||||
);
|
||||
setErrorDiagnostics(null);
|
||||
} finally {
|
||||
if (isProjectContextCurrent(projectContext)) {
|
||||
setSavingProviderId(null);
|
||||
|
|
@ -965,6 +1051,7 @@ export function useRuntimeProviderManagement(
|
|||
setActiveFormProviderId(null);
|
||||
openModelPickerState(providerId, mode);
|
||||
setError(null);
|
||||
setErrorDiagnostics(null);
|
||||
setSuccessMessage(null);
|
||||
},
|
||||
[openModelPickerState]
|
||||
|
|
@ -979,6 +1066,7 @@ export function useRuntimeProviderManagement(
|
|||
setSelectedModelId(modelId);
|
||||
setSuccessMessage(null);
|
||||
setError(null);
|
||||
setErrorDiagnostics(null);
|
||||
}, []);
|
||||
|
||||
const testModel = useCallback(
|
||||
|
|
@ -994,6 +1082,7 @@ export function useRuntimeProviderManagement(
|
|||
current.includes(modelId) ? current : [...current, modelId]
|
||||
);
|
||||
setError(null);
|
||||
setErrorDiagnostics(null);
|
||||
setSuccessMessage(null);
|
||||
try {
|
||||
const response = await withUiTimeout(
|
||||
|
|
@ -1007,6 +1096,10 @@ export function useRuntimeProviderManagement(
|
|||
100_000
|
||||
);
|
||||
if (response.error) {
|
||||
if (response.error.diagnostics && shouldRecordProbeResult()) {
|
||||
setError(response.error.message);
|
||||
setErrorDiagnostics(response.error.diagnostics);
|
||||
}
|
||||
if (shouldRecordProbeResult()) {
|
||||
const result = buildFailedModelTestResult(providerId, modelId, response.error.message);
|
||||
setModelResults((current) => ({
|
||||
|
|
@ -1064,6 +1157,7 @@ export function useRuntimeProviderManagement(
|
|||
): Promise<void> => {
|
||||
setSavingDefaultModelId(modelId);
|
||||
setError(null);
|
||||
setErrorDiagnostics(null);
|
||||
setSuccessMessage(null);
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
try {
|
||||
|
|
@ -1084,6 +1178,7 @@ export function useRuntimeProviderManagement(
|
|||
}
|
||||
if (response.error) {
|
||||
setError(response.error.message);
|
||||
setErrorDiagnostics(response.error.diagnostics ?? null);
|
||||
return;
|
||||
}
|
||||
const proofResult: RuntimeProviderModelTestResultDto = {
|
||||
|
|
@ -1130,6 +1225,7 @@ export function useRuntimeProviderManagement(
|
|||
setError(
|
||||
defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default'
|
||||
);
|
||||
setErrorDiagnostics(null);
|
||||
} finally {
|
||||
if (isProjectContextCurrent(projectContext)) {
|
||||
setSavingDefaultModelId(null);
|
||||
|
|
@ -1146,7 +1242,9 @@ export function useRuntimeProviderManagement(
|
|||
setActiveFormProviderId(null);
|
||||
setSetupForm(null);
|
||||
setSetupFormError(null);
|
||||
setSetupFormErrorDiagnostics(null);
|
||||
setSetupSubmitError(null);
|
||||
setSetupSubmitErrorDiagnostics(null);
|
||||
setSetupMetadata({});
|
||||
setApiKeyValue('');
|
||||
if (activeModelPickerProviderRef.current !== providerId) {
|
||||
|
|
@ -1199,6 +1297,7 @@ export function useRuntimeProviderManagement(
|
|||
directoryLoading,
|
||||
directoryRefreshing,
|
||||
directoryError,
|
||||
directoryErrorDiagnostics,
|
||||
directoryEntries,
|
||||
directoryTotalCount,
|
||||
directoryNextCursor,
|
||||
|
|
@ -1209,7 +1308,9 @@ export function useRuntimeProviderManagement(
|
|||
setupForm,
|
||||
setupFormLoading,
|
||||
setupFormError,
|
||||
setupFormErrorDiagnostics,
|
||||
setupSubmitError,
|
||||
setupSubmitErrorDiagnostics,
|
||||
setupMetadata,
|
||||
apiKeyValue,
|
||||
modelPickerProviderId,
|
||||
|
|
@ -1218,6 +1319,7 @@ export function useRuntimeProviderManagement(
|
|||
models,
|
||||
modelsLoading,
|
||||
modelsError,
|
||||
modelsErrorDiagnostics,
|
||||
selectedModelId,
|
||||
testingModelIds,
|
||||
savingDefaultModelId,
|
||||
|
|
@ -1225,18 +1327,22 @@ export function useRuntimeProviderManagement(
|
|||
loading,
|
||||
savingProviderId,
|
||||
error,
|
||||
errorDiagnostics,
|
||||
successMessage,
|
||||
}),
|
||||
[
|
||||
activeFormProviderId,
|
||||
apiKeyValue,
|
||||
setupForm,
|
||||
setupFormErrorDiagnostics,
|
||||
setupFormError,
|
||||
setupFormLoading,
|
||||
setupSubmitErrorDiagnostics,
|
||||
setupSubmitError,
|
||||
setupMetadata,
|
||||
directoryEntries,
|
||||
directoryError,
|
||||
directoryErrorDiagnostics,
|
||||
directoryLoaded,
|
||||
directoryLoading,
|
||||
directoryNextCursor,
|
||||
|
|
@ -1245,12 +1351,14 @@ export function useRuntimeProviderManagement(
|
|||
directorySupported,
|
||||
directoryTotalCount,
|
||||
error,
|
||||
errorDiagnostics,
|
||||
loading,
|
||||
modelPickerMode,
|
||||
modelPickerProviderId,
|
||||
modelQuery,
|
||||
modelResults,
|
||||
models,
|
||||
modelsErrorDiagnostics,
|
||||
modelsError,
|
||||
modelsLoading,
|
||||
providerQuery,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
|
|
@ -20,7 +20,9 @@ import {
|
|||
} from '@renderer/utils/openCodeModelRecommendations';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
|
|
@ -47,6 +49,7 @@ import type {
|
|||
RuntimeProviderDefaultModelSourceDto,
|
||||
RuntimeProviderDefaultScopeDto,
|
||||
RuntimeProviderDirectoryEntryDto,
|
||||
RuntimeProviderManagementErrorDiagnosticsDto,
|
||||
RuntimeProviderModelDto,
|
||||
RuntimeProviderModelTestResultDto,
|
||||
RuntimeProviderSetupPromptDto,
|
||||
|
|
@ -84,6 +87,12 @@ interface ProviderRowProps {
|
|||
readonly actions: RuntimeProviderManagementActions;
|
||||
}
|
||||
|
||||
interface RuntimeProviderErrorAlertProps {
|
||||
readonly message: string;
|
||||
readonly diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null;
|
||||
readonly testId: string;
|
||||
}
|
||||
|
||||
type OpenCodeSettingsSection = 'models' | 'providers';
|
||||
|
||||
const NO_PROJECT_CONTEXT_VALUE = '__runtime-provider-no-project-context__';
|
||||
|
|
@ -338,8 +347,11 @@ function ProviderSetupFormPanel({
|
|||
const form = state.setupForm?.providerId === provider.providerId ? state.setupForm : null;
|
||||
const loading = state.setupFormLoading && state.activeFormProviderId === provider.providerId;
|
||||
const error = state.setupFormError;
|
||||
const errorDiagnostics = state.setupFormErrorDiagnostics;
|
||||
const submitError =
|
||||
state.activeFormProviderId === provider.providerId ? state.setupSubmitError : null;
|
||||
const submitErrorDiagnostics =
|
||||
state.activeFormProviderId === provider.providerId ? state.setupSubmitErrorDiagnostics : null;
|
||||
const canSubmit = setupFormCanSubmit(state, provider.providerId);
|
||||
|
||||
return (
|
||||
|
|
@ -356,9 +368,11 @@ function ProviderSetupFormPanel({
|
|||
) : null}
|
||||
|
||||
{!loading && error ? (
|
||||
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
<RuntimeProviderErrorAlert
|
||||
message={error}
|
||||
diagnostics={errorDiagnostics}
|
||||
testId="runtime-provider-setup-form-error"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!loading && form ? (
|
||||
|
|
@ -445,8 +459,12 @@ function ProviderSetupFormPanel({
|
|||
) : null}
|
||||
|
||||
{submitError ? (
|
||||
<div className="mt-3 rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
|
||||
{submitError}
|
||||
<div className="mt-3">
|
||||
<RuntimeProviderErrorAlert
|
||||
message={submitError}
|
||||
diagnostics={submitErrorDiagnostics}
|
||||
testId="runtime-provider-setup-submit-error"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -668,6 +686,228 @@ function RuntimeProviderLoadingPlaceholder(): JSX.Element {
|
|||
);
|
||||
}
|
||||
|
||||
function formatRuntimeProviderDiagnosticsCopyText(
|
||||
message: string,
|
||||
diagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null | undefined
|
||||
): string {
|
||||
const lines = ['OpenCode provider settings diagnostics', '', 'Message:', message.trim()];
|
||||
if (!diagnostics) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
const hints = diagnostics.hints ?? [];
|
||||
|
||||
const fields: Array<[string, string | number | null]> = [
|
||||
['Error code', diagnostics.errorCode ?? null],
|
||||
['Summary', diagnostics.summary],
|
||||
['Likely cause', diagnostics.likelyCause],
|
||||
['Resolved runtime binary', diagnostics.binaryPath],
|
||||
['Command', diagnostics.command],
|
||||
['Project path', diagnostics.projectPath],
|
||||
['Exit code', diagnostics.exitCode],
|
||||
];
|
||||
|
||||
lines.push('', 'Structured diagnostics:');
|
||||
for (const [label, value] of fields) {
|
||||
if (value !== null && value !== '') {
|
||||
lines.push(`${label}: ${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hints.length > 0) {
|
||||
lines.push('', 'Hints:', ...hints.map((hint) => `- ${hint}`));
|
||||
}
|
||||
if (diagnostics.stderrPreview) {
|
||||
lines.push('', 'stderr preview:', diagnostics.stderrPreview);
|
||||
}
|
||||
if (diagnostics.stdoutPreview) {
|
||||
lines.push('', 'stdout preview:', diagnostics.stdoutPreview);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getRuntimeProviderDiagnosticRows(
|
||||
diagnostics: RuntimeProviderManagementErrorDiagnosticsDto
|
||||
): Array<[string, string]> {
|
||||
const rows: Array<[string, string | number | null]> = [
|
||||
['Code', diagnostics.errorCode ?? null],
|
||||
['Binary', diagnostics.binaryPath],
|
||||
['Command', diagnostics.command],
|
||||
['Project', diagnostics.projectPath],
|
||||
['Exit', diagnostics.exitCode],
|
||||
];
|
||||
return rows
|
||||
.filter(([, value]) => value !== null && value !== '')
|
||||
.map(([label, value]) => [label, String(value)]);
|
||||
}
|
||||
|
||||
async function writeRuntimeProviderDiagnosticsToClipboard(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall back to the selection API below.
|
||||
}
|
||||
}
|
||||
|
||||
return copyRuntimeProviderDiagnosticsWithSelection(text);
|
||||
}
|
||||
|
||||
function copyRuntimeProviderDiagnosticsWithSelection(text: string): boolean {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '-9999px';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
return document.execCommand('copy');
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
textarea.remove();
|
||||
}
|
||||
}
|
||||
|
||||
const RuntimeProviderErrorAlert = ({
|
||||
message,
|
||||
diagnostics = null,
|
||||
testId,
|
||||
}: RuntimeProviderErrorAlertProps): JSX.Element => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [headline = message, ...detailLines] = message.trim().split(/\r?\n/);
|
||||
const fallbackDetails = detailLines.join('\n').trim();
|
||||
const hints = diagnostics?.hints ?? [];
|
||||
const copyText = useMemo(
|
||||
() => formatRuntimeProviderDiagnosticsCopyText(message, diagnostics),
|
||||
[diagnostics, message]
|
||||
);
|
||||
const diagnosticRows = diagnostics ? getRuntimeProviderDiagnosticRows(diagnostics) : [];
|
||||
const copyDiagnostics = useCallback(async (): Promise<void> => {
|
||||
setCopied(await writeRuntimeProviderDiagnosticsToClipboard(copyText));
|
||||
}, [copyText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) {
|
||||
return;
|
||||
}
|
||||
const timeout = window.setTimeout(() => setCopied(false), 1_500);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [copied]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
role="alert"
|
||||
className="flex min-w-0 items-start gap-2 rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'rgba(248, 113, 113, 0.25)',
|
||||
backgroundColor: 'rgba(248, 113, 113, 0.06)',
|
||||
color: '#fca5a5',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0 whitespace-pre-wrap break-words font-medium leading-5">
|
||||
{headline || message}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-2 text-[11px]"
|
||||
title={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
|
||||
aria-label={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void copyDiagnostics();
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="mr-1 size-3" /> : <ClipboardList className="mr-1 size-3" />}
|
||||
{copied ? 'Copied' : 'Copy diagnostics'}
|
||||
</Button>
|
||||
</div>
|
||||
{diagnostics ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
{diagnostics.likelyCause ? (
|
||||
<div className="whitespace-pre-wrap break-words leading-5 text-red-100">
|
||||
<span className="font-medium text-red-100">Likely cause: </span>
|
||||
{diagnostics.likelyCause}
|
||||
</div>
|
||||
) : null}
|
||||
{diagnosticRows.length > 0 ? (
|
||||
<dl className="grid gap-1 rounded border px-2 py-1.5 text-[11px] leading-4 sm:grid-cols-[92px_minmax(0,1fr)]">
|
||||
{diagnosticRows.map(([label, value]) => (
|
||||
<div key={label} className="contents">
|
||||
<dt className="text-red-200/75">{label}</dt>
|
||||
<dd className="min-w-0 break-words font-mono text-red-100">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
{hints.length > 0 ? (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-red-100">Hints</div>
|
||||
<ul className="space-y-1 pl-4">
|
||||
{hints.map((hint, index) => (
|
||||
<li
|
||||
key={`${hint}-${index}`}
|
||||
className="list-disc whitespace-pre-wrap break-words"
|
||||
>
|
||||
{hint}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{diagnostics.stderrPreview ? (
|
||||
<pre
|
||||
data-testid={`${testId}-stderr-preview`}
|
||||
className="m-0 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded border px-2 py-1.5 font-mono text-[11px] leading-4"
|
||||
style={{
|
||||
borderColor: 'rgba(248, 113, 113, 0.2)',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.38)',
|
||||
color: '#fecaca',
|
||||
}}
|
||||
>
|
||||
{`stderr preview:\n${diagnostics.stderrPreview}`}
|
||||
</pre>
|
||||
) : null}
|
||||
{diagnostics.stdoutPreview ? (
|
||||
<pre
|
||||
data-testid={`${testId}-stdout-preview`}
|
||||
className="m-0 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded border px-2 py-1.5 font-mono text-[11px] leading-4"
|
||||
style={{
|
||||
borderColor: 'rgba(248, 113, 113, 0.2)',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.38)',
|
||||
color: '#fecaca',
|
||||
}}
|
||||
>
|
||||
{`stdout preview:\n${diagnostics.stdoutPreview}`}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : fallbackDetails ? (
|
||||
<pre
|
||||
className="m-0 mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded border px-2 py-1.5 font-mono text-[11px] leading-4"
|
||||
style={{
|
||||
borderColor: 'rgba(248, 113, 113, 0.2)',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.38)',
|
||||
color: '#fecaca',
|
||||
}}
|
||||
>
|
||||
{fallbackDetails}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function RuntimeProviderModelLoadingSkeleton(): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-2" data-testid="runtime-provider-model-loading-skeleton">
|
||||
|
|
@ -1756,9 +1996,11 @@ function ProviderModelList({
|
|||
</div>
|
||||
|
||||
{state.modelsError ? (
|
||||
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
|
||||
{state.modelsError}
|
||||
</div>
|
||||
<RuntimeProviderErrorAlert
|
||||
message={state.modelsError}
|
||||
diagnostics={state.modelsErrorDiagnostics}
|
||||
testId="runtime-provider-models-error"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
|
|
@ -1843,17 +2085,11 @@ export function RuntimeProviderManagementPanelView({
|
|||
<RuntimeSummary state={state} disabled={disabled} onRefresh={() => void actions.refresh()} />
|
||||
|
||||
{state.error ? (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'rgba(248, 113, 113, 0.25)',
|
||||
backgroundColor: 'rgba(248, 113, 113, 0.06)',
|
||||
color: '#fca5a5',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{state.error}</span>
|
||||
</div>
|
||||
<RuntimeProviderErrorAlert
|
||||
message={state.error}
|
||||
diagnostics={state.errorDiagnostics}
|
||||
testId="runtime-provider-error"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.successMessage ? (
|
||||
|
|
@ -1988,9 +2224,11 @@ export function RuntimeProviderManagementPanelView({
|
|||
) : null}
|
||||
|
||||
{state.directoryError ? (
|
||||
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
|
||||
{state.directoryError}
|
||||
</div>
|
||||
<RuntimeProviderErrorAlert
|
||||
message={state.directoryError}
|
||||
diagnostics={state.directoryErrorDiagnostics}
|
||||
testId="runtime-provider-directory-error"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-[min(52vh,640px)] space-y-2 overflow-y-auto pr-1">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerRuntimeProviderManagementIpc } from '../../../../src/features/runtime-provider-management/main';
|
||||
import {
|
||||
RUNTIME_PROVIDER_MANAGEMENT_CONNECT,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
|
||||
|
|
@ -9,16 +8,17 @@ import {
|
|||
RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
|
||||
} from '../../../../src/features/runtime-provider-management/contracts';
|
||||
import { registerRuntimeProviderManagementIpc } from '../../../../src/features/runtime-provider-management/main';
|
||||
|
||||
import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/features/runtime-provider-management/main';
|
||||
import type {
|
||||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementSetupFormResponse,
|
||||
RuntimeProviderManagementViewResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
} from '../../../../src/features/runtime-provider-management/contracts';
|
||||
import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/features/runtime-provider-management/main';
|
||||
import type { IpcMain } from 'electron';
|
||||
|
||||
describe('registerRuntimeProviderManagementIpc', () => {
|
||||
|
|
@ -234,4 +234,151 @@ describe('registerRuntimeProviderManagementIpc', () => {
|
|||
limit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes unexpected IPC error messages before returning them to the renderer', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
||||
const ipcMain = {
|
||||
handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
|
||||
handlers.set(channel, handler);
|
||||
}),
|
||||
removeHandler: vi.fn(),
|
||||
} as unknown as IpcMain;
|
||||
const feature: RuntimeProviderManagementFeatureFacade = {
|
||||
loadView: vi.fn(() =>
|
||||
Promise.reject(
|
||||
new Error(
|
||||
'\u001B]8;;https://logs.example/secret\u0007\u001B[31mProvider failed with api_key: sk-secret-value-123456 and Authorization: Bearer live-token-123456789 and key=AIzaSyD-test-secret-value-123456789 and OPENAI_API_KEY=plain_provider_secret_123456 and PROVIDER_TOKEN=provider_token_value_123456\u001B[0m\u001B]8;;\u0007'
|
||||
)
|
||||
)
|
||||
),
|
||||
loadProviderDirectory: vi.fn(),
|
||||
loadSetupForm: vi.fn(),
|
||||
connectProvider: vi.fn(),
|
||||
connectWithApiKey: vi.fn(),
|
||||
forgetCredential: vi.fn(),
|
||||
loadModels: vi.fn(),
|
||||
testModel: vi.fn(),
|
||||
setDefaultModel: vi.fn(),
|
||||
};
|
||||
|
||||
registerRuntimeProviderManagementIpc(ipcMain, feature);
|
||||
|
||||
const response = (await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_VIEW)?.(
|
||||
{},
|
||||
{ runtimeId: 'opencode' }
|
||||
)) as RuntimeProviderManagementViewResponse;
|
||||
|
||||
expect(response.error?.message).toContain('api_key: ...redacted');
|
||||
expect(response.error?.message).toContain('Authorization: Bearer ...redacted');
|
||||
expect(response.error?.message).toContain('key=...redacted');
|
||||
expect(response.error?.message).toContain('OPENAI_API_KEY=...redacted');
|
||||
expect(response.error?.message).toContain('PROVIDER_TOKEN=...redacted');
|
||||
expect(response.error?.message).not.toContain('sk-secret-value-123456');
|
||||
expect(response.error?.message).not.toContain('live-token-123456789');
|
||||
expect(response.error?.message).not.toContain('AIzaSyD-test-secret-value-123456789');
|
||||
expect(response.error?.message).not.toContain('plain_provider_secret_123456');
|
||||
expect(response.error?.message).not.toContain('provider_token_value_123456');
|
||||
expect(response.error?.message).not.toContain('logs.example/secret');
|
||||
expect(response.error?.message).not.toContain('[31m');
|
||||
expect(response.error?.message).not.toContain(']8;;');
|
||||
expect(response.error?.diagnostics?.summary).toContain('api_key: ...redacted');
|
||||
expect(response.error?.diagnostics?.errorCode).toBe('runtime-unhealthy');
|
||||
expect(response.error?.diagnostics?.stderrPreview).toContain(
|
||||
'Authorization: Bearer ...redacted'
|
||||
);
|
||||
expect(JSON.stringify(response.error?.diagnostics)).not.toContain('sk-secret-value-123456');
|
||||
expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('api_key: ...redacted');
|
||||
expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('key=...redacted');
|
||||
expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('sk-secret-value-123456');
|
||||
expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('live-token-123456789');
|
||||
expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain(
|
||||
'AIzaSyD-test-secret-value-123456789'
|
||||
);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('bounds unexpected IPC diagnostics before returning them to the renderer', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
||||
const ipcMain = {
|
||||
handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
|
||||
handlers.set(channel, handler);
|
||||
}),
|
||||
removeHandler: vi.fn(),
|
||||
} as unknown as IpcMain;
|
||||
const feature: RuntimeProviderManagementFeatureFacade = {
|
||||
loadView: vi.fn(() => Promise.reject(new Error(`x${'y'.repeat(3_000)}`))),
|
||||
loadProviderDirectory: vi.fn(),
|
||||
loadSetupForm: vi.fn(),
|
||||
connectProvider: vi.fn(),
|
||||
connectWithApiKey: vi.fn(),
|
||||
forgetCredential: vi.fn(),
|
||||
loadModels: vi.fn(),
|
||||
testModel: vi.fn(),
|
||||
setDefaultModel: vi.fn(),
|
||||
};
|
||||
|
||||
registerRuntimeProviderManagementIpc(ipcMain, feature);
|
||||
|
||||
const response = (await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_VIEW)?.(
|
||||
{},
|
||||
{ runtimeId: 'opencode' }
|
||||
)) as RuntimeProviderManagementViewResponse;
|
||||
|
||||
expect(response.error?.message.endsWith('...')).toBe(true);
|
||||
expect(response.error?.message.length).toBeLessThanOrEqual(1_603);
|
||||
expect(response.error?.diagnostics?.stderrPreview).toBe(response.error?.message);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not log raw secrets when connect handlers throw non-Error values', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
||||
const ipcMain = {
|
||||
handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
|
||||
handlers.set(channel, handler);
|
||||
}),
|
||||
removeHandler: vi.fn(),
|
||||
} as unknown as IpcMain;
|
||||
const feature: RuntimeProviderManagementFeatureFacade = {
|
||||
loadView: vi.fn(),
|
||||
loadProviderDirectory: vi.fn(),
|
||||
loadSetupForm: vi.fn(),
|
||||
connectProvider: vi.fn(() =>
|
||||
Promise.reject(
|
||||
'Provider failed with api_key: sk-secret-value-123456 and token=provider-token-123456789'
|
||||
)
|
||||
),
|
||||
connectWithApiKey: vi.fn(),
|
||||
forgetCredential: vi.fn(),
|
||||
loadModels: vi.fn(),
|
||||
testModel: vi.fn(),
|
||||
setDefaultModel: vi.fn(),
|
||||
};
|
||||
|
||||
registerRuntimeProviderManagementIpc(ipcMain, feature);
|
||||
|
||||
const response = (await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_CONNECT)?.(
|
||||
{},
|
||||
{
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'openrouter',
|
||||
method: 'api',
|
||||
apiKey: 'sk-input-secret-value',
|
||||
metadata: {},
|
||||
}
|
||||
)) as RuntimeProviderManagementProviderResponse;
|
||||
|
||||
expect(response.error?.message).toContain('api_key: ...redacted');
|
||||
expect(response.error?.message).toContain('token=...redacted');
|
||||
expect(response.error?.diagnostics?.errorCode).toBe('auth-failed');
|
||||
expect(response.error?.diagnostics?.stderrPreview).toContain('token=...redacted');
|
||||
expect(JSON.stringify(response)).not.toContain('sk-input-secret-value');
|
||||
expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('api_key: ...redacted');
|
||||
expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('token=...redacted');
|
||||
expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('sk-secret-value-123456');
|
||||
expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('provider-token-123456789');
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ function createState(
|
|||
directoryLoading: false,
|
||||
directoryRefreshing: false,
|
||||
directoryError: null,
|
||||
directoryErrorDiagnostics: null,
|
||||
directoryEntries: [],
|
||||
directoryTotalCount: null,
|
||||
directoryNextCursor: null,
|
||||
|
|
@ -67,7 +68,9 @@ function createState(
|
|||
setupForm: null,
|
||||
setupFormLoading: false,
|
||||
setupFormError: null,
|
||||
setupFormErrorDiagnostics: null,
|
||||
setupSubmitError: null,
|
||||
setupSubmitErrorDiagnostics: null,
|
||||
setupMetadata: {},
|
||||
apiKeyValue: '',
|
||||
modelPickerProviderId: null,
|
||||
|
|
@ -76,6 +79,7 @@ function createState(
|
|||
models: [],
|
||||
modelsLoading: false,
|
||||
modelsError: null,
|
||||
modelsErrorDiagnostics: null,
|
||||
selectedModelId: null,
|
||||
testingModelIds: [],
|
||||
savingDefaultModelId: null,
|
||||
|
|
@ -83,6 +87,7 @@ function createState(
|
|||
loading: false,
|
||||
savingProviderId: null,
|
||||
error: null,
|
||||
errorDiagnostics: null,
|
||||
successMessage: null,
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -170,6 +175,397 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
expect(host.textContent).not.toContain('No launchable OpenCode model routes were reported yet');
|
||||
});
|
||||
|
||||
it('renders runtime command errors with a readable headline and multiline details', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const message = [
|
||||
'OpenCode provider settings could not read the runtime response.',
|
||||
'Expected a JSON object from the Agent Teams runtime provider command.',
|
||||
'Resolved runtime binary: /opt/homebrew/bin/opencode',
|
||||
'Command: /opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact',
|
||||
'stdout preview:',
|
||||
'Commands:',
|
||||
' opencode providers',
|
||||
].join('\n');
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState({ error: message }),
|
||||
actions: createActions(),
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const alert = host.querySelector<HTMLElement>('[data-testid="runtime-provider-error"]');
|
||||
const details = alert?.querySelector('pre');
|
||||
|
||||
expect(alert?.getAttribute('role')).toBe('alert');
|
||||
expect(alert?.textContent).toContain(
|
||||
'OpenCode provider settings could not read the runtime response.'
|
||||
);
|
||||
expect(details?.textContent).toContain('Resolved runtime binary: /opt/homebrew/bin/opencode');
|
||||
expect(details?.textContent).toContain(' opencode providers');
|
||||
expect(details?.className).toContain('whitespace-pre-wrap');
|
||||
expect(details?.className).toContain('font-mono');
|
||||
});
|
||||
|
||||
it('copies fallback error text when structured diagnostics are unavailable', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const writeText = vi.fn((_text: string) => Promise.resolve());
|
||||
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState({
|
||||
error: 'Runtime provider crashed\nstderr preview:\nmissing bun',
|
||||
errorDiagnostics: null,
|
||||
}),
|
||||
actions: createActions(),
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
Array.from(host.querySelectorAll('button'))
|
||||
.find((button) => button.textContent?.includes('Copy diagnostics'))
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
'OpenCode provider settings diagnostics\n\nMessage:\nRuntime provider crashed\nstderr preview:\nmissing bun'
|
||||
);
|
||||
if (clipboardDescriptor) {
|
||||
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
}
|
||||
});
|
||||
|
||||
it('copies diagnostics with the selection fallback when clipboard API is unavailable', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
const execCommandDescriptor = Object.getOwnPropertyDescriptor(document, 'execCommand');
|
||||
const execCommand = vi.fn(() => true);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: execCommand,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState({
|
||||
error: 'Runtime provider crashed\nstderr preview:\nmissing bun',
|
||||
errorDiagnostics: null,
|
||||
}),
|
||||
actions: createActions(),
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
Array.from(host.querySelectorAll('button'))
|
||||
.find((button) => button.textContent?.includes('Copy diagnostics'))
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(execCommand).toHaveBeenCalledWith('copy');
|
||||
expect(host.textContent).toContain('Copied');
|
||||
expect(document.querySelector('textarea')).toBeNull();
|
||||
if (clipboardDescriptor) {
|
||||
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
}
|
||||
if (execCommandDescriptor) {
|
||||
Object.defineProperty(document, 'execCommand', execCommandDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(document, 'execCommand');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders structured runtime diagnostics and copies the full redacted report', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const writeText = vi.fn((_text: string) => Promise.resolve());
|
||||
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState({
|
||||
error: 'OpenCode provider settings could not read the runtime response.',
|
||||
errorDiagnostics: {
|
||||
errorCode: 'runtime-unhealthy',
|
||||
summary: 'OpenCode provider settings could not read the runtime response.',
|
||||
likelyCause:
|
||||
'The app is launching the OpenCode CLI itself instead of the Agent Teams runtime.',
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
command:
|
||||
'/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact',
|
||||
projectPath: '/Users/test/project',
|
||||
exitCode: 1,
|
||||
stderrPreview: 'Command failed before JSON',
|
||||
stdoutPreview: 'Commands:\n opencode providers',
|
||||
hints: [
|
||||
'Check CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH and CLAUDE_CLI_PATH.',
|
||||
'Those environment variables must not point to opencode.',
|
||||
],
|
||||
},
|
||||
}),
|
||||
actions: createActions(),
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Likely cause');
|
||||
expect(host.textContent).toContain('/opt/homebrew/bin/opencode');
|
||||
expect(host.textContent).toContain('Command failed before JSON');
|
||||
expect(
|
||||
host.querySelector('[data-testid="runtime-provider-error-stderr-preview"]')?.textContent
|
||||
).toContain('stderr preview');
|
||||
expect(
|
||||
host.querySelector('[data-testid="runtime-provider-error-stdout-preview"]')?.textContent
|
||||
).toContain('opencode providers');
|
||||
|
||||
await act(async () => {
|
||||
Array.from(host.querySelectorAll('button'))
|
||||
.find((button) => button.textContent?.includes('Copy diagnostics'))
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledTimes(1);
|
||||
expect(writeText.mock.calls[0][0]).toContain('OpenCode provider settings diagnostics');
|
||||
expect(writeText.mock.calls[0][0]).toContain('Error code: runtime-unhealthy');
|
||||
expect(writeText.mock.calls[0][0]).toContain('Resolved runtime binary: /opt/homebrew/bin/opencode');
|
||||
expect(writeText.mock.calls[0][0]).toContain('stderr preview:');
|
||||
expect(writeText.mock.calls[0][0]).toContain('stdout preview:');
|
||||
expect(host.textContent).toContain('Copied');
|
||||
if (clipboardDescriptor) {
|
||||
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not activate a provider row when copying model diagnostics', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const writeText = vi.fn((_text: string) => Promise.resolve());
|
||||
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const actions = createActions();
|
||||
const base = createState();
|
||||
const provider = {
|
||||
...base.view!.providers[0],
|
||||
state: 'connected' as const,
|
||||
modelCount: 2,
|
||||
actions: [
|
||||
{
|
||||
id: 'test' as const,
|
||||
label: 'Test',
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
requiresSecret: false,
|
||||
ownershipScope: 'runtime' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState({
|
||||
view: {
|
||||
...base.view!,
|
||||
providers: [provider],
|
||||
},
|
||||
providers: [provider],
|
||||
selectedProviderId: provider.providerId,
|
||||
modelPickerProviderId: provider.providerId,
|
||||
modelPickerMode: 'use',
|
||||
modelsError: 'Model list failed',
|
||||
modelsErrorDiagnostics: {
|
||||
summary: 'Model list failed',
|
||||
likelyCause: 'The runtime returned a malformed models response.',
|
||||
binaryPath: '/repo/cli-dev',
|
||||
command: '/repo/cli-dev runtime providers models --runtime opencode',
|
||||
projectPath: '/Users/test/project',
|
||||
exitCode: 1,
|
||||
stderrPreview: 'bad models payload',
|
||||
stdoutPreview: null,
|
||||
hints: ['Retry after refreshing the runtime.'],
|
||||
},
|
||||
}),
|
||||
actions,
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
Array.from(host.querySelectorAll('button'))
|
||||
.find((button) => button.textContent?.includes('Copy diagnostics'))
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledTimes(1);
|
||||
expect(actions.selectProvider).not.toHaveBeenCalled();
|
||||
expect(actions.startConnect).not.toHaveBeenCalled();
|
||||
if (clipboardDescriptor) {
|
||||
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders structured diagnostics in provider form and model picker errors', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const provider = {
|
||||
...createState().view!.providers[0],
|
||||
state: 'connected' as const,
|
||||
modelCount: 4,
|
||||
actions: [
|
||||
{
|
||||
id: 'test' as const,
|
||||
label: 'Test',
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
requiresSecret: false,
|
||||
ownershipScope: 'runtime' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState({
|
||||
providers: [provider],
|
||||
selectedProviderId: provider.providerId,
|
||||
activeFormProviderId: provider.providerId,
|
||||
modelPickerProviderId: provider.providerId,
|
||||
modelPickerMode: 'use',
|
||||
setupSubmitError: 'Provider connect failed before JSON.',
|
||||
setupSubmitErrorDiagnostics: {
|
||||
summary: 'Provider connect failed before JSON.',
|
||||
likelyCause: 'The runtime command printed CLI help instead of JSON.',
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
command: '/opt/homebrew/bin/opencode runtime providers connect',
|
||||
projectPath: null,
|
||||
exitCode: 1,
|
||||
stderrPreview: 'unknown command',
|
||||
stdoutPreview: 'Commands:\n opencode providers',
|
||||
hints: ['Check the resolved runtime binary.'],
|
||||
},
|
||||
modelsError: 'Provider models failed before JSON.',
|
||||
modelsErrorDiagnostics: {
|
||||
summary: 'Provider models failed before JSON.',
|
||||
likelyCause: 'The runtime command printed CLI help instead of JSON.',
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
command: '/opt/homebrew/bin/opencode runtime providers models',
|
||||
projectPath: null,
|
||||
exitCode: 1,
|
||||
stderrPreview: 'unknown command',
|
||||
stdoutPreview: 'Commands:\n opencode providers',
|
||||
hints: ['Check the resolved runtime binary.'],
|
||||
},
|
||||
}),
|
||||
actions: createActions(),
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
host.querySelector('[data-testid="runtime-provider-setup-submit-error"]')?.textContent
|
||||
).toContain('Provider connect failed before JSON.');
|
||||
expect(
|
||||
host.querySelector('[data-testid="runtime-provider-setup-submit-error"]')?.textContent
|
||||
).toContain('/opt/homebrew/bin/opencode');
|
||||
expect(host.querySelector('[data-testid="runtime-provider-models-error"]')?.textContent).toContain(
|
||||
'Provider models failed before JSON.'
|
||||
);
|
||||
expect(host.querySelector('[data-testid="runtime-provider-models-error"]')?.textContent).toContain(
|
||||
'opencode providers'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders provider directory errors with preserved multiline details', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const message = [
|
||||
'OpenCode provider settings could not read the runtime response.',
|
||||
'stderr preview:',
|
||||
'runtime crashed before JSON',
|
||||
].join('\n');
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState({
|
||||
directoryError: message,
|
||||
directoryLoaded: true,
|
||||
}),
|
||||
actions: createActions(),
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const alert = host.querySelector<HTMLElement>(
|
||||
'[data-testid="runtime-provider-directory-error"]'
|
||||
);
|
||||
const details = alert?.querySelector('pre');
|
||||
|
||||
expect(alert?.getAttribute('role')).toBe('alert');
|
||||
expect(details?.textContent).toContain('stderr preview:');
|
||||
expect(details?.textContent).toContain('runtime crashed before JSON');
|
||||
expect(details?.className).toContain('whitespace-pre-wrap');
|
||||
});
|
||||
|
||||
it('keeps project context out of the runtime summary and labels it as validation context', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -554,6 +950,57 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
expect(duplicateKeyWarnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders duplicate structured diagnostic hints without React key warnings', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState({
|
||||
error: 'OpenCode provider settings are using the wrong runtime binary.',
|
||||
errorDiagnostics: {
|
||||
summary: 'OpenCode provider settings are using the wrong runtime binary.',
|
||||
likelyCause:
|
||||
'The app resolved the OpenCode CLI itself as the Agent Teams runtime binary.',
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
command:
|
||||
'/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact',
|
||||
projectPath: null,
|
||||
exitCode: null,
|
||||
stderrPreview: null,
|
||||
stdoutPreview: null,
|
||||
hints: [
|
||||
'Those environment variables must not point to opencode.',
|
||||
'Those environment variables must not point to opencode.',
|
||||
],
|
||||
},
|
||||
}),
|
||||
actions: createActions(),
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const duplicateHints = host.textContent?.match(
|
||||
/Those environment variables must not point to opencode\./g
|
||||
);
|
||||
const duplicateKeyWarnings = consoleError.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(argument) =>
|
||||
typeof argument === 'string' &&
|
||||
argument.includes('Encountered two children with the same key')
|
||||
)
|
||||
);
|
||||
consoleError.mockRestore();
|
||||
|
||||
expect(duplicateHints).toHaveLength(2);
|
||||
expect(duplicateKeyWarnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders provider actions and opens API-key form state without exposing a raw secret', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ import {
|
|||
import type {
|
||||
RuntimeProviderConnectionDto,
|
||||
RuntimeProviderDirectoryEntryDto,
|
||||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementSetupFormResponse,
|
||||
RuntimeProviderManagementViewDto,
|
||||
RuntimeProviderManagementViewResponse,
|
||||
} from '../../../../src/features/runtime-provider-management/contracts';
|
||||
|
|
@ -112,6 +115,20 @@ describe('useRuntimeProviderManagement', () => {
|
|||
return React.createElement('div');
|
||||
}
|
||||
|
||||
function ConfigurableHarness(props: {
|
||||
enabled: boolean;
|
||||
projectPath?: string | null;
|
||||
}): React.ReactElement {
|
||||
const hook = useRuntimeProviderManagement({
|
||||
runtimeId: 'opencode',
|
||||
enabled: props.enabled,
|
||||
projectPath: props.projectPath,
|
||||
});
|
||||
state = hook[0];
|
||||
actions = hook[1];
|
||||
return React.createElement('div');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
host = document.createElement('div');
|
||||
|
|
@ -174,6 +191,174 @@ describe('useRuntimeProviderManagement', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('clears structured errors and stale provider state when disabled', async () => {
|
||||
const loadView = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
error: {
|
||||
code: 'runtime-misconfigured',
|
||||
message: 'OpenCode provider settings are using the wrong runtime binary.',
|
||||
recoverable: true,
|
||||
diagnostics: {
|
||||
summary: 'OpenCode provider settings are using the wrong runtime binary.',
|
||||
likelyCause:
|
||||
'The app resolved the OpenCode CLI itself as the Agent Teams runtime binary.',
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
command:
|
||||
'/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact',
|
||||
projectPath: null,
|
||||
exitCode: null,
|
||||
stderrPreview: null,
|
||||
stdoutPreview: null,
|
||||
hints: ['Those environment variables must not point to opencode.'],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtimeProviderManagement: {
|
||||
loadView,
|
||||
},
|
||||
} as unknown as ElectronAPI,
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(ConfigurableHarness, { enabled: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(state?.error).toContain('wrong runtime binary');
|
||||
expect(state?.errorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode');
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(ConfigurableHarness, { enabled: false }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(state?.view).toBeNull();
|
||||
expect(state?.selectedProviderId).toBeNull();
|
||||
expect(state?.error).toBeNull();
|
||||
expect(state?.errorDiagnostics).toBeNull();
|
||||
expect(state?.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores pending directory and setup-form responses after being disabled', async () => {
|
||||
let resolveDirectory:
|
||||
| ((response: RuntimeProviderManagementDirectoryResponse) => void)
|
||||
| null = null;
|
||||
let resolveSetupForm:
|
||||
| ((response: RuntimeProviderManagementSetupFormResponse) => void)
|
||||
| null = null;
|
||||
const directoryResponse = new Promise<RuntimeProviderManagementDirectoryResponse>(
|
||||
(resolve) => {
|
||||
resolveDirectory = resolve;
|
||||
}
|
||||
);
|
||||
const setupFormResponse = new Promise<RuntimeProviderManagementSetupFormResponse>(
|
||||
(resolve) => {
|
||||
resolveSetupForm = resolve;
|
||||
}
|
||||
);
|
||||
const loadView = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
view: createRuntimeView(),
|
||||
})
|
||||
);
|
||||
const loadProviderDirectory = vi.fn(() => directoryResponse);
|
||||
const loadSetupForm = vi.fn(() => setupFormResponse);
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtimeProviderManagement: {
|
||||
loadView,
|
||||
loadProviderDirectory,
|
||||
loadSetupForm,
|
||||
},
|
||||
} as unknown as ElectronAPI,
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(ConfigurableHarness, { enabled: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(loadProviderDirectory).toHaveBeenCalled();
|
||||
});
|
||||
actions?.startConnect('openrouter');
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(loadSetupForm).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(ConfigurableHarness, { enabled: false }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveDirectory?.({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
directory: {
|
||||
runtimeId: 'opencode',
|
||||
totalCount: 1,
|
||||
returnedCount: 1,
|
||||
query: null,
|
||||
filter: 'all',
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
nextCursor: null,
|
||||
entries: [createOpenAiLocalDirectoryEntry()],
|
||||
diagnostics: [],
|
||||
fetchedAt: '2026-05-22T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
resolveSetupForm?.({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
setupForm: {
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'openrouter',
|
||||
displayName: 'OpenRouter',
|
||||
method: 'api',
|
||||
supported: true,
|
||||
title: 'Connect OpenRouter',
|
||||
description: null,
|
||||
submitLabel: 'Connect',
|
||||
disabledReason: null,
|
||||
source: 'curated',
|
||||
secret: {
|
||||
key: 'key',
|
||||
label: 'API key',
|
||||
placeholder: 'Paste API key',
|
||||
required: true,
|
||||
},
|
||||
prompts: [],
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(state?.directoryEntries).toEqual([]);
|
||||
expect(state?.directoryLoaded).toBe(false);
|
||||
expect(state?.setupForm).toBeNull();
|
||||
expect(state?.activeFormProviderId).toBeNull();
|
||||
expect(state?.setupFormLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores stale provider views after project context changes', async () => {
|
||||
let resolveProjectA:
|
||||
| ((response: {
|
||||
|
|
@ -246,6 +431,143 @@ describe('useRuntimeProviderManagement', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('restarts provider directory loading when project context changes while loading', async () => {
|
||||
let resolveProjectADirectory:
|
||||
| ((response: RuntimeProviderManagementDirectoryResponse) => void)
|
||||
| null = null;
|
||||
let resolveProjectBDirectory:
|
||||
| ((response: RuntimeProviderManagementDirectoryResponse) => void)
|
||||
| null = null;
|
||||
const projectBEntry: RuntimeProviderDirectoryEntryDto = {
|
||||
...createOpenAiLocalDirectoryEntry(),
|
||||
providerId: 'project-b-provider',
|
||||
displayName: 'Project B Provider',
|
||||
};
|
||||
const loadView = vi.fn((input: { projectPath?: string | null }) =>
|
||||
Promise.resolve({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
view: {
|
||||
...createRuntimeView(),
|
||||
projectPath: input.projectPath ?? null,
|
||||
},
|
||||
})
|
||||
);
|
||||
const loadProviderDirectory = vi.fn((input: { projectPath?: string | null }) => {
|
||||
if (input.projectPath === '/tmp/project-a') {
|
||||
return new Promise<RuntimeProviderManagementDirectoryResponse>((resolve) => {
|
||||
resolveProjectADirectory = resolve;
|
||||
});
|
||||
}
|
||||
return new Promise<RuntimeProviderManagementDirectoryResponse>((resolve) => {
|
||||
resolveProjectBDirectory = resolve;
|
||||
});
|
||||
});
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtimeProviderManagement: {
|
||||
loadView,
|
||||
loadProviderDirectory,
|
||||
},
|
||||
} as unknown as ElectronAPI,
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 10));
|
||||
await vi.waitFor(() => {
|
||||
expect(loadProviderDirectory).toHaveBeenCalledWith({
|
||||
runtimeId: 'opencode',
|
||||
projectPath: '/tmp/project-a',
|
||||
query: null,
|
||||
filter: 'all',
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
refresh: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 10));
|
||||
await vi.waitFor(() => {
|
||||
expect(loadProviderDirectory).toHaveBeenCalledWith({
|
||||
runtimeId: 'opencode',
|
||||
projectPath: '/tmp/project-b',
|
||||
query: null,
|
||||
filter: 'all',
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
refresh: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveProjectBDirectory?.({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
directory: {
|
||||
runtimeId: 'opencode',
|
||||
totalCount: 1,
|
||||
returnedCount: 1,
|
||||
query: null,
|
||||
filter: 'all',
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
nextCursor: null,
|
||||
fetchedAt: '2026-05-22T00:00:00.000Z',
|
||||
entries: [projectBEntry],
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(state?.directoryEntries.map((entry) => entry.providerId)).toEqual([
|
||||
'project-b-provider',
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
resolveProjectADirectory?.({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
directory: {
|
||||
runtimeId: 'opencode',
|
||||
totalCount: 1,
|
||||
returnedCount: 1,
|
||||
query: null,
|
||||
filter: 'all',
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
nextCursor: null,
|
||||
fetchedAt: '2026-05-22T00:00:00.000Z',
|
||||
entries: [createOpenAiLocalDirectoryEntry()],
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(state?.directoryEntries.map((entry) => entry.providerId)).toEqual([
|
||||
'project-b-provider',
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('drops stale model probe results after project context changes', async () => {
|
||||
const modelId = 'llama.cpp/qwen-test:0.5b';
|
||||
let resolveProbe: ((value: RuntimeProviderManagementModelTestResponse) => void) | null = null;
|
||||
|
|
@ -413,6 +735,153 @@ describe('useRuntimeProviderManagement', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('clears pending provider save state after project context changes', async () => {
|
||||
const connectedProvider: RuntimeProviderConnectionDto = {
|
||||
...createOpenAiLocalProvider(),
|
||||
ownership: ['managed'],
|
||||
detail: 'Connected via managed OpenCode credential',
|
||||
};
|
||||
let resolveConnect: ((value: RuntimeProviderManagementProviderResponse) => void) | null =
|
||||
null;
|
||||
const loadView = vi.fn((input: { projectPath?: string | null }) =>
|
||||
Promise.resolve({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
view: {
|
||||
...createRuntimeView(),
|
||||
projectPath: input.projectPath ?? null,
|
||||
defaultModel: input.projectPath === '/tmp/project-b' ? 'opencode/project-b' : null,
|
||||
},
|
||||
})
|
||||
);
|
||||
const loadProviderDirectory = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
directory: {
|
||||
runtimeId: 'opencode',
|
||||
totalCount: 1,
|
||||
returnedCount: 1,
|
||||
query: null,
|
||||
filter: 'all',
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
nextCursor: null,
|
||||
fetchedAt: '2026-04-25T00:00:00.000Z',
|
||||
entries: [createOpenAiLocalDirectoryEntry()],
|
||||
diagnostics: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
const loadSetupForm = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
setupForm: {
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'openai',
|
||||
displayName: 'OpenAI',
|
||||
method: 'api',
|
||||
supported: true,
|
||||
title: 'Connect OpenAI',
|
||||
description: null,
|
||||
submitLabel: 'Connect',
|
||||
disabledReason: null,
|
||||
source: 'curated',
|
||||
secret: {
|
||||
key: 'key',
|
||||
label: 'API key',
|
||||
placeholder: 'Paste API key',
|
||||
required: true,
|
||||
},
|
||||
prompts: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
const connectProvider = vi.fn(
|
||||
() =>
|
||||
new Promise<RuntimeProviderManagementProviderResponse>((resolve) => {
|
||||
resolveConnect = resolve;
|
||||
})
|
||||
);
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtimeProviderManagement: {
|
||||
loadView,
|
||||
loadProviderDirectory,
|
||||
loadSetupForm,
|
||||
connectProvider,
|
||||
},
|
||||
} as unknown as ElectronAPI,
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
actions?.startConnect('openai');
|
||||
actions?.setApiKeyValue('sk-project-a');
|
||||
await vi.waitFor(() => {
|
||||
expect(loadSetupForm).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
let submitPromise: Promise<void> | null = null;
|
||||
await act(async () => {
|
||||
submitPromise = actions?.submitConnect('openai') ?? null;
|
||||
await vi.waitFor(() => {
|
||||
expect(connectProvider).toHaveBeenCalledWith({
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'openai',
|
||||
method: 'api',
|
||||
apiKey: 'sk-project-a',
|
||||
metadata: {},
|
||||
projectPath: '/tmp/project-a',
|
||||
});
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(state?.savingProviderId).toBe('openai');
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(loadView).toHaveBeenCalledWith({
|
||||
runtimeId: 'opencode',
|
||||
projectPath: '/tmp/project-b',
|
||||
});
|
||||
});
|
||||
|
||||
expect(state?.savingProviderId).toBeNull();
|
||||
expect(state?.activeFormProviderId).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveConnect?.({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
provider: connectedProvider,
|
||||
});
|
||||
await submitPromise;
|
||||
});
|
||||
|
||||
expect(state?.view?.providers).toEqual([]);
|
||||
expect(state?.savingProviderId).toBeNull();
|
||||
expect(state?.setupSubmitError).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes view and catalog after forgetting managed auth while local auth remains', async () => {
|
||||
const localProvider = createOpenAiLocalProvider();
|
||||
const loadView = vi.fn(() =>
|
||||
|
|
@ -1040,6 +1509,68 @@ describe('useRuntimeProviderManagement', () => {
|
|||
expect(state?.apiKeyValue).toBe('sk-bad-value');
|
||||
});
|
||||
|
||||
it('keeps setup form diagnostics available when submit is attempted after form load failure', async () => {
|
||||
const loadSetupForm = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
error: {
|
||||
code: 'runtime-misconfigured',
|
||||
message: 'OpenCode provider settings are using the wrong runtime binary.',
|
||||
recoverable: true,
|
||||
diagnostics: {
|
||||
summary: 'OpenCode provider settings are using the wrong runtime binary.',
|
||||
likelyCause: 'The app resolved the OpenCode CLI itself as the runtime binary.',
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
command: '/opt/homebrew/bin/opencode runtime providers setup-form',
|
||||
projectPath: null,
|
||||
exitCode: null,
|
||||
stderrPreview: null,
|
||||
stdoutPreview: null,
|
||||
hints: ['Those environment variables must not point to opencode.'],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtimeProviderManagement: {
|
||||
loadSetupForm,
|
||||
},
|
||||
} as unknown as ElectronAPI,
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
actions?.startConnect('openrouter');
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(loadSetupForm).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
expect(state?.setupFormError).toBe(
|
||||
'OpenCode provider settings are using the wrong runtime binary.'
|
||||
);
|
||||
expect(state?.setupFormErrorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode');
|
||||
|
||||
await act(async () => {
|
||||
await actions?.submitConnect('openrouter');
|
||||
});
|
||||
|
||||
expect(state?.setupSubmitError).toBe(
|
||||
'OpenCode provider settings are using the wrong runtime binary.'
|
||||
);
|
||||
expect(state?.setupSubmitErrorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode');
|
||||
});
|
||||
|
||||
it('submits a supported setup form without a secret as a null API key', async () => {
|
||||
const loadSetupForm = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
|
|
@ -1443,6 +1974,47 @@ describe('useRuntimeProviderManagement', () => {
|
|||
expect(state?.modelResults[modelId]?.message).toBe(message);
|
||||
});
|
||||
|
||||
it('promotes structured model probe failures to the global diagnostics alert state', async () => {
|
||||
const modelId = 'openrouter/anthropic/claude-3.5-haiku';
|
||||
installRuntimeProviderManagementApi({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
error: {
|
||||
code: 'runtime-misconfigured',
|
||||
message: 'OpenCode provider settings are using the wrong runtime binary.',
|
||||
recoverable: true,
|
||||
diagnostics: {
|
||||
summary: 'OpenCode provider settings are using the wrong runtime binary.',
|
||||
likelyCause: 'The app resolved the OpenCode CLI itself as the runtime binary.',
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
command: '/opt/homebrew/bin/opencode runtime providers test-model',
|
||||
projectPath: null,
|
||||
exitCode: null,
|
||||
stderrPreview: null,
|
||||
stdoutPreview: null,
|
||||
hints: ['Those environment variables must not point to opencode.'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await actions?.testModel('openrouter', modelId);
|
||||
});
|
||||
|
||||
expect(state?.error).toBe('OpenCode provider settings are using the wrong runtime binary.');
|
||||
expect(state?.errorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode');
|
||||
expect(state?.modelResults[modelId]).toMatchObject({
|
||||
ok: false,
|
||||
message: 'OpenCode provider settings are using the wrong runtime binary.',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps successful model probes scoped to the model card instead of a global success banner', async () => {
|
||||
const modelId = 'openrouter/openai/gpt-oss-20b:free';
|
||||
installRuntimeProviderManagementApi({
|
||||
|
|
|
|||
Loading…
Reference in a new issue