diff --git a/src/main/index.ts b/src/main/index.ts index 07354a5a..aa40badd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -226,7 +226,7 @@ import { TeamTaskStallSnapshotSource, TeamTranscriptSourceLocator, UpdaterService, - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath, + resolveVerifiedOpenCodeRuntimeBinaryPath, } from './services'; import type { FileChangeEvent } from '@main/types'; @@ -343,10 +343,8 @@ function describeMemberWorkSyncReviewPickupEscalationReason(reason: string): str } async function resolveOpenCodeRuntimeBinaryForBridgeEnv(): Promise { - const manifestBinaryPath = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(); - if (manifestBinaryPath) { - return manifestBinaryPath; - } + const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath(); + if (resolvedBinaryPath) return resolvedBinaryPath; try { const status = await openCodeRuntimeInstallerService?.getStatus(); @@ -435,7 +433,7 @@ async function createOpenCodeRuntimeAdapterRegistry( await ensureOpenCodeBridgeRuntimeBinaryEnv({ targetEnv, bridgeEnv, - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv, + resolveVerifiedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv, onWarning: (message) => logger.warn(message), }); }; diff --git a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts index d086b3c1..cabcc480 100644 --- a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts +++ b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts @@ -1,7 +1,7 @@ import { execCli } from '@main/utils/childProcess'; import { getAppDataPath } from '@main/utils/pathDecoder'; import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; -import { getCachedShellEnv } from '@main/utils/shellEnv'; +import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { createHash, randomUUID } from 'crypto'; @@ -22,6 +22,7 @@ const MAX_TARBALL_BYTES = 250 * 1024 * 1024; const MAX_BINARY_BYTES = 350 * 1024 * 1024; const FETCH_TIMEOUT_MS = 60_000; const VERSION_TIMEOUT_MS = 10_000; +const PATH_SHELL_ENV_TIMEOUT_MS = 1_500; interface NpmPackageMetadata { name?: string; @@ -134,9 +135,15 @@ function splitPathEnv(pathValue: string | undefined): string[] { .filter(Boolean); } -function resolvePathOpenCodeBinary(): string | null { +function resolvePathOpenCodeBinary( + additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = [] +): string | null { const shellEnv = getCachedShellEnv() ?? {}; - const pathEntries = [...splitPathEnv(shellEnv.PATH), ...splitPathEnv(process.env.PATH)]; + const pathEntries = [ + ...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)), + ...splitPathEnv(shellEnv.PATH), + ...splitPathEnv(process.env.PATH), + ]; const seen = new Set(); for (const entry of pathEntries) { const normalizedEntry = path.resolve(entry); @@ -154,6 +161,57 @@ function resolvePathOpenCodeBinary(): string | null { return null; } +type OpenCodeBinaryVersionProbe = + | { ok: true; version: string | null } + | { ok: false; error: string }; + +async function probeOpenCodeBinaryVersion(binaryPath: string): Promise { + try { + const { stdout } = await execCli(binaryPath, ['--version'], { + timeout: VERSION_TIMEOUT_MS, + windowsHide: true, + }); + return { ok: true, version: stdout.trim() || null }; + } catch (error) { + return { ok: false, error: getErrorMessage(error) }; + } +} + +async function resolvePathOpenCodeBinaryWithBestEffortEnv( + options: { shellEnvTimeoutMs?: number } = {} +): Promise { + const cachedCandidate = resolvePathOpenCodeBinary(); + if (cachedCandidate) { + return cachedCandidate; + } + + const shellEnv = await resolveInteractiveShellEnvBestEffort({ + timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS, + fallbackEnv: process.env, + }); + return resolvePathOpenCodeBinary([shellEnv]); +} + +async function resolveVerifiedPathOpenCodeBinaryPath( + options: { shellEnvTimeoutMs?: number } = {} +): Promise { + const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv(options); + if (!binaryPath) { + return null; + } + + return (await probeOpenCodeBinaryVersion(binaryPath)).ok ? binaryPath : null; +} + +export async function resolveVerifiedOpenCodeRuntimeBinaryPath( + options: { shellEnvTimeoutMs?: number } = {} +): Promise { + return ( + (await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ?? + (await resolveVerifiedPathOpenCodeBinaryPath(options)) + ); +} + function isLinuxMuslRuntime(): boolean { if (process.platform !== 'linux') { return false; @@ -466,31 +524,27 @@ export class OpenCodeRuntimeInstallerService { } private async getPathStatus(): Promise { - const binaryPath = resolvePathOpenCodeBinary(); + const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv(); if (!binaryPath) { return { installed: false, source: 'missing', state: 'idle' }; } - try { - const { stdout } = await execCli(binaryPath, ['--version'], { - timeout: VERSION_TIMEOUT_MS, - windowsHide: true, - }); - return { - installed: true, - binaryPath, - version: stdout.trim() || undefined, - source: 'path', - state: 'ready', - }; - } catch (error) { + const version = await probeOpenCodeBinaryVersion(binaryPath); + if (!version.ok) { return { installed: false, binaryPath, source: 'path', state: 'failed', - error: getErrorMessage(error), + error: version.error, }; } + return { + installed: true, + binaryPath, + version: version.version ?? undefined, + source: 'path', + state: 'ready', + }; } private async installInternal(): Promise { diff --git a/src/main/services/runtime/openCodeBridgeRuntimeEnv.ts b/src/main/services/runtime/openCodeBridgeRuntimeEnv.ts index b74e9c13..e31ddcb3 100644 --- a/src/main/services/runtime/openCodeBridgeRuntimeEnv.ts +++ b/src/main/services/runtime/openCodeBridgeRuntimeEnv.ts @@ -1,3 +1,6 @@ +import { existsSync, statSync } from 'node:fs'; +import path from 'node:path'; + import { getErrorMessage } from '@shared/utils/errorHandling'; import { @@ -9,16 +12,76 @@ import { export interface EnsureOpenCodeBridgeRuntimeBinaryEnvOptions { targetEnv: NodeJS.ProcessEnv; bridgeEnv?: NodeJS.ProcessEnv; - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => Promise; + resolveVerifiedOpenCodeRuntimeBinaryPath: () => Promise; onWarning?: (message: string) => void; } +function resolveExistingFilePath(filePath: string): string | null { + const resolvedPath = path.resolve(filePath.trim()); + if (!existsSync(resolvedPath)) { + return null; + } + try { + return statSync(resolvedPath).isFile() ? resolvedPath : null; + } catch { + return null; + } +} + +function getOpenCodeRuntimeBinaryEnvValues(env: NodeJS.ProcessEnv): string[] { + return [ + env[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim(), + env[OPENCODE_LEGACY_BINARY_PATH_ENV]?.trim(), + ].filter((value): value is string => Boolean(value)); +} + +function resolveExistingOpenCodeRuntimeBinaryEnvPath(env: NodeJS.ProcessEnv): string | null { + for (const value of getOpenCodeRuntimeBinaryEnvValues(env)) { + const resolvedPath = resolveExistingFilePath(value); + if (resolvedPath) { + return resolvedPath; + } + } + return null; +} + +function clearOpenCodeRuntimeBinaryEnvValues( + env: NodeJS.ProcessEnv, + invalidValues: Set +): void { + for (const key of [OPENCODE_RUNTIME_BINARY_PATH_ENV, OPENCODE_LEGACY_BINARY_PATH_ENV]) { + const value = env[key]?.trim(); + if (value && invalidValues.has(value)) { + delete env[key]; + } + } +} + export async function ensureOpenCodeBridgeRuntimeBinaryEnv({ targetEnv, bridgeEnv = targetEnv, - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath, + resolveVerifiedOpenCodeRuntimeBinaryPath, onWarning, }: EnsureOpenCodeBridgeRuntimeBinaryEnvOptions): Promise { + if ( + targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim() || + targetEnv[OPENCODE_LEGACY_BINARY_PATH_ENV]?.trim() + ) { + const existingBinaryPath = resolveExistingOpenCodeRuntimeBinaryEnvPath(targetEnv); + if (!existingBinaryPath) { + const invalidValues = new Set(getOpenCodeRuntimeBinaryEnvValues(targetEnv)); + clearOpenCodeRuntimeBinaryEnvValues(targetEnv, invalidValues); + if (targetEnv !== bridgeEnv) { + clearOpenCodeRuntimeBinaryEnvValues(bridgeEnv, invalidValues); + } + } else { + targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV] = existingBinaryPath; + targetEnv[OPENCODE_LEGACY_BINARY_PATH_ENV] = existingBinaryPath; + applyOpenCodeRuntimeBinaryEnv(targetEnv, existingBinaryPath); + return; + } + } + if ( targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim() || targetEnv[OPENCODE_LEGACY_BINARY_PATH_ENV]?.trim() @@ -28,8 +91,8 @@ export async function ensureOpenCodeBridgeRuntimeBinaryEnv({ } try { - const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(); - applyOpenCodeRuntimeBinaryEnv(targetEnv, appManagedOpenCodeBinary); + const openCodeBinary = await resolveVerifiedOpenCodeRuntimeBinaryPath(); + applyOpenCodeRuntimeBinaryEnv(targetEnv, openCodeBinary); if ( targetEnv !== bridgeEnv && targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV] && @@ -38,8 +101,6 @@ export async function ensureOpenCodeBridgeRuntimeBinaryEnv({ applyOpenCodeRuntimeBinaryEnv(bridgeEnv, targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]); } } catch (error) { - onWarning?.( - `[OpenCode] Runtime adapter bundled OpenCode binary unresolved: ${getErrorMessage(error)}` - ); + onWarning?.(`[OpenCode] Runtime adapter OpenCode binary unresolved: ${getErrorMessage(error)}`); } } diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts index 551c3a27..656cdaa1 100644 --- a/src/main/services/runtime/providerAwareCliEnv.ts +++ b/src/main/services/runtime/providerAwareCliEnv.ts @@ -1,7 +1,7 @@ import { resolveVerifiedAppManagedCodexRuntimeBinaryPath } from '@features/codex-runtime-installer/main'; import { getCachedShellEnv } from '@main/utils/shellEnv'; -import { resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService'; +import { resolveVerifiedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService'; import { ensureAgentTeamsMcpLocalLaunchEnv } from './agentTeamsMcpLaunchEnv'; import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv'; @@ -45,8 +45,8 @@ export async function buildProviderAwareCliEnv( env: options.env, }); if (!resolvedProviderId || resolvedProviderId === 'opencode') { - const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(); - applyOpenCodeRuntimeBinaryEnv(env, appManagedOpenCodeBinary); + const openCodeBinary = await resolveVerifiedOpenCodeRuntimeBinaryPath(); + applyOpenCodeRuntimeBinaryEnv(env, openCodeBinary); } const appManagedCodexBinary = await resolveVerifiedAppManagedCodexRuntimeBinaryPath(); if ( diff --git a/src/main/services/team/AgentTeamsMcpHttpServer.ts b/src/main/services/team/AgentTeamsMcpHttpServer.ts index f8faafca..4b81baf9 100644 --- a/src/main/services/team/AgentTeamsMcpHttpServer.ts +++ b/src/main/services/team/AgentTeamsMcpHttpServer.ts @@ -12,6 +12,7 @@ const logger = createLogger('Service:AgentTeamsMcpHttpServer'); const MCP_HTTP_HOST = '127.0.0.1'; const MCP_HTTP_ENDPOINT = '/mcp'; const MCP_HTTP_READY_TIMEOUT_MS = 5_000; +const MCP_HTTP_EXISTING_HANDLE_READY_TIMEOUT_MS = 3_000; const MCP_HTTP_READY_POLL_MS = 100; export interface AgentTeamsMcpHttpServerHandle { @@ -120,18 +121,18 @@ export class AgentTeamsMcpHttpServer { private startPromise: Promise | null = null; private child: ChildProcess | null = null; private handle: AgentTeamsMcpHttpServerHandle | null = null; + private readonly expectedStopChildren = new WeakSet(); constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {} async ensureStarted(): Promise { - if (this.handle) { - return this.handle; - } if (this.startPromise) { return this.startPromise; } - this.startPromise = this.startOnce().finally(() => { + this.startPromise = ( + this.handle ? this.reuseOrRestartExistingHandle(this.handle) : this.startOnce() + ).finally(() => { this.startPromise = null; }); return this.startPromise; @@ -142,10 +143,34 @@ export class AgentTeamsMcpHttpServer { this.child = null; this.handle = null; if (child) { + this.expectedStopChildren.add(child); killProcessTree(child, 'SIGKILL'); } } + private async reuseOrRestartExistingHandle( + handle: AgentTeamsMcpHttpServerHandle + ): Promise { + const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort; + try { + await waitForPort(MCP_HTTP_HOST, handle.port, MCP_HTTP_EXISTING_HANDLE_READY_TIMEOUT_MS); + if (this.handle === handle) { + return handle; + } + } catch (error) { + if (this.handle === handle) { + logger.warn( + `Agent Teams MCP HTTP server at ${handle.url} failed health reuse check, restarting: ${ + error instanceof Error ? error.message : String(error) + }` + ); + await this.stop(); + } + } + + return this.startOnce(); + } + private async startOnce(): Promise { const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec; const allocatePort = this.deps.allocatePort ?? allocateLoopbackPort; @@ -181,14 +206,21 @@ export class AgentTeamsMcpHttpServer { let startupSettled = false; const startupFailure = new Promise((_, reject) => { child.once('exit', (code, signal) => { + const expectedStop = this.expectedStopChildren.delete(child); clearIfCurrent(); const codeSuffix = typeof code === 'number' ? ` with code ${code}` : ''; const signalSuffix = signal ? ` (${signal})` : ''; const message = `Agent Teams MCP HTTP server exited before startup completed${codeSuffix}${signalSuffix}`; - if (!startupSettled) { + if (!startupSettled && !expectedStop) { reject(new Error(message)); + logger.warn(message); + return; + } + if (startupSettled && !expectedStop) { + logger.warn( + `Agent Teams MCP HTTP server exited after startup${codeSuffix}${signalSuffix}` + ); } - logger.warn(message); }); child.once('error', (error) => { clearIfCurrent(); @@ -216,6 +248,7 @@ export class AgentTeamsMcpHttpServer { this.child = null; this.handle = null; } + this.expectedStopChildren.add(child); killProcessTree(child, 'SIGKILL'); throw error; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 57a8ce60..f5939599 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -915,6 +915,10 @@ const OPENCODE_PROVIDER_SCOPED_PREPARE_FAILURE_REASONS = new Set([ 'mcp_unavailable', 'adapter_disabled', ]); +const OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC = + 'OpenCode runtime binary is not installed or not reachable by launch preflight.'; +const OPENCODE_APP_MCP_UNREACHABLE_DIAGNOSTIC = + 'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.'; function pushUniqueLine(lines: string[], line: string): void { const trimmed = line.trim(); @@ -931,10 +935,41 @@ function looksLikeOpenCodeProviderPrepareDiagnostic(value: string): boolean { lower.includes('mcp_unavailable') || lower.includes('runtime store') || lower.includes('opencode cli') || + lower.includes('opencode runtime binary') || lower.includes('unable to connect') ); } +function normalizeOpenCodePrepareDiagnostic(value: string, reason?: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + + if (/opencode cli (?:not detected on path|not found)/i.test(trimmed)) { + return OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC; + } + + const lower = trimmed.toLowerCase(); + if ( + lower.includes('unable to connect') && + (lower.includes('/experimental/tool') || + lower.includes('mcp_unavailable') || + reason === 'mcp_unavailable') + ) { + const detail = trimmed.includes(' - ') ? trimmed.split(' - ').pop()?.trim() : trimmed; + return detail && detail !== trimmed + ? `${OPENCODE_APP_MCP_UNREACHABLE_DIAGNOSTIC} Details: ${detail}` + : OPENCODE_APP_MCP_UNREACHABLE_DIAGNOSTIC; + } + + if (reason === 'mcp_unavailable' && lower.includes('mcp_unavailable')) { + return 'OpenCode app MCP is unavailable. Retry launch to refresh the app MCP bridge.'; + } + + return trimmed; +} + function isRetryableOpenCodePreflightBusyDiagnostic(value: string | null | undefined): boolean { const lower = value?.trim().toLowerCase() ?? ''; if (!lower) { @@ -18553,10 +18588,21 @@ export class TeamProvisioningService { expectedMembers: [], previousLaunchState: null, }); - details.push(...prepare.diagnostics); - warnings.push(...prepare.warnings); + const prepareReason = prepare.ok ? undefined : prepare.reason; + details.push( + ...prepare.diagnostics.map((diagnostic) => + normalizeOpenCodePrepareDiagnostic(diagnostic, prepareReason) + ) + ); + warnings.push( + ...prepare.warnings.map((warning) => + normalizeOpenCodePrepareDiagnostic(warning, prepareReason) + ) + ); if (!prepare.ok) { - blockingMessages.push(`OpenCode: ${prepare.reason}`); + blockingMessages.push( + normalizeOpenCodePrepareDiagnostic(`OpenCode: ${prepare.reason}`, prepare.reason) + ); } continue; } @@ -18876,7 +18922,12 @@ export class TeamProvisioningService { } const { modelId, prepare } = result; - warnings.push(...prepare.warnings); + const prepareReason = prepare.ok ? undefined : prepare.reason; + warnings.push( + ...prepare.warnings.map((warning) => + normalizeOpenCodePrepareDiagnostic(warning, prepareReason) + ) + ); if (prepare.ok) { details.push( verificationMode === 'compatibility' @@ -18886,8 +18937,10 @@ export class TeamProvisioningService { continue; } - const primaryReason = - prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason; + const primaryReason = normalizeOpenCodePrepareDiagnostic( + prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason, + prepare.reason + ); if ( prepare.retryable && [primaryReason, prepare.reason, ...prepare.diagnostics].some( @@ -19031,7 +19084,12 @@ export class TeamProvisioningService { }; } - warnings.push(...sharedPrepare.warnings); + const sharedPrepareReason = sharedPrepare.ok ? undefined : sharedPrepare.reason; + warnings.push( + ...sharedPrepare.warnings.map((warning) => + normalizeOpenCodePrepareDiagnostic(warning, sharedPrepareReason) + ) + ); appendPreflightDebugLog('opencode_compatibility_batch_shared_prepare', { cwd, modelIds, @@ -19042,8 +19100,10 @@ export class TeamProvisioningService { }); if (!sharedPrepare.ok) { - const primaryReason = - sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason; + const primaryReason = normalizeOpenCodePrepareDiagnostic( + sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason, + sharedPrepare.reason + ); if (primaryReason.trim().length > 0) { details.push(primaryReason); blockingMessages.push(primaryReason); diff --git a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts index 0cf779d9..b3e90652 100644 --- a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts +++ b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts @@ -99,6 +99,9 @@ export interface OpenCodeTeamLaunchReadinessServiceOptions { versionPolicy?: OpenCodeSupportedVersionPolicy; } +const OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC = + 'OpenCode runtime binary is not installed or not reachable by launch preflight.'; + export class OpenCodeTeamLaunchReadinessService { constructor( private readonly inventory: OpenCodeRuntimeInventoryPort, @@ -124,7 +127,7 @@ export class OpenCodeTeamLaunchReadinessService { inventory, modelId: input.selectedModel, diagnostics: appendDiagnostics(inventory.diagnostics, [ - 'OpenCode CLI not detected on PATH', + OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC, ]), }); } diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index c6d09c91..78dd107a 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -27,6 +27,7 @@ import { getProviderCredentialSummary, getProviderCurrentRuntimeSummary, getProviderDisconnectAction, + isOpenCodeCatalogHydrating, isConnectionManagedRuntimeProvider, shouldShowProviderConnectAction, } from '@renderer/components/runtime/providerConnectionUi'; @@ -868,13 +869,16 @@ const InstalledBanner = ({ ((provider.providerId === 'codex' && codexRateLimitsLoading) || anthropicRateLimitsLoading)); const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider); - const modelCatalogLoading = provider.modelCatalogRefreshState === 'loading'; + const modelCatalogLoading = + provider.modelCatalogRefreshState === 'loading' || + isOpenCodeCatalogHydrating(provider); const hasDetailContent = Boolean( (provider.backend?.label && !runtimeSummary) || runtimeSummary || connectionModeSummary || credentialSummary || - provider.models.length === 0 + provider.models.length === 0 || + modelCatalogLoading ); return ( @@ -936,9 +940,7 @@ const InstalledBanner = ({ ) : null} {connectionModeSummary ? {connectionModeSummary} : null} {credentialSummary ? {credentialSummary} : null} - {provider.models.length === 0 && modelCatalogLoading ? ( - Loading models... - ) : null} + {modelCatalogLoading ? Loading models... : null} {provider.models.length === 0 && !modelCatalogLoading && ( Models unavailable for this runtime build )} @@ -1121,7 +1123,7 @@ const InstalledBanner = ({ - {!showSkeleton && provider.models.length > 0 && ( + {!showSkeleton && !modelCatalogLoading && provider.models.length > 0 && (
+ | null + | undefined +): boolean { + if (provider?.providerId !== 'opencode') { + return false; + } + + if (provider.modelCatalog?.providerId === 'opencode' && provider.modelCatalog.models.length > 0) { + return false; + } + + if ( + provider.modelCatalogRefreshState === 'ready' || + provider.modelCatalogRefreshState === 'error' + ) { + return false; + } + + const hasOnlySummaryFallback = + provider.models.length === 0 || + provider.models.every((model) => model.trim() === 'opencode/big-pickle'); + + return ( + hasOnlySummaryFallback && + (provider.modelCatalogRefreshState === 'loading' || + provider.runtimeCapabilities?.modelCatalog?.dynamic === true) + ); +} + export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean { return provider.providerId === 'codex'; } diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index f65a902a..6dddfe30 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -21,6 +21,7 @@ import { getProviderCredentialSummary, getProviderCurrentRuntimeSummary, getProviderDisconnectAction, + isOpenCodeCatalogHydrating, isConnectionManagedRuntimeProvider, shouldShowProviderConnectAction, } from '@renderer/components/runtime/providerConnectionUi'; @@ -524,7 +525,8 @@ export const CliStatusSection = (): React.JSX.Element | null => { ? 'Checking...' : formatProviderStatusText(provider); const modelCatalogLoading = - provider.modelCatalogRefreshState === 'loading'; + provider.modelCatalogRefreshState === 'loading' || + isOpenCodeCatalogHydrating(provider); const connectionModeSummary = getProviderConnectionModeSummary(provider); const credentialSummary = getProviderCredentialSummary(provider); const disconnectAction = getProviderDisconnectAction(provider); @@ -533,7 +535,8 @@ export const CliStatusSection = (): React.JSX.Element | null => { runtimeSummary || connectionModeSummary || credentialSummary || - provider.models.length === 0 + provider.models.length === 0 || + modelCatalogLoading ); return ( @@ -585,9 +588,7 @@ export const CliStatusSection = (): React.JSX.Element | null => { {connectionModeSummary} ) : null} {credentialSummary ? {credentialSummary} : null} - {provider.models.length === 0 && modelCatalogLoading ? ( - Loading models... - ) : null} + {modelCatalogLoading ? Loading models... : null} {provider.models.length === 0 && !modelCatalogLoading && ( Models unavailable for this runtime build )} @@ -645,16 +646,18 @@ export const CliStatusSection = (): React.JSX.Element | null => { ) : null}
- {!effectiveShowSkeleton && provider.models.length > 0 && ( -
- -
- )} + {!effectiveShowSkeleton && + !modelCatalogLoading && + provider.models.length > 0 && ( +
+ +
+ )} ); })()} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index c9b96369..6fc1c8a1 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -2368,7 +2368,7 @@ export const CreateTeamDialog = ({

- CLI environment is not available - launch is blocked + Runtime environment is not available - launch is blocked

{effectivePrepare.message ?? 'Failed to prepare environment'} diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 0f930518..b44a1c46 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -2981,8 +2981,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen

- CLI environment is not available - {isRelaunch ? 'relaunch' : 'launch'} is - blocked + Runtime environment is not available - {isRelaunch ? 'relaunch' : 'launch'}{' '} + is blocked

{effectivePrepare.message ?? 'Failed to prepare environment'} diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index 280c6a6e..0003fdc4 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -135,6 +135,8 @@ export function failIncompleteProviderChecks( type ProvisioningDetailSummary = | 'CLI binary missing' + | 'OpenCode runtime missing' + | 'OpenCode app MCP unreachable' | 'Working directory missing' | 'CLI binary could not be started' | 'CLI preflight did not complete' @@ -198,6 +200,16 @@ function summarizeDetail( if (lower.includes('spawn ') && lower.includes(' enoent')) { return 'CLI binary missing'; } + if (lower.includes('opencode runtime binary is not installed')) { + return 'OpenCode runtime missing'; + } + if ( + lower.includes('opencode app mcp is unreachable') || + (lower.includes('unable to connect') && + (lower.includes('/experimental/tool') || lower.includes('mcp_unavailable'))) + ) { + return 'OpenCode app MCP unreachable'; + } if (lower.includes('working directory does not exist:')) { return 'Working directory missing'; } @@ -406,6 +418,8 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string { check.status === 'failed' ? (summarizedDetails.find( (detail) => + detail === 'OpenCode app MCP unreachable' || + detail === 'OpenCode runtime missing' || detail === 'Selected model unavailable' || detail === 'Selected model check failed' || detail === 'Authentication required' || @@ -436,6 +450,8 @@ function getDetailTone( summary === 'Selected model unavailable' || summary === 'Selected model check failed' || summary === 'CLI binary missing' || + summary === 'OpenCode runtime missing' || + summary === 'OpenCode app MCP unreachable' || summary === 'Working directory missing' || summary === 'CLI binary could not be started' || summary === 'CLI preflight did not complete' || @@ -697,6 +713,13 @@ export function getProvisioningFailureHint( ) { return 'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.'; } + if ( + combined.includes('opencode app mcp is unreachable') || + (combined.includes('unable to connect') && + (combined.includes('/experimental/tool') || combined.includes('mcp_unavailable'))) + ) { + return 'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.'; + } if ( combined.includes('spawn ') || combined.includes(' enoent') || diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 39978124..3890ad47 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { isOpenCodeCatalogHydrating } from '@renderer/components/runtime/providerConnectionUi'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { HoverTooltip } from '@renderer/components/ui/hover-tooltip'; import { Input } from '@renderer/components/ui/input'; @@ -999,12 +1000,7 @@ export const TeamModelSelector: React.FC = ({ const visibleConcreteModelOptionCount = visibleModelOptions.length - visibleDefaultModelOptions.length; const concreteModelOptionCount = modelOptions.filter((option) => option.value.trim()).length; - const shouldShowOpenCodeCatalogLoading = - effectiveProviderId === 'opencode' && - runtimeProviderStatus?.modelCatalogRefreshState === 'loading' && - runtimeProviderStatus.modelCatalog?.providerId !== 'opencode' && - (runtimeProviderStatus.models.length === 0 || - runtimeProviderStatus.models.every((model) => model.trim() === 'opencode/big-pickle')); + const shouldShowOpenCodeCatalogLoading = isOpenCodeCatalogHydrating(runtimeProviderStatus); const shouldShowModelSearch = !shouldShowOpenCodeCatalogLoading && concreteModelOptionCount > 8; const trimmedModelQuery = modelQuery.trim(); const shouldConstrainModelListHeight = visibleModelOptions.length > 8; diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts index ff6ffa90..96ca9e72 100644 --- a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts @@ -77,7 +77,7 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function uniquePrepareLines(lines: Array): string[] { +function uniquePrepareLines(lines: (string | null | undefined)[]): string[] { const seen = new Set(); const uniqueLines: string[] = []; for (const line of lines) { @@ -252,7 +252,8 @@ function looksLikeOpenCodeRuntimeFailureReason(reason: string | null | undefined lower.includes('mcp_unavailable') || lower.includes('unable to connect') || lower.includes('runtime store') || - lower.includes('opencode cli') + lower.includes('opencode cli') || + lower.includes('opencode runtime binary') ); } @@ -271,13 +272,6 @@ function getBlockingProviderIssue( ); } -function getBlockingProviderIssueMessage( - providerId: TeamProviderId, - result: TeamProvisioningPrepareResult -): string | null { - return getBlockingProviderIssue(providerId, result)?.message.trim() ?? null; -} - function isAdvisoryOpenCodeDeepVerificationIssue( issue: TeamProvisioningPrepareIssue | null, reason: string | null | undefined @@ -304,7 +298,8 @@ function isAdvisoryOpenCodeDeepVerificationIssue( lower.includes('api key') || lower.includes('/experimental/tool') || lower.includes('runtime store') || - lower.includes('opencode cli'); + lower.includes('opencode cli') || + lower.includes('opencode runtime binary'); if (hasHardRuntimeMarker) { return false; } @@ -437,10 +432,17 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string } function createRuntimeWarningLines(result: TeamProvisioningPrepareResult): string[] { - return uniquePrepareLines(result.warnings ?? []); + return uniquePrepareLines( + (result.warnings ?? []) + .map((warning) => normalizeRuntimeFailureDetailLine(warning)) + .filter(Boolean) + ); } -function normalizeRuntimeFailureDetailLine(detail: string | null | undefined): string | null { +function normalizeRuntimeFailureDetailLine( + detail: string | null | undefined, + code?: string | null +): string | null { const trimmed = detail?.trim(); if (!trimmed) { return null; @@ -450,6 +452,20 @@ function normalizeRuntimeFailureDetailLine(detail: string | null | undefined): s return 'OpenCode runtime binary is not installed or not reachable by launch preflight.'; } + const lower = trimmed.toLowerCase(); + if ( + lower.includes('unable to connect') && + (lower.includes('/experimental/tool') || + lower.includes('mcp_unavailable') || + code?.trim().toLowerCase() === 'mcp_unavailable') + ) { + const connectionDetail = trimmed.includes(' - ') ? trimmed.split(' - ').pop()?.trim() : trimmed; + const base = 'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.'; + return connectionDetail && connectionDetail !== trimmed + ? `${base} Details: ${connectionDetail}` + : base; + } + return trimmed; } @@ -458,7 +474,9 @@ function createRuntimeFailureDetailLines( message: string | null | undefined ): string[] { return uniquePrepareLines( - [...runtimeDetailLines, message].map(normalizeRuntimeFailureDetailLine).filter(Boolean) + [...runtimeDetailLines, message] + .map((detail) => normalizeRuntimeFailureDetailLine(detail)) + .filter(Boolean) ); } @@ -1033,15 +1051,20 @@ export async function runProviderPrepareDiagnostics({ uncachedModelIds, compatibilityResult ); - const structuredProviderScopedFailure = getBlockingProviderIssueMessage( + const structuredProviderScopedIssue = getBlockingProviderIssue( providerId, compatibilityResult ); + const structuredProviderScopedFailure = + structuredProviderScopedIssue?.message.trim() ?? null; if (structuredProviderScopedFailure || providerScopedFailure) { return { status: 'failed', details: [ - structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed', + normalizeRuntimeFailureDetailLine( + structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed', + structuredProviderScopedIssue?.code + ) ?? 'OpenCode failed', ], warnings: [], modelResultsById: {}, @@ -1195,7 +1218,12 @@ export async function runProviderPrepareDiagnostics({ } else { return { status: 'failed', - details: [failureReason], + details: [ + normalizeRuntimeFailureDetailLine( + failureReason, + structuredProviderScopedIssue?.code + ) ?? failureReason, + ], warnings: [], modelResultsById: {}, }; diff --git a/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts b/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts index e3dde3b1..c43f8e1c 100644 --- a/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts +++ b/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts @@ -6,21 +6,33 @@ import { gzipSync } from 'zlib'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const execCliMock = vi.hoisted(() => vi.fn()); +const getCachedShellEnvMock = vi.hoisted(() => vi.fn()); +const resolveInteractiveShellEnvBestEffortMock = vi.hoisted(() => vi.fn()); vi.mock('@main/utils/childProcess', () => ({ execCli: execCliMock, })); +vi.mock('@main/utils/shellEnv', () => ({ + getCachedShellEnv: () => getCachedShellEnvMock(), + resolveInteractiveShellEnvBestEffort: ( + ...args: Parameters + ) => resolveInteractiveShellEnvBestEffortMock(...args), +})); + import { extractOpenCodeRuntimeBinaryFromTarball, getOpenCodeRuntimePlatformCandidates, + OpenCodeRuntimeInstallerService, resolveAppManagedOpenCodeRuntimeBinaryPath, resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath, + resolveVerifiedOpenCodeRuntimeBinaryPath, verifyOpenCodeRuntimePackageIntegrity, } from '@main/services/infrastructure/OpenCodeRuntimeInstallerService'; import { setAppDataBasePath } from '@main/utils/pathDecoder'; let tempRoot: string | null = null; +let originalPath: string | undefined; function writeOctal(header: Buffer, offset: number, length: number, value: number): void { const encoded = value @@ -63,12 +75,24 @@ describe('OpenCodeRuntimeInstallerService resolver', () => { beforeEach(async () => { tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-runtime-resolver-')); setAppDataBasePath(tempRoot); + originalPath = process.env.PATH; + process.env.PATH = ''; execCliMock.mockReset(); execCliMock.mockResolvedValue({ stdout: 'opencode 1.0.0\n', stderr: '' }); + getCachedShellEnvMock.mockReset(); + getCachedShellEnvMock.mockReturnValue(null); + resolveInteractiveShellEnvBestEffortMock.mockReset(); + resolveInteractiveShellEnvBestEffortMock.mockResolvedValue(process.env); }); afterEach(async () => { setAppDataBasePath(null); + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + originalPath = undefined; if (tempRoot) { await rm(tempRoot, { recursive: true, force: true }); tempRoot = null; @@ -163,6 +187,46 @@ describe('OpenCodeRuntimeInstallerService resolver', () => { await expect(resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()).resolves.toBeNull(); }); + + it('returns a verified OpenCode binary from best-effort shell PATH when app-managed runtime is absent', async () => { + const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode'); + await mkdir(path.dirname(binaryPath), { recursive: true }); + await writeFile(binaryPath, 'binary', { mode: 0o755 }); + resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({ + PATH: path.dirname(binaryPath), + }); + + await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe( + binaryPath + ); + expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith( + expect.objectContaining({ + timeoutMs: 0, + fallbackEnv: process.env, + }) + ); + expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], { + timeout: 10_000, + windowsHide: true, + }); + }); + + it('reports PATH-installed OpenCode as installed after best-effort shell env resolution', async () => { + const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode'); + await mkdir(path.dirname(binaryPath), { recursive: true }); + await writeFile(binaryPath, 'binary', { mode: 0o755 }); + resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({ + PATH: path.dirname(binaryPath), + }); + + await expect(new OpenCodeRuntimeInstallerService().getStatus()).resolves.toMatchObject({ + installed: true, + source: 'path', + state: 'ready', + binaryPath, + version: 'opencode 1.0.0', + }); + }); }); describe('OpenCodeRuntimeInstallerService package safety helpers', () => { diff --git a/test/main/services/runtime/OpenCodeRuntimePreflight.integration.test.ts b/test/main/services/runtime/OpenCodeRuntimePreflight.integration.test.ts new file mode 100644 index 00000000..50c0fe1b --- /dev/null +++ b/test/main/services/runtime/OpenCodeRuntimePreflight.integration.test.ts @@ -0,0 +1,199 @@ +// @vitest-environment node +/* eslint-disable security/detect-non-literal-fs-filename */ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const augmentConfiguredConnectionEnvMock = vi.hoisted(() => + vi.fn((env: NodeJS.ProcessEnv) => Promise.resolve(env)) +); +const applyConfiguredConnectionEnvMock = vi.hoisted(() => + vi.fn((env: NodeJS.ProcessEnv) => Promise.resolve(env)) +); +const getConfiguredConnectionIssuesMock = vi.hoisted(() => vi.fn(() => Promise.resolve({}))); +const getConfiguredConnectionLaunchArgsMock = vi.hoisted(() => vi.fn(() => Promise.resolve([]))); +const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.hoisted(() => + vi.fn(() => Promise.resolve(null)) +); + +vi.mock('../../../../src/main/services/infrastructure/ConfigManager', () => ({ + configManager: { + getConfig: () => ({ + runtime: { + providerBackends: { + codex: 'codex-native', + gemini: 'cli', + }, + }, + }), + }, +})); + +vi.mock('../../../../src/main/services/runtime/ProviderConnectionService', () => ({ + providerConnectionService: { + augmentConfiguredConnectionEnv: ( + ...args: Parameters + ) => augmentConfiguredConnectionEnvMock(...args), + applyConfiguredConnectionEnv: (...args: Parameters) => + applyConfiguredConnectionEnvMock(...args), + getConfiguredConnectionIssues: ( + ...args: Parameters + ) => getConfiguredConnectionIssuesMock(...args), + getConfiguredConnectionLaunchArgs: ( + ...args: Parameters + ) => getConfiguredConnectionLaunchArgsMock(...args), + }, +})); + +vi.mock('@features/codex-runtime-installer/main', () => ({ + resolveVerifiedAppManagedCodexRuntimeBinaryPath: () => + resolveVerifiedAppManagedCodexRuntimeBinaryPathMock(), +})); + +import { resolveVerifiedOpenCodeRuntimeBinaryPath } from '../../../../src/main/services/infrastructure/OpenCodeRuntimeInstallerService'; +import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '../../../../src/main/services/runtime/openCodeBridgeRuntimeEnv'; +import { buildProviderAwareCliEnv } from '../../../../src/main/services/runtime/providerAwareCliEnv'; +import { execCli } from '../../../../src/main/utils/childProcess'; +import { setAppDataBasePath } from '../../../../src/main/utils/pathDecoder'; +import { clearShellEnvCache } from '../../../../src/main/utils/shellEnv'; + +const describePosix = process.platform === 'win32' ? describe.skip : describe; + +describePosix('OpenCode packaged-runtime preflight integration', () => { + let tempDir: string | null = null; + let originalPath: string | undefined; + let originalShell: string | undefined; + let originalFakeOpenCodeBinDir: string | undefined; + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-prod-preflight-')); + setAppDataBasePath(path.join(tempDir, 'app-data')); + clearShellEnvCache(); + + originalPath = process.env.PATH; + originalShell = process.env.SHELL; + originalFakeOpenCodeBinDir = process.env.FAKE_OPENCODE_BIN_DIR; + process.env.PATH = ''; + + vi.clearAllMocks(); + }); + + afterEach(async () => { + clearShellEnvCache(); + setAppDataBasePath(null); + + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + if (originalShell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = originalShell; + } + if (originalFakeOpenCodeBinDir === undefined) { + delete process.env.FAKE_OPENCODE_BIN_DIR; + } else { + process.env.FAKE_OPENCODE_BIN_DIR = originalFakeOpenCodeBinDir; + } + + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + async function createFakeOpenCodeBinary(): Promise<{ binDir: string; binaryPath: string }> { + const binDir = path.join(tempDir!, 'homebrew', 'bin'); + const binaryPath = path.join(binDir, 'opencode'); + await mkdir(binDir, { recursive: true }); + await writeFile( + binaryPath, + [ + '#!/bin/sh', + 'if [ "$1" = "--version" ]; then', + ' echo "opencode 9.9.9"', + ' exit 0', + 'fi', + 'echo "unexpected opencode args: $*" >&2', + 'exit 2', + ].join('\n'), + 'utf8' + ); + await chmod(binaryPath, 0o755); + return { binDir, binaryPath }; + } + + async function createFakeInteractiveShell(binDir: string): Promise { + const shellPath = path.join(tempDir!, 'fake-login-shell'); + process.env.FAKE_OPENCODE_BIN_DIR = binDir; + await writeFile( + shellPath, + [ + '#!/bin/sh', + 'printf "%s\\0" "PATH=$FAKE_OPENCODE_BIN_DIR" "HOME=$HOME" "SHELL=$0"', + ].join('\n'), + 'utf8' + ); + await chmod(shellPath, 0o755); + return shellPath; + } + + it('keeps OpenCode launch preflight and bridge commands working when packaged Electron starts with an empty PATH', async () => { + const { binDir, binaryPath } = await createFakeOpenCodeBinary(); + process.env.SHELL = await createFakeInteractiveShell(binDir); + + const providerEnv = await buildProviderAwareCliEnv({ + providerId: 'opencode', + connectionMode: 'augment', + shellEnv: {}, + env: { + PATH: '', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: 'node', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: '/mock/mcp-server/index.js', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: '["/mock/mcp-server/index.js"]', + }, + }); + + expect(providerEnv.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); + expect(providerEnv.env.OPENCODE_BIN_PATH).toBe(binaryPath); + expect(providerEnv.env.PATH?.split(path.delimiter)[0]).toBe(binDir); + expect(augmentConfiguredConnectionEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ + CLAUDE_CODE_ENTRY_PROVIDER: 'opencode', + CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: binaryPath, + OPENCODE_BIN_PATH: binaryPath, + }), + 'opencode', + undefined + ); + + const bridgeEnv: NodeJS.ProcessEnv = { PATH: '' }; + await ensureOpenCodeBridgeRuntimeBinaryEnv({ + targetEnv: bridgeEnv, + bridgeEnv, + resolveVerifiedOpenCodeRuntimeBinaryPath, + }); + + const commandEnv = { ...bridgeEnv }; + await ensureOpenCodeBridgeRuntimeBinaryEnv({ + targetEnv: commandEnv, + bridgeEnv, + resolveVerifiedOpenCodeRuntimeBinaryPath, + }); + + expect(commandEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); + expect(commandEnv.OPENCODE_BIN_PATH).toBe(binaryPath); + expect(commandEnv.PATH?.split(path.delimiter)[0]).toBe(binDir); + + const version = await execCli('opencode', ['--version'], { + env: commandEnv, + timeout: 2_000, + windowsHide: true, + }); + expect(version.stdout.trim()).toBe('opencode 9.9.9'); + }); +}); diff --git a/test/main/services/runtime/openCodeBridgeRuntimeEnv.test.ts b/test/main/services/runtime/openCodeBridgeRuntimeEnv.test.ts index 4a58532b..e452f94d 100644 --- a/test/main/services/runtime/openCodeBridgeRuntimeEnv.test.ts +++ b/test/main/services/runtime/openCodeBridgeRuntimeEnv.test.ts @@ -1,10 +1,31 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '../../../../src/main/services/runtime/openCodeBridgeRuntimeEnv'; describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => { + let tempDir: string | null = null; + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-bridge-runtime-env-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + async function writeExecutable(relativePath: string): Promise { + const binaryPath = path.join(tempDir!, relativePath); + await writeFile(binaryPath, 'binary', { mode: 0o755 }); + return binaryPath; + } + it('makes an app-managed OpenCode binary visible to PATH-based bridge inventory', async () => { const binaryPath = path.join(process.cwd(), 'managed opencode', 'bin', 'opencode'); const env: NodeJS.ProcessEnv = { @@ -13,7 +34,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => { await ensureOpenCodeBridgeRuntimeBinaryEnv({ targetEnv: env, - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => Promise.resolve(binaryPath), + resolveVerifiedOpenCodeRuntimeBinaryPath: () => Promise.resolve(binaryPath), }); expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); @@ -35,7 +56,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => { await ensureOpenCodeBridgeRuntimeBinaryEnv({ targetEnv: bridgeEnv, bridgeEnv, - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolver, + resolveVerifiedOpenCodeRuntimeBinaryPath: resolver, }); expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined(); @@ -45,7 +66,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => { await ensureOpenCodeBridgeRuntimeBinaryEnv({ targetEnv: commandEnv, bridgeEnv, - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolver, + resolveVerifiedOpenCodeRuntimeBinaryPath: resolver, }); expect(commandEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); @@ -57,7 +78,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => { }); it('honors a legacy OpenCode binary override already present in the command env', async () => { - const binaryPath = path.join(process.cwd(), 'legacy opencode', 'opencode'); + const binaryPath = await writeExecutable('legacy-opencode'); const env: NodeJS.ProcessEnv = { OPENCODE_BIN_PATH: binaryPath, PATH: '/usr/bin', @@ -66,7 +87,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => { await ensureOpenCodeBridgeRuntimeBinaryEnv({ targetEnv: env, - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolver, + resolveVerifiedOpenCodeRuntimeBinaryPath: resolver, }); expect(resolver).not.toHaveBeenCalled(); @@ -75,7 +96,52 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => { expect(env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath)); }); - it('keeps bridge startup non-fatal when the managed resolver fails', async () => { + it('normalizes a relative OpenCode binary override before exposing it to the bridge', async () => { + const binaryPath = await writeExecutable('relative-opencode'); + const relativeBinaryPath = path.relative(process.cwd(), binaryPath); + const env: NodeJS.ProcessEnv = { + OPENCODE_BIN_PATH: relativeBinaryPath, + PATH: '/usr/bin', + }; + const resolver = vi.fn<() => Promise>(); + + await ensureOpenCodeBridgeRuntimeBinaryEnv({ + targetEnv: env, + resolveVerifiedOpenCodeRuntimeBinaryPath: resolver, + }); + + expect(resolver).not.toHaveBeenCalled(); + expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); + expect(env.OPENCODE_BIN_PATH).toBe(binaryPath); + expect(env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath)); + }); + + it('replaces stale bridge-owned OpenCode binary env with a fresh verified resolver result', async () => { + const staleBinaryPath = path.join(tempDir!, 'missing-opencode'); + const binaryPath = path.join(process.cwd(), 'fresh managed opencode', 'opencode'); + const bridgeEnv: NodeJS.ProcessEnv = { + CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: staleBinaryPath, + OPENCODE_BIN_PATH: staleBinaryPath, + PATH: '/usr/bin', + }; + const commandEnv = { ...bridgeEnv }; + const resolver = vi.fn<() => Promise>().mockResolvedValue(binaryPath); + + await ensureOpenCodeBridgeRuntimeBinaryEnv({ + targetEnv: commandEnv, + bridgeEnv, + resolveVerifiedOpenCodeRuntimeBinaryPath: resolver, + }); + + expect(resolver).toHaveBeenCalledTimes(1); + expect(commandEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); + expect(commandEnv.OPENCODE_BIN_PATH).toBe(binaryPath); + expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); + expect(bridgeEnv.OPENCODE_BIN_PATH).toBe(binaryPath); + expect(commandEnv.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath)); + }); + + it('keeps bridge startup non-fatal when the runtime binary resolver fails', async () => { const onWarning = vi.fn(); const env: NodeJS.ProcessEnv = { PATH: '/usr/bin', @@ -84,14 +150,14 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => { await expect( ensureOpenCodeBridgeRuntimeBinaryEnv({ targetEnv: env, - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => + resolveVerifiedOpenCodeRuntimeBinaryPath: () => Promise.reject(new Error('manifest unreadable')), onWarning, }) ).resolves.toBeUndefined(); expect(onWarning).toHaveBeenCalledWith( - '[OpenCode] Runtime adapter bundled OpenCode binary unresolved: manifest unreadable' + '[OpenCode] Runtime adapter OpenCode binary unresolved: manifest unreadable' ); expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined(); expect(env.OPENCODE_BIN_PATH).toBeUndefined(); diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts index d9995c78..67ffc330 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -12,7 +12,7 @@ const applyConfiguredConnectionEnvMock = vi.fn(); const applyAllConfiguredConnectionEnvMock = vi.fn(); const getConfiguredConnectionIssuesMock = vi.fn(); const getConfiguredConnectionLaunchArgsMock = vi.fn(); -const resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock = vi.fn(); +const resolveVerifiedOpenCodeRuntimeBinaryPathMock = vi.fn(); const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn(); const resolveAgentTeamsMcpLaunchSpecMock = vi.fn(); @@ -62,8 +62,7 @@ vi.mock('../../../../src/main/services/runtime/ProviderConnectionService', () => })); vi.mock('../../../../src/main/services/infrastructure/OpenCodeRuntimeInstallerService', () => ({ - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock(), + resolveVerifiedOpenCodeRuntimeBinaryPath: () => resolveVerifiedOpenCodeRuntimeBinaryPathMock(), })); vi.mock('@features/codex-runtime-installer/main', () => ({ @@ -100,7 +99,7 @@ describe('buildProviderAwareCliEnv', () => { ); getConfiguredConnectionLaunchArgsMock.mockResolvedValue([]); getConfiguredConnectionIssuesMock.mockResolvedValue({}); - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(null); + resolveVerifiedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(null); resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null); resolveAgentTeamsMcpLaunchSpecMock.mockResolvedValue({ command: 'node', @@ -360,7 +359,7 @@ describe('buildProviderAwareCliEnv', () => { 'current', 'opencode' ); - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(appManagedBinaryPath); + resolveVerifiedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(appManagedBinaryPath); const { buildProviderAwareCliEnv } = await import('../../../../src/main/services/runtime/providerAwareCliEnv'); @@ -399,7 +398,7 @@ describe('buildProviderAwareCliEnv', () => { }); it('does not inject the app-managed OpenCode binary into non-OpenCode provider launches', async () => { - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue( + resolveVerifiedOpenCodeRuntimeBinaryPathMock.mockResolvedValue( '/Users/tester/App Support/runtimes/opencode/current/opencode' ); diff --git a/test/main/services/team/AgentTeamsMcpHttpServer.integration.test.ts b/test/main/services/team/AgentTeamsMcpHttpServer.integration.test.ts new file mode 100644 index 00000000..2b407a1c --- /dev/null +++ b/test/main/services/team/AgentTeamsMcpHttpServer.integration.test.ts @@ -0,0 +1,469 @@ +// @vitest-environment node +/* eslint-disable security/detect-non-literal-fs-filename, sonarjs/publicly-writable-directories */ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer'; +import { OpenCodeBridgeCommandClient } from '@main/services/team/opencode/bridge/OpenCodeBridgeCommandClient'; + +const FAKE_MCP_HTTP_SERVER_SOURCE = String.raw` +const fs = require('node:fs'); +const http = require('node:http'); + +function readArg(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : null; +} + +const host = readArg('--host') || '127.0.0.1'; +const endpoint = readArg('--endpoint') || '/mcp'; +const port = Number(readArg('--port')); +const controlFile = process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE; + +function readControl() { + if (!controlFile) { + return 'healthy'; + } + try { + return fs.readFileSync(controlFile, 'utf8').trim() || 'healthy'; + } catch { + return 'healthy'; + } +} + +function isUnhealthy() { + const control = readControl(); + return control === 'unhealthy-all' || control === 'unhealthy-port:' + port; +} + +const server = http.createServer((request, response) => { + if (request.url === '/health') { + if (isUnhealthy()) { + response.writeHead(503, { 'content-type': 'text/plain' }); + response.end('unhealthy'); + return; + } + response.writeHead(200, { 'content-type': 'text/plain' }); + response.end('ok'); + return; + } + + if (request.url === endpoint) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{"jsonrpc":"2.0","result":{}}'); + return; + } + + response.writeHead(404, { 'content-type': 'text/plain' }); + response.end('not found'); +}); + +server.listen(port, host); + +function shutdown() { + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 500).unref(); +} + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); +`; + +const FAKE_OPENCODE_BRIDGE_BINARY_SOURCE = String.raw`#!/usr/bin/env node +const fs = require('node:fs'); +const http = require('node:http'); + +function readArg(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : null; +} + +function readHealthStatus(url) { + return new Promise((resolve) => { + if (!url) { + resolve(null); + return; + } + const target = new URL(url); + target.pathname = '/health'; + target.search = ''; + target.hash = ''; + const request = http.get( + { + host: target.hostname, + port: Number(target.port), + path: target.pathname, + timeout: 750, + }, + (response) => { + response.resume(); + resolve(response.statusCode || null); + } + ); + request.once('timeout', () => { + request.destroy(); + resolve(null); + }); + request.once('error', () => resolve(null)); + }); +} + +async function main() { + const inputPath = readArg('--input'); + if (!inputPath) { + console.error('missing --input'); + process.exit(64); + } + + const envelope = JSON.parse(fs.readFileSync(inputPath, 'utf8')); + const mcpUrl = process.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL || null; + const healthStatus = await readHealthStatus(mcpUrl); + if (healthStatus !== 200) { + console.error( + JSON.stringify({ + kind: 'mcp_unreachable', + mcpUrl, + healthStatus, + }) + ); + process.exit(7); + } + + process.stdout.write( + JSON.stringify({ + ok: true, + schemaVersion: envelope.schemaVersion, + requestId: envelope.requestId, + command: envelope.command, + completedAt: new Date().toISOString(), + durationMs: 1, + runtime: { + providerId: 'opencode', + binaryPath: process.argv[1], + binaryFingerprint: 'fake-runtime', + version: 'fake-opencode-bridge-e2e', + capabilitySnapshotId: 'fake-capabilities', + }, + diagnostics: [], + data: { + runId: envelope.body && envelope.body.runId ? envelope.body.runId : null, + observedMcpUrl: mcpUrl, + healthStatus, + }, + }) + '\n' + ); +} + +main().catch((error) => { + console.error(error && error.stack ? error.stack : String(error)); + process.exit(1); +}); +`; + +const describePosix = process.platform === 'win32' ? describe.skip : describe; + +async function allocateLoopbackPort(excluded: Set = new Set()): Promise { + while (true) { + const port = await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate test port'))); + return; + } + server.close(() => resolve(address.port)); + }); + }); + if (!excluded.has(port)) { + excluded.add(port); + return port; + } + } +} + +async function readHealthStatus(url: string): Promise { + const target = new URL(url); + target.pathname = '/health'; + target.search = ''; + target.hash = ''; + + return new Promise((resolve) => { + const request = http.get( + { + host: target.hostname, + port: Number(target.port), + path: target.pathname, + timeout: 500, + }, + (response) => { + response.resume(); + resolve(response.statusCode ?? null); + } + ); + request.once('timeout', () => { + request.destroy(); + resolve(null); + }); + request.once('error', () => resolve(null)); + }); +} + +async function waitForHealthDown(url: string): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < 5_000) { + if ((await readHealthStatus(url)) !== 200) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`Expected ${url} health endpoint to go down`); +} + +async function writeFakeMcpHttpServer(tempDir: string): Promise { + const scriptDir = path.join(tempDir, 'fake-mcp'); + const scriptPath = path.join(scriptDir, 'server.cjs'); + await mkdir(scriptDir, { recursive: true }); + await writeFile(scriptPath, FAKE_MCP_HTTP_SERVER_SOURCE, 'utf8'); + return scriptPath; +} + +async function writeFakeOpenCodeBridgeBinary(tempDir: string): Promise { + const scriptDir = path.join(tempDir, 'fake-runtime'); + const scriptPath = path.join(scriptDir, 'claude-multimodel-fake'); + await mkdir(scriptDir, { recursive: true }); + await writeFile(scriptPath, FAKE_OPENCODE_BRIDGE_BINARY_SOURCE, 'utf8'); + await chmod(scriptPath, 0o755); + return scriptPath; +} + +describePosix('AgentTeamsMcpHttpServer integration', () => { + let tempDir: string | null = null; + let originalControlFileEnv: string | undefined; + const servers: AgentTeamsMcpHttpServer[] = []; + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'agent-teams-mcp-http-integration-')); + originalControlFileEnv = process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE; + }); + + afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.stop())); + vi.mocked(console.warn).mockClear(); + if (originalControlFileEnv === undefined) { + delete process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE; + } else { + process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE = originalControlFileEnv; + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + function createControlledServer(input: { + scriptPath: string; + controlFile: string; + allocatePort?: () => Promise; + }): AgentTeamsMcpHttpServer { + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: () => + Promise.resolve({ + command: process.execPath, + args: [input.scriptPath], + }), + allocatePort: input.allocatePort, + }); + servers.push(server); + + process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE = input.controlFile; + return server; + } + + it('starts the actual Agent Teams MCP HTTP server and proves its health endpoint', async () => { + const server = new AgentTeamsMcpHttpServer(); + servers.push(server); + + const handle = await server.ensureStarted(); + + expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/); + expect(handle.pid).toEqual(expect.any(Number)); + expect(await readHealthStatus(handle.url)).toBe(200); + }); + + it('reuses a healthy cached bridge URL after a real loopback health recheck', async () => { + const scriptPath = await writeFakeMcpHttpServer(tempDir!); + const controlFile = path.join(tempDir!, 'health-control.txt'); + await writeFile(controlFile, 'healthy', 'utf8'); + const server = createControlledServer({ scriptPath, controlFile }); + + const first = await server.ensureStarted(); + const second = await server.ensureStarted(); + + expect(second).toEqual(first); + expect(await readHealthStatus(first.url)).toBe(200); + expect(vi.mocked(console.warn).mock.calls).toEqual([]); + }); + + it('restarts a stale but still-running MCP HTTP child when cached URL health turns unhealthy', async () => { + const scriptPath = await writeFakeMcpHttpServer(tempDir!); + const controlFile = path.join(tempDir!, 'health-control.txt'); + const usedPorts = new Set(); + await writeFile(controlFile, 'healthy', 'utf8'); + const server = createControlledServer({ + scriptPath, + controlFile, + allocatePort: () => allocateLoopbackPort(usedPorts), + }); + + const first = await server.ensureStarted(); + await writeFile(controlFile, `unhealthy-port:${first.port}`, 'utf8'); + expect(await readHealthStatus(first.url)).toBe(503); + + const second = await server.ensureStarted(); + + expect(second.port).not.toBe(first.port); + expect(second.pid).not.toBe(first.pid); + expect(await readHealthStatus(second.url)).toBe(200); + expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain('failed health reuse check'); + vi.mocked(console.warn).mockClear(); + }); + + it('recovers when the cached MCP HTTP child dies and the old URL refuses connections', async () => { + const scriptPath = await writeFakeMcpHttpServer(tempDir!); + const controlFile = path.join(tempDir!, 'health-control.txt'); + const usedPorts = new Set(); + await writeFile(controlFile, 'healthy', 'utf8'); + const server = createControlledServer({ + scriptPath, + controlFile, + allocatePort: () => allocateLoopbackPort(usedPorts), + }); + + const first = await server.ensureStarted(); + expect(first.pid).toEqual(expect.any(Number)); + process.kill(first.pid!, 'SIGTERM'); + await waitForHealthDown(first.url); + + const second = await server.ensureStarted(); + + expect(second.port).not.toBe(first.port); + expect(second.pid).not.toBe(first.pid); + expect(await readHealthStatus(second.url)).toBe(200); + }); + + it('passes a refreshed MCP URL into a real bridge child process after the cached URL goes stale', async () => { + const scriptPath = await writeFakeMcpHttpServer(tempDir!); + const bridgeBinaryPath = await writeFakeOpenCodeBridgeBinary(tempDir!); + const controlFile = path.join(tempDir!, 'health-control.txt'); + const usedPorts = new Set(); + await writeFile(controlFile, 'healthy', 'utf8'); + const server = createControlledServer({ + scriptPath, + controlFile, + allocatePort: () => allocateLoopbackPort(usedPorts), + }); + const bridgeEnv: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + }; + let requestIdCounter = 0; + const client = new OpenCodeBridgeCommandClient({ + binaryPath: bridgeBinaryPath, + tempDirectory: path.join(tempDir!, 'bridge-input'), + env: bridgeEnv, + envProvider: async () => { + const mcpHttpServer = await server.ensureStarted(); + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; + return { + ...bridgeEnv, + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: mcpHttpServer.url, + }; + }, + requestIdFactory: () => { + requestIdCounter += 1; + return `req-refresh-${requestIdCounter}`; + }, + }); + + const firstResult = await client.execute<{ runId: string }, { observedMcpUrl: string }>( + 'opencode.launchTeam', + { runId: 'run-1' }, + { + cwd: tempDir!, + timeoutMs: 5_000, + } + ); + + expect(firstResult.ok).toBe(true); + if (!firstResult.ok) { + throw new Error(firstResult.error.message); + } + await writeFile( + controlFile, + `unhealthy-port:${new URL(firstResult.data.observedMcpUrl).port}`, + 'utf8' + ); + expect(await readHealthStatus(firstResult.data.observedMcpUrl)).toBe(503); + + const secondResult = await client.execute<{ runId: string }, { observedMcpUrl: string }>( + 'opencode.launchTeam', + { runId: 'run-2' }, + { + cwd: tempDir!, + timeoutMs: 5_000, + } + ); + + expect(secondResult.ok).toBe(true); + if (!secondResult.ok) { + throw new Error(secondResult.error.message); + } + expect(secondResult.data.observedMcpUrl).not.toBe(firstResult.data.observedMcpUrl); + expect(await readHealthStatus(secondResult.data.observedMcpUrl)).toBe(200); + }); + + it('fails closed when a bridge child receives an unreachable MCP URL without env refresh', async () => { + const scriptPath = await writeFakeMcpHttpServer(tempDir!); + const bridgeBinaryPath = await writeFakeOpenCodeBridgeBinary(tempDir!); + const controlFile = path.join(tempDir!, 'health-control.txt'); + await writeFile(controlFile, 'healthy', 'utf8'); + const server = createControlledServer({ scriptPath, controlFile }); + + const first = await server.ensureStarted(); + await server.stop(); + await waitForHealthDown(first.url); + + const client = new OpenCodeBridgeCommandClient({ + binaryPath: bridgeBinaryPath, + tempDirectory: path.join(tempDir!, 'bridge-input-stale'), + env: { + PATH: process.env.PATH, + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: first.url, + }, + requestIdFactory: () => 'req-stale-mcp', + }); + + const result = await client.execute<{ runId: string }, { observedMcpUrl: string }>( + 'opencode.launchTeam', + { runId: 'run-stale' }, + { + cwd: tempDir!, + timeoutMs: 5_000, + } + ); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error('Expected stale MCP URL to fail'); + } + expect(result.error.kind).toBe('provider_error'); + expect(result.error.details?.stderr).toContain('mcp_unreachable'); + expect(result.error.details?.stderr).toContain(first.url); + }); +}); diff --git a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts index a964b49e..d4ecb175 100644 --- a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts +++ b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts @@ -20,8 +20,13 @@ vi.mock('@main/utils/childProcess', async (importOriginal) => { import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer'; class FakeChildProcess extends EventEmitter { - pid = 43123; + pid: number; stderr = new EventEmitter(); + + constructor(pid = 43123) { + super(); + this.pid = pid; + } } async function allocateLoopbackPort(): Promise { @@ -145,6 +150,71 @@ describe('AgentTeamsMcpHttpServer', () => { expect(spawnProcess).toHaveBeenCalledTimes(1); }); + it('reuses an existing handle only after its health check still passes', async () => { + const child = new FakeChildProcess(); + const spawnProcess = vi.fn(() => child as any); + const waitForPort = vi.fn(async () => undefined); + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort: async () => 41006, + spawnProcess, + waitForPort, + }); + + const first = await server.ensureStarted(); + const second = await server.ensureStarted(); + + expect(second).toBe(first); + expect(spawnProcess).toHaveBeenCalledTimes(1); + expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41006, 5_000); + expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41006, 3_000); + expect(hoisted.killProcessTreeMock).not.toHaveBeenCalled(); + }); + + it('restarts a cached HTTP MCP server handle when the health check goes stale', async () => { + const firstChild = new FakeChildProcess(43123); + const secondChild = new FakeChildProcess(43124); + const spawnProcess = vi + .fn() + .mockReturnValueOnce(firstChild as any) + .mockReturnValueOnce(secondChild as any); + const allocatePort = vi.fn().mockResolvedValueOnce(41007).mockResolvedValueOnce(41008); + const waitForPort = vi.fn(async (_host: string, port: number, timeoutMs: number) => { + if (port === 41007 && timeoutMs === 3_000) { + throw new Error('stale health check'); + } + }); + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort, + spawnProcess, + waitForPort, + }); + + const first = await server.ensureStarted(); + const second = await server.ensureStarted(); + + expect(first.url).toBe('http://127.0.0.1:41007/mcp'); + expect(second).toEqual({ + url: 'http://127.0.0.1:41008/mcp', + port: 41008, + pid: 43124, + }); + expect(spawnProcess).toHaveBeenCalledTimes(2); + expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(firstChild, 'SIGKILL'); + expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 5_000); + expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 3_000); + expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41008, 5_000); + expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain('failed health reuse check'); + vi.mocked(console.warn).mockClear(); + }); + it('fails startup promptly when the child exits before readiness', async () => { const child = new FakeChildProcess(); const server = new AgentTeamsMcpHttpServer({ diff --git a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts index 7c67dd1c..e86e7afa 100644 --- a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts +++ b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts @@ -29,7 +29,10 @@ describe('OpenCodeTeamLaunchReadinessService', () => { state: 'not_installed', launchAllowed: false, hostHealthy: false, - diagnostics: ['PATH checked', 'OpenCode CLI not detected on PATH'], + diagnostics: [ + 'PATH checked', + 'OpenCode runtime binary is not installed or not reachable by launch preflight.', + ], }); expect(ports.capabilities.detect).not.toHaveBeenCalled(); expect(ports.mcpTools.prove).not.toHaveBeenCalled(); @@ -219,19 +222,19 @@ function createPorts( } { return { inventory: { - probe: vi.fn(async () => inventory(overrides.inventory)), + probe: vi.fn(() => Promise.resolve(inventory(overrides.inventory))), }, capabilities: { - detect: vi.fn(async () => overrides.capabilities ?? capabilities()), + detect: vi.fn(() => Promise.resolve(overrides.capabilities ?? capabilities())), }, mcpTools: { - prove: vi.fn(async () => overrides.toolProof ?? toolProof()), + prove: vi.fn(() => Promise.resolve(overrides.toolProof ?? toolProof())), }, runtimeStores: { - check: vi.fn(async () => overrides.runtimeStores ?? runtimeStores()), + check: vi.fn(() => Promise.resolve(overrides.runtimeStores ?? runtimeStores())), }, modelExecution: { - verify: vi.fn(async () => overrides.modelProbe ?? modelProbe()), + verify: vi.fn(() => Promise.resolve(overrides.modelProbe ?? modelProbe())), }, }; } diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 82f95e68..71726eee 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -1328,6 +1328,8 @@ describe('TeamProvisioningService prepare/auth behavior', () => { it('keeps deep OpenCode runtime failures provider-scoped instead of model-scoped', async () => { const runtimeFailure = 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?'; + const normalizedRuntimeFailure = + 'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge. Details: Unable to connect. Is the computer able to access the url?'; const prepare = vi.fn(async () => ({ ok: false as const, providerId: 'opencode' as const, @@ -1356,16 +1358,16 @@ describe('TeamProvisioningService prepare/auth behavior', () => { }); expect(result.ready).toBe(false); - expect(result.message).toBe(runtimeFailure); - expect(result.details).toEqual([runtimeFailure]); - expect(result.warnings).toEqual([runtimeFailure]); + expect(result.message).toBe(normalizedRuntimeFailure); + expect(result.details).toEqual([normalizedRuntimeFailure]); + expect(result.warnings).toEqual([normalizedRuntimeFailure]); expect(result.issues).toEqual([ { providerId: 'opencode', scope: 'provider', severity: 'blocking', code: 'mcp_unavailable', - message: runtimeFailure, + message: normalizedRuntimeFailure, }, ]); }); @@ -1414,6 +1416,8 @@ describe('TeamProvisioningService prepare/auth behavior', () => { }); it('keeps shared OpenCode MCP compatibility failures provider-scoped', async () => { + const normalizedRuntimeFailure = + 'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge. Details: Unable to connect. Is the computer able to access the url?'; const prepare = vi.fn(async () => ({ ok: false as const, providerId: 'opencode' as const, @@ -1444,27 +1448,62 @@ describe('TeamProvisioningService prepare/auth behavior', () => { }); expect(result.ready).toBe(false); - expect(result.message).toBe( - 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?' - ); - expect(result.details).toEqual([ - 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', - ]); - expect(result.warnings).toEqual([ - 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', - ]); + expect(result.message).toBe(normalizedRuntimeFailure); + expect(result.details).toEqual([normalizedRuntimeFailure]); + expect(result.warnings).toEqual([normalizedRuntimeFailure]); expect(result.issues).toEqual([ { providerId: 'opencode', scope: 'provider', severity: 'blocking', code: 'mcp_unavailable', - message: - 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', + message: normalizedRuntimeFailure, }, ]); }); + it('restores OpenCode MCP context when the bridge reports only a plain connect failure', async () => { + const normalizedRuntimeFailure = + 'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.'; + const prepare = vi.fn(async () => ({ + ok: false as const, + providerId: 'opencode' as const, + reason: 'mcp_unavailable', + retryable: true, + diagnostics: ['Unable to connect. Is the computer able to access the url?'], + warnings: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare, + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + } as any, + ]); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(registry); + + const result = await svc.prepareForProvisioning(tempRoot, { + providerId: 'opencode', + forceFresh: true, + modelIds: ['opencode/big-pickle'], + modelVerificationMode: 'compatibility', + }); + + expect(result.ready).toBe(false); + expect(result.message).toBe(normalizedRuntimeFailure); + expect(result.details).toEqual([normalizedRuntimeFailure]); + expect(result.issues?.[0]).toMatchObject({ + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: 'mcp_unavailable', + message: normalizedRuntimeFailure, + }); + }); + it('normalizes unexpected OpenCode model prepare exceptions into a blocking diagnostic', async () => { const prepare = vi.fn(async (input: { model?: string }) => { if (input.model === 'opencode/nemotron-3-super-free') { diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index 061853eb..a5c3d73a 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -615,6 +615,62 @@ describe('CLI status visibility during completed install state', () => { }); }); + it('shows OpenCode model loading instead of the summary-only big-pickle badge', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: true, + providers: [ + { + providerId: 'opencode', + displayName: 'OpenCode (200+ models)', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + statusMessage: null, + models: ['opencode/big-pickle'], + canLoginFromUi: false, + capabilities: { + teamLaunch: true, + oneShot: false, + }, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + modelCatalog: null, + modelCatalogRefreshState: 'idle', + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Loading models...'); + expect(host.textContent).not.toContain('big-pickle'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('preserves dashboard runtime backend refresh errors for the manage dialog', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; diff --git a/test/renderer/components/runtime/providerConnectionUi.test.ts b/test/renderer/components/runtime/providerConnectionUi.test.ts index b2620e4d..330c3759 100644 --- a/test/renderer/components/runtime/providerConnectionUi.test.ts +++ b/test/renderer/components/runtime/providerConnectionUi.test.ts @@ -6,6 +6,7 @@ import { getProviderCredentialSummary, getProviderCurrentRuntimeSummary, isProviderInventoryOnlyFallback, + isOpenCodeCatalogHydrating, isConnectionManagedRuntimeProvider, shouldShowProviderConnectAction, } from '@renderer/components/runtime/providerConnectionUi'; @@ -226,6 +227,47 @@ describe('providerConnectionUi', () => { expect(getProviderCredentialSummary(provider)).toBe('API key also configured in Manage'); }); + it('treats the OpenCode summary-only big-pickle model as catalog hydration', () => { + const provider: CliProviderStatus = { + providerId: 'opencode', + displayName: 'OpenCode (200+ models)', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + modelCatalogRefreshState: 'idle', + statusMessage: null, + models: ['opencode/big-pickle'], + modelCatalog: null, + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, + canLoginFromUi: false, + capabilities: { + teamLaunch: true, + oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), + }, + }; + + expect(isOpenCodeCatalogHydrating(provider)).toBe(true); + expect( + isOpenCodeCatalogHydrating({ + ...provider, + modelCatalogRefreshState: 'ready', + }) + ).toBe(false); + expect( + isOpenCodeCatalogHydrating({ + ...provider, + models: ['opencode/big-pickle', 'openrouter/qwen/qwen3-coder-plus'], + }) + ).toBe(false); + }); + it('does not describe Anthropic API key mode as subscription connected when the key is missing', () => { const provider = createAnthropicProvider({ authenticated: true, diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index 10decff9..6476019b 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -369,7 +369,13 @@ describe('TeamModelSelector disabled Codex models', () => { }, models: ['opencode/big-pickle'], modelCatalog: null, - modelCatalogRefreshState: 'loading', + modelCatalogRefreshState: 'idle', + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, modelVerificationState: 'idle', modelAvailability: [], }, diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index 99d0a019..01af0818 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -107,7 +107,7 @@ describe('ProvisioningProviderStatusList', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('OpenCode (OpenCode CLI): Needs attention'); + expect(host.textContent).toContain('OpenCode (OpenCode CLI): OpenCode app MCP unreachable'); expect(host.textContent).not.toContain('Selected model checks'); expect(host.textContent).not.toContain('model unavailable'); @@ -119,12 +119,14 @@ describe('ProvisioningProviderStatusList', () => { it('gives a concrete hint for missing OpenCode runtime binary failures', () => { expect( - getProvisioningFailureHint('CLI environment is not available - launch is blocked', [ + getProvisioningFailureHint('Runtime environment is not available - launch is blocked', [ { providerId: 'opencode', status: 'failed', backendSummary: null, - details: ['OpenCode runtime binary is not installed or not reachable by launch preflight.'], + details: [ + 'OpenCode runtime binary is not installed or not reachable by launch preflight.', + ], }, ]) ).toBe( @@ -132,6 +134,23 @@ describe('ProvisioningProviderStatusList', () => { ); }); + it('gives a concrete hint for stale OpenCode app MCP bridge failures', () => { + expect( + getProvisioningFailureHint('Runtime environment is not available - launch is blocked', [ + { + providerId: 'opencode', + status: 'failed', + backendSummary: null, + details: [ + 'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge. Details: Unable to connect. Is the computer able to access the url?', + ], + }, + ]) + ).toBe( + 'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.' + ); + }); + it('picks the first real failure detail instead of a verified line', () => { expect( getPrimaryProvisioningFailureDetail([ diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts index afe53c1d..865a7d88 100644 --- a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -9,6 +9,11 @@ import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSel import type { TeamProviderId, TeamProvisioningPrepareResult } from '@shared/types'; +const OPENCODE_RAW_MCP_UNREACHABLE = + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?'; +const OPENCODE_NORMALIZED_MCP_UNREACHABLE = + 'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge. Details: Unable to connect. Is the computer able to access the url?'; + function createDeferred(): { promise: Promise; resolve: (value: T) => void; @@ -340,9 +345,7 @@ describe('runProviderPrepareDiagnostics', () => { Promise.resolve({ ready: false, message: 'OpenCode: mcp_unavailable', - details: [ - 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', - ], + details: [OPENCODE_RAW_MCP_UNREACHABLE], }) ); @@ -355,7 +358,7 @@ describe('runProviderPrepareDiagnostics', () => { expect(result.status).toBe('failed'); expect(result.details).toEqual([ - 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', + OPENCODE_NORMALIZED_MCP_UNREACHABLE, 'OpenCode: mcp_unavailable', ]); expect(result.modelResultsById).toEqual({}); @@ -406,14 +409,14 @@ describe('runProviderPrepareDiagnostics', () => { }); expect(result.status).toBe('failed'); - expect(result.details).toEqual(['Future OpenCode health check failed without known marker words']); + expect(result.details).toEqual([ + 'Future OpenCode health check failed without known marker words', + ]); expect(result.modelResultsById).toEqual({}); expect(result.details.join('\n')).not.toContain('big-pickle - unavailable'); }); it('deduplicates repeated OpenCode provider runtime failure details', async () => { - const runtimeFailure = - 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?'; const prepareProvisioning = vi.fn< ( cwd?: string, @@ -426,9 +429,9 @@ describe('runProviderPrepareDiagnostics', () => { >(() => Promise.resolve({ ready: false, - message: runtimeFailure, - details: [runtimeFailure], - warnings: [runtimeFailure], + message: OPENCODE_RAW_MCP_UNREACHABLE, + details: [OPENCODE_RAW_MCP_UNREACHABLE], + warnings: [OPENCODE_RAW_MCP_UNREACHABLE], }) ); @@ -440,8 +443,8 @@ describe('runProviderPrepareDiagnostics', () => { }); expect(result.status).toBe('failed'); - expect(result.details).toEqual([runtimeFailure]); - expect(result.warnings).toEqual([runtimeFailure]); + expect(result.details).toEqual([OPENCODE_NORMALIZED_MCP_UNREACHABLE]); + expect(result.warnings).toEqual([OPENCODE_NORMALIZED_MCP_UNREACHABLE]); expect(result.modelResultsById).toEqual({}); }); @@ -543,8 +546,6 @@ describe('runProviderPrepareDiagnostics', () => { }); it('keeps stale OpenCode model-scoped runtime failures provider-scoped', async () => { - const runtimeFailure = - 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?'; const prepareProvisioning = vi.fn< ( cwd?: string, @@ -557,8 +558,10 @@ describe('runProviderPrepareDiagnostics', () => { >(() => Promise.resolve({ ready: false, - message: `Selected model opencode/big-pickle could not be verified. ${runtimeFailure}`, - warnings: [`Selected model opencode/big-pickle could not be verified. ${runtimeFailure}`], + message: `Selected model opencode/big-pickle could not be verified. ${OPENCODE_RAW_MCP_UNREACHABLE}`, + warnings: [ + `Selected model opencode/big-pickle could not be verified. ${OPENCODE_RAW_MCP_UNREACHABLE}`, + ], }) ); @@ -570,7 +573,7 @@ describe('runProviderPrepareDiagnostics', () => { }); expect(result.status).toBe('failed'); - expect(result.details).toEqual([runtimeFailure]); + expect(result.details).toEqual([OPENCODE_NORMALIZED_MCP_UNREACHABLE]); expect(result.warnings).toEqual([]); expect(result.modelResultsById).toEqual({}); }); @@ -663,9 +666,7 @@ describe('runProviderPrepareDiagnostics', () => { return Promise.resolve({ ready: true, message: 'CLI is ready to launch', - details: [ - 'Selected model opencode/big-pickle is compatible. Deep verification pending.', - ], + details: ['Selected model opencode/big-pickle is compatible. Deep verification pending.'], }); } @@ -674,9 +675,7 @@ describe('runProviderPrepareDiagnostics', () => { return Promise.resolve({ ready: false, message: 'OpenCode: mcp_unavailable', - details: [ - 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', - ], + details: [OPENCODE_RAW_MCP_UNREACHABLE], }); }); @@ -689,7 +688,7 @@ describe('runProviderPrepareDiagnostics', () => { expect(result.status).toBe('failed'); expect(result.details).toEqual([ - 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', + OPENCODE_NORMALIZED_MCP_UNREACHABLE, 'OpenCode: mcp_unavailable', ]); expect(result.modelResultsById).toEqual({}); @@ -713,9 +712,7 @@ describe('runProviderPrepareDiagnostics', () => { return Promise.resolve({ ready: true, message: 'CLI is ready to launch', - details: [ - 'Selected model opencode/big-pickle is compatible. Deep verification pending.', - ], + details: ['Selected model opencode/big-pickle is compatible. Deep verification pending.'], }); } @@ -767,9 +764,7 @@ describe('runProviderPrepareDiagnostics', () => { return Promise.resolve({ ready: true, message: 'CLI is ready to launch', - details: [ - 'Selected model opencode/big-pickle is compatible. Deep verification pending.', - ], + details: ['Selected model opencode/big-pickle is compatible. Deep verification pending.'], }); } @@ -830,9 +825,7 @@ describe('runProviderPrepareDiagnostics', () => { return Promise.resolve({ ready: true, message: 'CLI is ready to launch', - details: [ - 'Selected model opencode/big-pickle is compatible. Deep verification pending.', - ], + details: ['Selected model opencode/big-pickle is compatible. Deep verification pending.'], }); } @@ -866,6 +859,52 @@ describe('runProviderPrepareDiagnostics', () => { expect(prepareProvisioning).toHaveBeenCalledTimes(2); }); + it('uses structured mcp_unavailable code to explain plain OpenCode connect failures', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[], + limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' + ) => Promise + >((_cwd, _providerId, _providerIds, selectedModels, _limitContext, modelVerificationMode) => { + if (modelVerificationMode === 'compatibility') { + expect(selectedModels).toEqual(['opencode/big-pickle']); + return Promise.resolve({ + ready: false, + message: 'Unable to connect. Is the computer able to access the url?', + issues: [ + { + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: 'mcp_unavailable', + message: 'Unable to connect. Is the computer able to access the url?', + }, + ], + }); + } + + throw new Error('deep verification should not run'); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'opencode', + selectedModelIds: ['opencode/big-pickle'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + 'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.', + ]); + expect(result.modelResultsById).toEqual({}); + expect(prepareProvisioning).toHaveBeenCalledTimes(1); + }); + it('keeps OpenCode deep selected-model failures scoped to the selected model', async () => { const prepareProvisioning = vi.fn< ( @@ -892,7 +931,8 @@ describe('runProviderPrepareDiagnostics', () => { expect(selectedModels).toEqual(['openrouter/example/not-available']); return Promise.resolve({ ready: false, - message: 'API Error: 400 {"detail":"The requested model is not available for your account."}', + message: + 'API Error: 400 {"detail":"The requested model is not available for your account."}', }); }); diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnosticsOpenCodeRuntime.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnosticsOpenCodeRuntime.test.ts index 6cc566d2..1650a774 100644 --- a/test/renderer/components/team/dialogs/providerPrepareDiagnosticsOpenCodeRuntime.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnosticsOpenCodeRuntime.test.ts @@ -42,4 +42,34 @@ describe('runProviderPrepareDiagnostics OpenCode runtime failures', () => { 'compatibility' ); }); + + it('normalizes structured OpenCode provider issue messages that bypass runtime details', async () => { + const prepareProvisioning = vi.fn().mockResolvedValue({ + ready: false, + message: 'not_installed', + details: ['OpenCode CLI not detected on PATH'], + issues: [ + { + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: 'not_installed', + message: 'OpenCode CLI not detected on PATH', + }, + ], + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/Users/tester/project', + providerId: 'opencode', + selectedModelIds: ['opencode/big-pickle'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + 'OpenCode runtime binary is not installed or not reachable by launch preflight.', + ]); + expect(result.modelResultsById).toEqual({}); + }); });