fix(opencode): improve runtime preflight diagnostics
This commit is contained in:
parent
88e01ae87d
commit
5e0d552cb9
29 changed files with 1573 additions and 171 deletions
|
|
@ -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<string | null> {
|
||||
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),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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<OpenCodeBinaryVersionProbe> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv(options);
|
||||
if (!binaryPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await probeOpenCodeBinaryVersion(binaryPath)).ok ? binaryPath : null;
|
||||
}
|
||||
|
||||
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
): Promise<string | null> {
|
||||
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<OpenCodeRuntimeStatus> {
|
||||
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<OpenCodeRuntimeStatus> {
|
||||
|
|
|
|||
|
|
@ -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<string | null>;
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath: () => Promise<string | null>;
|
||||
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<string>
|
||||
): 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<void> {
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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<AgentTeamsMcpHttpServerHandle> | null = null;
|
||||
private child: ChildProcess | null = null;
|
||||
private handle: AgentTeamsMcpHttpServerHandle | null = null;
|
||||
private readonly expectedStopChildren = new WeakSet<ChildProcess>();
|
||||
|
||||
constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {}
|
||||
|
||||
async ensureStarted(): Promise<AgentTeamsMcpHttpServerHandle> {
|
||||
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<AgentTeamsMcpHttpServerHandle> {
|
||||
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<AgentTeamsMcpHttpServerHandle> {
|
||||
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<never>((_, 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? <span>{connectionModeSummary}</span> : null}
|
||||
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
||||
{provider.models.length === 0 && modelCatalogLoading ? (
|
||||
<span>Loading models...</span>
|
||||
) : null}
|
||||
{modelCatalogLoading ? <span>Loading models...</span> : null}
|
||||
{provider.models.length === 0 && !modelCatalogLoading && (
|
||||
<span>Models unavailable for this runtime build</span>
|
||||
)}
|
||||
|
|
@ -1121,7 +1123,7 @@ const InstalledBanner = ({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!showSkeleton && provider.models.length > 0 && (
|
||||
{!showSkeleton && !modelCatalogLoading && provider.models.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,45 @@ export function isProviderInventoryOnlyFallback(provider: CliProviderStatus): bo
|
|||
);
|
||||
}
|
||||
|
||||
export function isOpenCodeCatalogHydrating(
|
||||
provider:
|
||||
| Pick<
|
||||
CliProviderStatus,
|
||||
| 'providerId'
|
||||
| 'models'
|
||||
| 'modelCatalog'
|
||||
| 'modelCatalogRefreshState'
|
||||
| 'runtimeCapabilities'
|
||||
>
|
||||
| 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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
|||
<span>{connectionModeSummary}</span>
|
||||
) : null}
|
||||
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
||||
{provider.models.length === 0 && modelCatalogLoading ? (
|
||||
<span>Loading models...</span>
|
||||
) : null}
|
||||
{modelCatalogLoading ? <span>Loading models...</span> : null}
|
||||
{provider.models.length === 0 && !modelCatalogLoading && (
|
||||
<span>Models unavailable for this runtime build</span>
|
||||
)}
|
||||
|
|
@ -645,16 +646,18 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{!effectiveShowSkeleton && provider.models.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
models={provider.models}
|
||||
modelAvailability={provider.modelAvailability}
|
||||
providerStatus={provider}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!effectiveShowSkeleton &&
|
||||
!modelCatalogLoading &&
|
||||
provider.models.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
models={provider.models}
|
||||
modelAvailability={provider.modelAvailability}
|
||||
providerStatus={provider}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -2368,7 +2368,7 @@ export const CreateTeamDialog = ({
|
|||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
CLI environment is not available - launch is blocked
|
||||
Runtime environment is not available - launch is blocked
|
||||
</p>
|
||||
<p className="mt-0.5 text-red-300/80">
|
||||
{effectivePrepare.message ?? 'Failed to prepare environment'}
|
||||
|
|
|
|||
|
|
@ -2981,8 +2981,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
CLI environment is not available - {isRelaunch ? 'relaunch' : 'launch'} is
|
||||
blocked
|
||||
Runtime environment is not available - {isRelaunch ? 'relaunch' : 'launch'}{' '}
|
||||
is blocked
|
||||
</p>
|
||||
<p className="mt-0.5 text-red-300/80">
|
||||
{effectivePrepare.message ?? 'Failed to prepare environment'}
|
||||
|
|
|
|||
|
|
@ -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') ||
|
||||
|
|
|
|||
|
|
@ -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<TeamModelSelectorProps> = ({
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ function escapeRegExp(value: string): string {
|
|||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function uniquePrepareLines(lines: Array<string | null | undefined>): string[] {
|
||||
function uniquePrepareLines(lines: (string | null | undefined)[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
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: {},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<typeof resolveInteractiveShellEnvBestEffortMock>
|
||||
) => 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', () => {
|
||||
|
|
|
|||
|
|
@ -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<typeof augmentConfiguredConnectionEnvMock>
|
||||
) => augmentConfiguredConnectionEnvMock(...args),
|
||||
applyConfiguredConnectionEnv: (...args: Parameters<typeof applyConfiguredConnectionEnvMock>) =>
|
||||
applyConfiguredConnectionEnvMock(...args),
|
||||
getConfiguredConnectionIssues: (
|
||||
...args: Parameters<typeof getConfiguredConnectionIssuesMock>
|
||||
) => getConfiguredConnectionIssuesMock(...args),
|
||||
getConfiguredConnectionLaunchArgs: (
|
||||
...args: Parameters<typeof getConfiguredConnectionLaunchArgsMock>
|
||||
) => 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<string> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string> {
|
||||
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<string | null>>();
|
||||
|
||||
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<string | null>>().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();
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<number> = new Set<number>()): Promise<number> {
|
||||
while (true) {
|
||||
const port = await new Promise<number>((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<number | null> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<number>;
|
||||
}): 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<number>();
|
||||
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<number>();
|
||||
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<number>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<number> {
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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<T>(): {
|
||||
promise: Promise<T>;
|
||||
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<TeamProvisioningPrepareResult>
|
||||
>((_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."}',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PrepareProvisioningFn>().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({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue