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