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,
|
TeamTaskStallSnapshotSource,
|
||||||
TeamTranscriptSourceLocator,
|
TeamTranscriptSourceLocator,
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
|
resolveVerifiedOpenCodeRuntimeBinaryPath,
|
||||||
} from './services';
|
} from './services';
|
||||||
|
|
||||||
import type { FileChangeEvent } from '@main/types';
|
import type { FileChangeEvent } from '@main/types';
|
||||||
|
|
@ -343,10 +343,8 @@ function describeMemberWorkSyncReviewPickupEscalationReason(reason: string): str
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveOpenCodeRuntimeBinaryForBridgeEnv(): Promise<string | null> {
|
async function resolveOpenCodeRuntimeBinaryForBridgeEnv(): Promise<string | null> {
|
||||||
const manifestBinaryPath = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
|
const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath();
|
||||||
if (manifestBinaryPath) {
|
if (resolvedBinaryPath) return resolvedBinaryPath;
|
||||||
return manifestBinaryPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await openCodeRuntimeInstallerService?.getStatus();
|
const status = await openCodeRuntimeInstallerService?.getStatus();
|
||||||
|
|
@ -435,7 +433,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
||||||
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
targetEnv,
|
targetEnv,
|
||||||
bridgeEnv,
|
bridgeEnv,
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv,
|
resolveVerifiedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv,
|
||||||
onWarning: (message) => logger.warn(message),
|
onWarning: (message) => logger.warn(message),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
import { getAppDataPath } from '@main/utils/pathDecoder';
|
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
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 { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { createHash, randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
|
@ -22,6 +22,7 @@ const MAX_TARBALL_BYTES = 250 * 1024 * 1024;
|
||||||
const MAX_BINARY_BYTES = 350 * 1024 * 1024;
|
const MAX_BINARY_BYTES = 350 * 1024 * 1024;
|
||||||
const FETCH_TIMEOUT_MS = 60_000;
|
const FETCH_TIMEOUT_MS = 60_000;
|
||||||
const VERSION_TIMEOUT_MS = 10_000;
|
const VERSION_TIMEOUT_MS = 10_000;
|
||||||
|
const PATH_SHELL_ENV_TIMEOUT_MS = 1_500;
|
||||||
|
|
||||||
interface NpmPackageMetadata {
|
interface NpmPackageMetadata {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
@ -134,9 +135,15 @@ function splitPathEnv(pathValue: string | undefined): string[] {
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePathOpenCodeBinary(): string | null {
|
function resolvePathOpenCodeBinary(
|
||||||
|
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = []
|
||||||
|
): string | null {
|
||||||
const shellEnv = getCachedShellEnv() ?? {};
|
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>();
|
const seen = new Set<string>();
|
||||||
for (const entry of pathEntries) {
|
for (const entry of pathEntries) {
|
||||||
const normalizedEntry = path.resolve(entry);
|
const normalizedEntry = path.resolve(entry);
|
||||||
|
|
@ -154,6 +161,57 @@ function resolvePathOpenCodeBinary(): string | null {
|
||||||
return 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 {
|
function isLinuxMuslRuntime(): boolean {
|
||||||
if (process.platform !== 'linux') {
|
if (process.platform !== 'linux') {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -466,31 +524,27 @@ export class OpenCodeRuntimeInstallerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPathStatus(): Promise<OpenCodeRuntimeStatus> {
|
private async getPathStatus(): Promise<OpenCodeRuntimeStatus> {
|
||||||
const binaryPath = resolvePathOpenCodeBinary();
|
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv();
|
||||||
if (!binaryPath) {
|
if (!binaryPath) {
|
||||||
return { installed: false, source: 'missing', state: 'idle' };
|
return { installed: false, source: 'missing', state: 'idle' };
|
||||||
}
|
}
|
||||||
try {
|
const version = await probeOpenCodeBinaryVersion(binaryPath);
|
||||||
const { stdout } = await execCli(binaryPath, ['--version'], {
|
if (!version.ok) {
|
||||||
timeout: VERSION_TIMEOUT_MS,
|
|
||||||
windowsHide: true,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
installed: true,
|
|
||||||
binaryPath,
|
|
||||||
version: stdout.trim() || undefined,
|
|
||||||
source: 'path',
|
|
||||||
state: 'ready',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
return {
|
||||||
installed: false,
|
installed: false,
|
||||||
binaryPath,
|
binaryPath,
|
||||||
source: 'path',
|
source: 'path',
|
||||||
state: 'failed',
|
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> {
|
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 { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,16 +12,76 @@ import {
|
||||||
export interface EnsureOpenCodeBridgeRuntimeBinaryEnvOptions {
|
export interface EnsureOpenCodeBridgeRuntimeBinaryEnvOptions {
|
||||||
targetEnv: NodeJS.ProcessEnv;
|
targetEnv: NodeJS.ProcessEnv;
|
||||||
bridgeEnv?: NodeJS.ProcessEnv;
|
bridgeEnv?: NodeJS.ProcessEnv;
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => Promise<string | null>;
|
resolveVerifiedOpenCodeRuntimeBinaryPath: () => Promise<string | null>;
|
||||||
onWarning?: (message: string) => void;
|
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({
|
export async function ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
targetEnv,
|
targetEnv,
|
||||||
bridgeEnv = targetEnv,
|
bridgeEnv = targetEnv,
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
|
resolveVerifiedOpenCodeRuntimeBinaryPath,
|
||||||
onWarning,
|
onWarning,
|
||||||
}: EnsureOpenCodeBridgeRuntimeBinaryEnvOptions): Promise<void> {
|
}: 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 (
|
if (
|
||||||
targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim() ||
|
targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim() ||
|
||||||
targetEnv[OPENCODE_LEGACY_BINARY_PATH_ENV]?.trim()
|
targetEnv[OPENCODE_LEGACY_BINARY_PATH_ENV]?.trim()
|
||||||
|
|
@ -28,8 +91,8 @@ export async function ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
|
const openCodeBinary = await resolveVerifiedOpenCodeRuntimeBinaryPath();
|
||||||
applyOpenCodeRuntimeBinaryEnv(targetEnv, appManagedOpenCodeBinary);
|
applyOpenCodeRuntimeBinaryEnv(targetEnv, openCodeBinary);
|
||||||
if (
|
if (
|
||||||
targetEnv !== bridgeEnv &&
|
targetEnv !== bridgeEnv &&
|
||||||
targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV] &&
|
targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV] &&
|
||||||
|
|
@ -38,8 +101,6 @@ export async function ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
applyOpenCodeRuntimeBinaryEnv(bridgeEnv, targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]);
|
applyOpenCodeRuntimeBinaryEnv(bridgeEnv, targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onWarning?.(
|
onWarning?.(`[OpenCode] Runtime adapter OpenCode binary unresolved: ${getErrorMessage(error)}`);
|
||||||
`[OpenCode] Runtime adapter bundled OpenCode binary unresolved: ${getErrorMessage(error)}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { resolveVerifiedAppManagedCodexRuntimeBinaryPath } from '@features/codex-runtime-installer/main';
|
import { resolveVerifiedAppManagedCodexRuntimeBinaryPath } from '@features/codex-runtime-installer/main';
|
||||||
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||||
|
|
||||||
import { resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService';
|
import { resolveVerifiedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService';
|
||||||
|
|
||||||
import { ensureAgentTeamsMcpLocalLaunchEnv } from './agentTeamsMcpLaunchEnv';
|
import { ensureAgentTeamsMcpLocalLaunchEnv } from './agentTeamsMcpLaunchEnv';
|
||||||
import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv';
|
import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv';
|
||||||
|
|
@ -45,8 +45,8 @@ export async function buildProviderAwareCliEnv(
|
||||||
env: options.env,
|
env: options.env,
|
||||||
});
|
});
|
||||||
if (!resolvedProviderId || resolvedProviderId === 'opencode') {
|
if (!resolvedProviderId || resolvedProviderId === 'opencode') {
|
||||||
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
|
const openCodeBinary = await resolveVerifiedOpenCodeRuntimeBinaryPath();
|
||||||
applyOpenCodeRuntimeBinaryEnv(env, appManagedOpenCodeBinary);
|
applyOpenCodeRuntimeBinaryEnv(env, openCodeBinary);
|
||||||
}
|
}
|
||||||
const appManagedCodexBinary = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
|
const appManagedCodexBinary = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const logger = createLogger('Service:AgentTeamsMcpHttpServer');
|
||||||
const MCP_HTTP_HOST = '127.0.0.1';
|
const MCP_HTTP_HOST = '127.0.0.1';
|
||||||
const MCP_HTTP_ENDPOINT = '/mcp';
|
const MCP_HTTP_ENDPOINT = '/mcp';
|
||||||
const MCP_HTTP_READY_TIMEOUT_MS = 5_000;
|
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;
|
const MCP_HTTP_READY_POLL_MS = 100;
|
||||||
|
|
||||||
export interface AgentTeamsMcpHttpServerHandle {
|
export interface AgentTeamsMcpHttpServerHandle {
|
||||||
|
|
@ -120,18 +121,18 @@ export class AgentTeamsMcpHttpServer {
|
||||||
private startPromise: Promise<AgentTeamsMcpHttpServerHandle> | null = null;
|
private startPromise: Promise<AgentTeamsMcpHttpServerHandle> | null = null;
|
||||||
private child: ChildProcess | null = null;
|
private child: ChildProcess | null = null;
|
||||||
private handle: AgentTeamsMcpHttpServerHandle | null = null;
|
private handle: AgentTeamsMcpHttpServerHandle | null = null;
|
||||||
|
private readonly expectedStopChildren = new WeakSet<ChildProcess>();
|
||||||
|
|
||||||
constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {}
|
constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {}
|
||||||
|
|
||||||
async ensureStarted(): Promise<AgentTeamsMcpHttpServerHandle> {
|
async ensureStarted(): Promise<AgentTeamsMcpHttpServerHandle> {
|
||||||
if (this.handle) {
|
|
||||||
return this.handle;
|
|
||||||
}
|
|
||||||
if (this.startPromise) {
|
if (this.startPromise) {
|
||||||
return this.startPromise;
|
return this.startPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.startPromise = this.startOnce().finally(() => {
|
this.startPromise = (
|
||||||
|
this.handle ? this.reuseOrRestartExistingHandle(this.handle) : this.startOnce()
|
||||||
|
).finally(() => {
|
||||||
this.startPromise = null;
|
this.startPromise = null;
|
||||||
});
|
});
|
||||||
return this.startPromise;
|
return this.startPromise;
|
||||||
|
|
@ -142,10 +143,34 @@ export class AgentTeamsMcpHttpServer {
|
||||||
this.child = null;
|
this.child = null;
|
||||||
this.handle = null;
|
this.handle = null;
|
||||||
if (child) {
|
if (child) {
|
||||||
|
this.expectedStopChildren.add(child);
|
||||||
killProcessTree(child, 'SIGKILL');
|
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> {
|
private async startOnce(): Promise<AgentTeamsMcpHttpServerHandle> {
|
||||||
const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec;
|
const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec;
|
||||||
const allocatePort = this.deps.allocatePort ?? allocateLoopbackPort;
|
const allocatePort = this.deps.allocatePort ?? allocateLoopbackPort;
|
||||||
|
|
@ -181,14 +206,21 @@ export class AgentTeamsMcpHttpServer {
|
||||||
let startupSettled = false;
|
let startupSettled = false;
|
||||||
const startupFailure = new Promise<never>((_, reject) => {
|
const startupFailure = new Promise<never>((_, reject) => {
|
||||||
child.once('exit', (code, signal) => {
|
child.once('exit', (code, signal) => {
|
||||||
|
const expectedStop = this.expectedStopChildren.delete(child);
|
||||||
clearIfCurrent();
|
clearIfCurrent();
|
||||||
const codeSuffix = typeof code === 'number' ? ` with code ${code}` : '';
|
const codeSuffix = typeof code === 'number' ? ` with code ${code}` : '';
|
||||||
const signalSuffix = signal ? ` (${signal})` : '';
|
const signalSuffix = signal ? ` (${signal})` : '';
|
||||||
const message = `Agent Teams MCP HTTP server exited before startup completed${codeSuffix}${signalSuffix}`;
|
const message = `Agent Teams MCP HTTP server exited before startup completed${codeSuffix}${signalSuffix}`;
|
||||||
if (!startupSettled) {
|
if (!startupSettled && !expectedStop) {
|
||||||
reject(new Error(message));
|
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) => {
|
child.once('error', (error) => {
|
||||||
clearIfCurrent();
|
clearIfCurrent();
|
||||||
|
|
@ -216,6 +248,7 @@ export class AgentTeamsMcpHttpServer {
|
||||||
this.child = null;
|
this.child = null;
|
||||||
this.handle = null;
|
this.handle = null;
|
||||||
}
|
}
|
||||||
|
this.expectedStopChildren.add(child);
|
||||||
killProcessTree(child, 'SIGKILL');
|
killProcessTree(child, 'SIGKILL');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -915,6 +915,10 @@ const OPENCODE_PROVIDER_SCOPED_PREPARE_FAILURE_REASONS = new Set([
|
||||||
'mcp_unavailable',
|
'mcp_unavailable',
|
||||||
'adapter_disabled',
|
'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 {
|
function pushUniqueLine(lines: string[], line: string): void {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
|
|
@ -931,10 +935,41 @@ function looksLikeOpenCodeProviderPrepareDiagnostic(value: string): boolean {
|
||||||
lower.includes('mcp_unavailable') ||
|
lower.includes('mcp_unavailable') ||
|
||||||
lower.includes('runtime store') ||
|
lower.includes('runtime store') ||
|
||||||
lower.includes('opencode cli') ||
|
lower.includes('opencode cli') ||
|
||||||
|
lower.includes('opencode runtime binary') ||
|
||||||
lower.includes('unable to connect')
|
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 {
|
function isRetryableOpenCodePreflightBusyDiagnostic(value: string | null | undefined): boolean {
|
||||||
const lower = value?.trim().toLowerCase() ?? '';
|
const lower = value?.trim().toLowerCase() ?? '';
|
||||||
if (!lower) {
|
if (!lower) {
|
||||||
|
|
@ -18553,10 +18588,21 @@ export class TeamProvisioningService {
|
||||||
expectedMembers: [],
|
expectedMembers: [],
|
||||||
previousLaunchState: null,
|
previousLaunchState: null,
|
||||||
});
|
});
|
||||||
details.push(...prepare.diagnostics);
|
const prepareReason = prepare.ok ? undefined : prepare.reason;
|
||||||
warnings.push(...prepare.warnings);
|
details.push(
|
||||||
|
...prepare.diagnostics.map((diagnostic) =>
|
||||||
|
normalizeOpenCodePrepareDiagnostic(diagnostic, prepareReason)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
warnings.push(
|
||||||
|
...prepare.warnings.map((warning) =>
|
||||||
|
normalizeOpenCodePrepareDiagnostic(warning, prepareReason)
|
||||||
|
)
|
||||||
|
);
|
||||||
if (!prepare.ok) {
|
if (!prepare.ok) {
|
||||||
blockingMessages.push(`OpenCode: ${prepare.reason}`);
|
blockingMessages.push(
|
||||||
|
normalizeOpenCodePrepareDiagnostic(`OpenCode: ${prepare.reason}`, prepare.reason)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -18876,7 +18922,12 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { modelId, prepare } = result;
|
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) {
|
if (prepare.ok) {
|
||||||
details.push(
|
details.push(
|
||||||
verificationMode === 'compatibility'
|
verificationMode === 'compatibility'
|
||||||
|
|
@ -18886,8 +18937,10 @@ export class TeamProvisioningService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryReason =
|
const primaryReason = normalizeOpenCodePrepareDiagnostic(
|
||||||
prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason;
|
prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason,
|
||||||
|
prepare.reason
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
prepare.retryable &&
|
prepare.retryable &&
|
||||||
[primaryReason, prepare.reason, ...prepare.diagnostics].some(
|
[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', {
|
appendPreflightDebugLog('opencode_compatibility_batch_shared_prepare', {
|
||||||
cwd,
|
cwd,
|
||||||
modelIds,
|
modelIds,
|
||||||
|
|
@ -19042,8 +19100,10 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!sharedPrepare.ok) {
|
if (!sharedPrepare.ok) {
|
||||||
const primaryReason =
|
const primaryReason = normalizeOpenCodePrepareDiagnostic(
|
||||||
sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason;
|
sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason,
|
||||||
|
sharedPrepare.reason
|
||||||
|
);
|
||||||
if (primaryReason.trim().length > 0) {
|
if (primaryReason.trim().length > 0) {
|
||||||
details.push(primaryReason);
|
details.push(primaryReason);
|
||||||
blockingMessages.push(primaryReason);
|
blockingMessages.push(primaryReason);
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,9 @@ export interface OpenCodeTeamLaunchReadinessServiceOptions {
|
||||||
versionPolicy?: OpenCodeSupportedVersionPolicy;
|
versionPolicy?: OpenCodeSupportedVersionPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC =
|
||||||
|
'OpenCode runtime binary is not installed or not reachable by launch preflight.';
|
||||||
|
|
||||||
export class OpenCodeTeamLaunchReadinessService {
|
export class OpenCodeTeamLaunchReadinessService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly inventory: OpenCodeRuntimeInventoryPort,
|
private readonly inventory: OpenCodeRuntimeInventoryPort,
|
||||||
|
|
@ -124,7 +127,7 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
inventory,
|
inventory,
|
||||||
modelId: input.selectedModel,
|
modelId: input.selectedModel,
|
||||||
diagnostics: appendDiagnostics(inventory.diagnostics, [
|
diagnostics: appendDiagnostics(inventory.diagnostics, [
|
||||||
'OpenCode CLI not detected on PATH',
|
OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC,
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
getProviderCredentialSummary,
|
getProviderCredentialSummary,
|
||||||
getProviderCurrentRuntimeSummary,
|
getProviderCurrentRuntimeSummary,
|
||||||
getProviderDisconnectAction,
|
getProviderDisconnectAction,
|
||||||
|
isOpenCodeCatalogHydrating,
|
||||||
isConnectionManagedRuntimeProvider,
|
isConnectionManagedRuntimeProvider,
|
||||||
shouldShowProviderConnectAction,
|
shouldShowProviderConnectAction,
|
||||||
} from '@renderer/components/runtime/providerConnectionUi';
|
} from '@renderer/components/runtime/providerConnectionUi';
|
||||||
|
|
@ -868,13 +869,16 @@ const InstalledBanner = ({
|
||||||
((provider.providerId === 'codex' && codexRateLimitsLoading) ||
|
((provider.providerId === 'codex' && codexRateLimitsLoading) ||
|
||||||
anthropicRateLimitsLoading));
|
anthropicRateLimitsLoading));
|
||||||
const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider);
|
const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider);
|
||||||
const modelCatalogLoading = provider.modelCatalogRefreshState === 'loading';
|
const modelCatalogLoading =
|
||||||
|
provider.modelCatalogRefreshState === 'loading' ||
|
||||||
|
isOpenCodeCatalogHydrating(provider);
|
||||||
const hasDetailContent = Boolean(
|
const hasDetailContent = Boolean(
|
||||||
(provider.backend?.label && !runtimeSummary) ||
|
(provider.backend?.label && !runtimeSummary) ||
|
||||||
runtimeSummary ||
|
runtimeSummary ||
|
||||||
connectionModeSummary ||
|
connectionModeSummary ||
|
||||||
credentialSummary ||
|
credentialSummary ||
|
||||||
provider.models.length === 0
|
provider.models.length === 0 ||
|
||||||
|
modelCatalogLoading
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -936,9 +940,7 @@ const InstalledBanner = ({
|
||||||
) : null}
|
) : null}
|
||||||
{connectionModeSummary ? <span>{connectionModeSummary}</span> : null}
|
{connectionModeSummary ? <span>{connectionModeSummary}</span> : null}
|
||||||
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
||||||
{provider.models.length === 0 && modelCatalogLoading ? (
|
{modelCatalogLoading ? <span>Loading models...</span> : null}
|
||||||
<span>Loading models...</span>
|
|
||||||
) : null}
|
|
||||||
{provider.models.length === 0 && !modelCatalogLoading && (
|
{provider.models.length === 0 && !modelCatalogLoading && (
|
||||||
<span>Models unavailable for this runtime build</span>
|
<span>Models unavailable for this runtime build</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1121,7 +1123,7 @@ const InstalledBanner = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!showSkeleton && provider.models.length > 0 && (
|
{!showSkeleton && !modelCatalogLoading && provider.models.length > 0 && (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<ProviderModelBadges
|
<ProviderModelBadges
|
||||||
providerId={provider.providerId}
|
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 {
|
export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean {
|
||||||
return provider.providerId === 'codex';
|
return provider.providerId === 'codex';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
getProviderCredentialSummary,
|
getProviderCredentialSummary,
|
||||||
getProviderCurrentRuntimeSummary,
|
getProviderCurrentRuntimeSummary,
|
||||||
getProviderDisconnectAction,
|
getProviderDisconnectAction,
|
||||||
|
isOpenCodeCatalogHydrating,
|
||||||
isConnectionManagedRuntimeProvider,
|
isConnectionManagedRuntimeProvider,
|
||||||
shouldShowProviderConnectAction,
|
shouldShowProviderConnectAction,
|
||||||
} from '@renderer/components/runtime/providerConnectionUi';
|
} from '@renderer/components/runtime/providerConnectionUi';
|
||||||
|
|
@ -524,7 +525,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
? 'Checking...'
|
? 'Checking...'
|
||||||
: formatProviderStatusText(provider);
|
: formatProviderStatusText(provider);
|
||||||
const modelCatalogLoading =
|
const modelCatalogLoading =
|
||||||
provider.modelCatalogRefreshState === 'loading';
|
provider.modelCatalogRefreshState === 'loading' ||
|
||||||
|
isOpenCodeCatalogHydrating(provider);
|
||||||
const connectionModeSummary = getProviderConnectionModeSummary(provider);
|
const connectionModeSummary = getProviderConnectionModeSummary(provider);
|
||||||
const credentialSummary = getProviderCredentialSummary(provider);
|
const credentialSummary = getProviderCredentialSummary(provider);
|
||||||
const disconnectAction = getProviderDisconnectAction(provider);
|
const disconnectAction = getProviderDisconnectAction(provider);
|
||||||
|
|
@ -533,7 +535,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
runtimeSummary ||
|
runtimeSummary ||
|
||||||
connectionModeSummary ||
|
connectionModeSummary ||
|
||||||
credentialSummary ||
|
credentialSummary ||
|
||||||
provider.models.length === 0
|
provider.models.length === 0 ||
|
||||||
|
modelCatalogLoading
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -585,9 +588,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
<span>{connectionModeSummary}</span>
|
<span>{connectionModeSummary}</span>
|
||||||
) : null}
|
) : null}
|
||||||
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
||||||
{provider.models.length === 0 && modelCatalogLoading ? (
|
{modelCatalogLoading ? <span>Loading models...</span> : null}
|
||||||
<span>Loading models...</span>
|
|
||||||
) : null}
|
|
||||||
{provider.models.length === 0 && !modelCatalogLoading && (
|
{provider.models.length === 0 && !modelCatalogLoading && (
|
||||||
<span>Models unavailable for this runtime build</span>
|
<span>Models unavailable for this runtime build</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -645,16 +646,18 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!effectiveShowSkeleton && provider.models.length > 0 && (
|
{!effectiveShowSkeleton &&
|
||||||
<div className="col-span-2">
|
!modelCatalogLoading &&
|
||||||
<ProviderModelBadges
|
provider.models.length > 0 && (
|
||||||
providerId={provider.providerId}
|
<div className="col-span-2">
|
||||||
models={provider.models}
|
<ProviderModelBadges
|
||||||
modelAvailability={provider.modelAvailability}
|
providerId={provider.providerId}
|
||||||
providerStatus={provider}
|
models={provider.models}
|
||||||
/>
|
modelAvailability={provider.modelAvailability}
|
||||||
</div>
|
providerStatus={provider}
|
||||||
)}
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
|
|
@ -2368,7 +2368,7 @@ export const CreateTeamDialog = ({
|
||||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
CLI environment is not available - launch is blocked
|
Runtime environment is not available - launch is blocked
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-red-300/80">
|
<p className="mt-0.5 text-red-300/80">
|
||||||
{effectivePrepare.message ?? 'Failed to prepare environment'}
|
{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" />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
CLI environment is not available - {isRelaunch ? 'relaunch' : 'launch'} is
|
Runtime environment is not available - {isRelaunch ? 'relaunch' : 'launch'}{' '}
|
||||||
blocked
|
is blocked
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-red-300/80">
|
<p className="mt-0.5 text-red-300/80">
|
||||||
{effectivePrepare.message ?? 'Failed to prepare environment'}
|
{effectivePrepare.message ?? 'Failed to prepare environment'}
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,8 @@ export function failIncompleteProviderChecks(
|
||||||
|
|
||||||
type ProvisioningDetailSummary =
|
type ProvisioningDetailSummary =
|
||||||
| 'CLI binary missing'
|
| 'CLI binary missing'
|
||||||
|
| 'OpenCode runtime missing'
|
||||||
|
| 'OpenCode app MCP unreachable'
|
||||||
| 'Working directory missing'
|
| 'Working directory missing'
|
||||||
| 'CLI binary could not be started'
|
| 'CLI binary could not be started'
|
||||||
| 'CLI preflight did not complete'
|
| 'CLI preflight did not complete'
|
||||||
|
|
@ -198,6 +200,16 @@ function summarizeDetail(
|
||||||
if (lower.includes('spawn ') && lower.includes(' enoent')) {
|
if (lower.includes('spawn ') && lower.includes(' enoent')) {
|
||||||
return 'CLI binary missing';
|
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:')) {
|
if (lower.includes('working directory does not exist:')) {
|
||||||
return 'Working directory missing';
|
return 'Working directory missing';
|
||||||
}
|
}
|
||||||
|
|
@ -406,6 +418,8 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string {
|
||||||
check.status === 'failed'
|
check.status === 'failed'
|
||||||
? (summarizedDetails.find(
|
? (summarizedDetails.find(
|
||||||
(detail) =>
|
(detail) =>
|
||||||
|
detail === 'OpenCode app MCP unreachable' ||
|
||||||
|
detail === 'OpenCode runtime missing' ||
|
||||||
detail === 'Selected model unavailable' ||
|
detail === 'Selected model unavailable' ||
|
||||||
detail === 'Selected model check failed' ||
|
detail === 'Selected model check failed' ||
|
||||||
detail === 'Authentication required' ||
|
detail === 'Authentication required' ||
|
||||||
|
|
@ -436,6 +450,8 @@ function getDetailTone(
|
||||||
summary === 'Selected model unavailable' ||
|
summary === 'Selected model unavailable' ||
|
||||||
summary === 'Selected model check failed' ||
|
summary === 'Selected model check failed' ||
|
||||||
summary === 'CLI binary missing' ||
|
summary === 'CLI binary missing' ||
|
||||||
|
summary === 'OpenCode runtime missing' ||
|
||||||
|
summary === 'OpenCode app MCP unreachable' ||
|
||||||
summary === 'Working directory missing' ||
|
summary === 'Working directory missing' ||
|
||||||
summary === 'CLI binary could not be started' ||
|
summary === 'CLI binary could not be started' ||
|
||||||
summary === 'CLI preflight did not complete' ||
|
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.';
|
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 (
|
if (
|
||||||
combined.includes('spawn ') ||
|
combined.includes('spawn ') ||
|
||||||
combined.includes(' enoent') ||
|
combined.includes(' enoent') ||
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||||
|
import { isOpenCodeCatalogHydrating } from '@renderer/components/runtime/providerConnectionUi';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
import { HoverTooltip } from '@renderer/components/ui/hover-tooltip';
|
import { HoverTooltip } from '@renderer/components/ui/hover-tooltip';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
|
|
@ -999,12 +1000,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
const visibleConcreteModelOptionCount =
|
const visibleConcreteModelOptionCount =
|
||||||
visibleModelOptions.length - visibleDefaultModelOptions.length;
|
visibleModelOptions.length - visibleDefaultModelOptions.length;
|
||||||
const concreteModelOptionCount = modelOptions.filter((option) => option.value.trim()).length;
|
const concreteModelOptionCount = modelOptions.filter((option) => option.value.trim()).length;
|
||||||
const shouldShowOpenCodeCatalogLoading =
|
const shouldShowOpenCodeCatalogLoading = isOpenCodeCatalogHydrating(runtimeProviderStatus);
|
||||||
effectiveProviderId === 'opencode' &&
|
|
||||||
runtimeProviderStatus?.modelCatalogRefreshState === 'loading' &&
|
|
||||||
runtimeProviderStatus.modelCatalog?.providerId !== 'opencode' &&
|
|
||||||
(runtimeProviderStatus.models.length === 0 ||
|
|
||||||
runtimeProviderStatus.models.every((model) => model.trim() === 'opencode/big-pickle'));
|
|
||||||
const shouldShowModelSearch = !shouldShowOpenCodeCatalogLoading && concreteModelOptionCount > 8;
|
const shouldShowModelSearch = !shouldShowOpenCodeCatalogLoading && concreteModelOptionCount > 8;
|
||||||
const trimmedModelQuery = modelQuery.trim();
|
const trimmedModelQuery = modelQuery.trim();
|
||||||
const shouldConstrainModelListHeight = visibleModelOptions.length > 8;
|
const shouldConstrainModelListHeight = visibleModelOptions.length > 8;
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ function escapeRegExp(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
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 seen = new Set<string>();
|
||||||
const uniqueLines: string[] = [];
|
const uniqueLines: string[] = [];
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|
@ -252,7 +252,8 @@ function looksLikeOpenCodeRuntimeFailureReason(reason: string | null | undefined
|
||||||
lower.includes('mcp_unavailable') ||
|
lower.includes('mcp_unavailable') ||
|
||||||
lower.includes('unable to connect') ||
|
lower.includes('unable to connect') ||
|
||||||
lower.includes('runtime store') ||
|
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(
|
function isAdvisoryOpenCodeDeepVerificationIssue(
|
||||||
issue: TeamProvisioningPrepareIssue | null,
|
issue: TeamProvisioningPrepareIssue | null,
|
||||||
reason: string | null | undefined
|
reason: string | null | undefined
|
||||||
|
|
@ -304,7 +298,8 @@ function isAdvisoryOpenCodeDeepVerificationIssue(
|
||||||
lower.includes('api key') ||
|
lower.includes('api key') ||
|
||||||
lower.includes('/experimental/tool') ||
|
lower.includes('/experimental/tool') ||
|
||||||
lower.includes('runtime store') ||
|
lower.includes('runtime store') ||
|
||||||
lower.includes('opencode cli');
|
lower.includes('opencode cli') ||
|
||||||
|
lower.includes('opencode runtime binary');
|
||||||
if (hasHardRuntimeMarker) {
|
if (hasHardRuntimeMarker) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -437,10 +432,17 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRuntimeWarningLines(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();
|
const trimmed = detail?.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return null;
|
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.';
|
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;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,7 +474,9 @@ function createRuntimeFailureDetailLines(
|
||||||
message: string | null | undefined
|
message: string | null | undefined
|
||||||
): string[] {
|
): string[] {
|
||||||
return uniquePrepareLines(
|
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,
|
uncachedModelIds,
|
||||||
compatibilityResult
|
compatibilityResult
|
||||||
);
|
);
|
||||||
const structuredProviderScopedFailure = getBlockingProviderIssueMessage(
|
const structuredProviderScopedIssue = getBlockingProviderIssue(
|
||||||
providerId,
|
providerId,
|
||||||
compatibilityResult
|
compatibilityResult
|
||||||
);
|
);
|
||||||
|
const structuredProviderScopedFailure =
|
||||||
|
structuredProviderScopedIssue?.message.trim() ?? null;
|
||||||
if (structuredProviderScopedFailure || providerScopedFailure) {
|
if (structuredProviderScopedFailure || providerScopedFailure) {
|
||||||
return {
|
return {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
details: [
|
details: [
|
||||||
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
|
normalizeRuntimeFailureDetailLine(
|
||||||
|
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
|
||||||
|
structuredProviderScopedIssue?.code
|
||||||
|
) ?? 'OpenCode failed',
|
||||||
],
|
],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
modelResultsById: {},
|
modelResultsById: {},
|
||||||
|
|
@ -1195,7 +1218,12 @@ export async function runProviderPrepareDiagnostics({
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
details: [failureReason],
|
details: [
|
||||||
|
normalizeRuntimeFailureDetailLine(
|
||||||
|
failureReason,
|
||||||
|
structuredProviderScopedIssue?.code
|
||||||
|
) ?? failureReason,
|
||||||
|
],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
modelResultsById: {},
|
modelResultsById: {},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,33 @@ import { gzipSync } from 'zlib';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const execCliMock = vi.hoisted(() => vi.fn());
|
const execCliMock = vi.hoisted(() => vi.fn());
|
||||||
|
const getCachedShellEnvMock = vi.hoisted(() => vi.fn());
|
||||||
|
const resolveInteractiveShellEnvBestEffortMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock('@main/utils/childProcess', () => ({
|
vi.mock('@main/utils/childProcess', () => ({
|
||||||
execCli: execCliMock,
|
execCli: execCliMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@main/utils/shellEnv', () => ({
|
||||||
|
getCachedShellEnv: () => getCachedShellEnvMock(),
|
||||||
|
resolveInteractiveShellEnvBestEffort: (
|
||||||
|
...args: Parameters<typeof resolveInteractiveShellEnvBestEffortMock>
|
||||||
|
) => resolveInteractiveShellEnvBestEffortMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
extractOpenCodeRuntimeBinaryFromTarball,
|
extractOpenCodeRuntimeBinaryFromTarball,
|
||||||
getOpenCodeRuntimePlatformCandidates,
|
getOpenCodeRuntimePlatformCandidates,
|
||||||
|
OpenCodeRuntimeInstallerService,
|
||||||
resolveAppManagedOpenCodeRuntimeBinaryPath,
|
resolveAppManagedOpenCodeRuntimeBinaryPath,
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
|
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
|
||||||
|
resolveVerifiedOpenCodeRuntimeBinaryPath,
|
||||||
verifyOpenCodeRuntimePackageIntegrity,
|
verifyOpenCodeRuntimePackageIntegrity,
|
||||||
} from '@main/services/infrastructure/OpenCodeRuntimeInstallerService';
|
} from '@main/services/infrastructure/OpenCodeRuntimeInstallerService';
|
||||||
import { setAppDataBasePath } from '@main/utils/pathDecoder';
|
import { setAppDataBasePath } from '@main/utils/pathDecoder';
|
||||||
|
|
||||||
let tempRoot: string | null = null;
|
let tempRoot: string | null = null;
|
||||||
|
let originalPath: string | undefined;
|
||||||
|
|
||||||
function writeOctal(header: Buffer, offset: number, length: number, value: number): void {
|
function writeOctal(header: Buffer, offset: number, length: number, value: number): void {
|
||||||
const encoded = value
|
const encoded = value
|
||||||
|
|
@ -63,12 +75,24 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-runtime-resolver-'));
|
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-runtime-resolver-'));
|
||||||
setAppDataBasePath(tempRoot);
|
setAppDataBasePath(tempRoot);
|
||||||
|
originalPath = process.env.PATH;
|
||||||
|
process.env.PATH = '';
|
||||||
execCliMock.mockReset();
|
execCliMock.mockReset();
|
||||||
execCliMock.mockResolvedValue({ stdout: 'opencode 1.0.0\n', stderr: '' });
|
execCliMock.mockResolvedValue({ stdout: 'opencode 1.0.0\n', stderr: '' });
|
||||||
|
getCachedShellEnvMock.mockReset();
|
||||||
|
getCachedShellEnvMock.mockReturnValue(null);
|
||||||
|
resolveInteractiveShellEnvBestEffortMock.mockReset();
|
||||||
|
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue(process.env);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
setAppDataBasePath(null);
|
setAppDataBasePath(null);
|
||||||
|
if (originalPath === undefined) {
|
||||||
|
delete process.env.PATH;
|
||||||
|
} else {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
}
|
||||||
|
originalPath = undefined;
|
||||||
if (tempRoot) {
|
if (tempRoot) {
|
||||||
await rm(tempRoot, { recursive: true, force: true });
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
tempRoot = null;
|
tempRoot = null;
|
||||||
|
|
@ -163,6 +187,46 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
|
||||||
|
|
||||||
await expect(resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()).resolves.toBeNull();
|
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', () => {
|
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 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';
|
import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '../../../../src/main/services/runtime/openCodeBridgeRuntimeEnv';
|
||||||
|
|
||||||
describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
|
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 () => {
|
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 binaryPath = path.join(process.cwd(), 'managed opencode', 'bin', 'opencode');
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
|
@ -13,7 +34,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
|
||||||
|
|
||||||
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
targetEnv: env,
|
targetEnv: env,
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => Promise.resolve(binaryPath),
|
resolveVerifiedOpenCodeRuntimeBinaryPath: () => Promise.resolve(binaryPath),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
|
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
|
||||||
|
|
@ -35,7 +56,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
|
||||||
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
targetEnv: bridgeEnv,
|
targetEnv: bridgeEnv,
|
||||||
bridgeEnv,
|
bridgeEnv,
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolver,
|
resolveVerifiedOpenCodeRuntimeBinaryPath: resolver,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined();
|
expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined();
|
||||||
|
|
@ -45,7 +66,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
|
||||||
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
targetEnv: commandEnv,
|
targetEnv: commandEnv,
|
||||||
bridgeEnv,
|
bridgeEnv,
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolver,
|
resolveVerifiedOpenCodeRuntimeBinaryPath: resolver,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(commandEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
|
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 () => {
|
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 = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
OPENCODE_BIN_PATH: binaryPath,
|
OPENCODE_BIN_PATH: binaryPath,
|
||||||
PATH: '/usr/bin',
|
PATH: '/usr/bin',
|
||||||
|
|
@ -66,7 +87,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
|
||||||
|
|
||||||
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
targetEnv: env,
|
targetEnv: env,
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolver,
|
resolveVerifiedOpenCodeRuntimeBinaryPath: resolver,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resolver).not.toHaveBeenCalled();
|
expect(resolver).not.toHaveBeenCalled();
|
||||||
|
|
@ -75,7 +96,52 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
|
||||||
expect(env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath));
|
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 onWarning = vi.fn();
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
PATH: '/usr/bin',
|
PATH: '/usr/bin',
|
||||||
|
|
@ -84,14 +150,14 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
|
||||||
await expect(
|
await expect(
|
||||||
ensureOpenCodeBridgeRuntimeBinaryEnv({
|
ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
targetEnv: env,
|
targetEnv: env,
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () =>
|
resolveVerifiedOpenCodeRuntimeBinaryPath: () =>
|
||||||
Promise.reject(new Error('manifest unreadable')),
|
Promise.reject(new Error('manifest unreadable')),
|
||||||
onWarning,
|
onWarning,
|
||||||
})
|
})
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
expect(onWarning).toHaveBeenCalledWith(
|
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.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined();
|
||||||
expect(env.OPENCODE_BIN_PATH).toBeUndefined();
|
expect(env.OPENCODE_BIN_PATH).toBeUndefined();
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const applyConfiguredConnectionEnvMock = vi.fn();
|
||||||
const applyAllConfiguredConnectionEnvMock = vi.fn();
|
const applyAllConfiguredConnectionEnvMock = vi.fn();
|
||||||
const getConfiguredConnectionIssuesMock = vi.fn();
|
const getConfiguredConnectionIssuesMock = vi.fn();
|
||||||
const getConfiguredConnectionLaunchArgsMock = vi.fn();
|
const getConfiguredConnectionLaunchArgsMock = vi.fn();
|
||||||
const resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock = vi.fn();
|
const resolveVerifiedOpenCodeRuntimeBinaryPathMock = vi.fn();
|
||||||
const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn();
|
const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn();
|
||||||
const resolveAgentTeamsMcpLaunchSpecMock = vi.fn();
|
const resolveAgentTeamsMcpLaunchSpecMock = vi.fn();
|
||||||
|
|
||||||
|
|
@ -62,8 +62,7 @@ vi.mock('../../../../src/main/services/runtime/ProviderConnectionService', () =>
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../../src/main/services/infrastructure/OpenCodeRuntimeInstallerService', () => ({
|
vi.mock('../../../../src/main/services/infrastructure/OpenCodeRuntimeInstallerService', () => ({
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () =>
|
resolveVerifiedOpenCodeRuntimeBinaryPath: () => resolveVerifiedOpenCodeRuntimeBinaryPathMock(),
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@features/codex-runtime-installer/main', () => ({
|
vi.mock('@features/codex-runtime-installer/main', () => ({
|
||||||
|
|
@ -100,7 +99,7 @@ describe('buildProviderAwareCliEnv', () => {
|
||||||
);
|
);
|
||||||
getConfiguredConnectionLaunchArgsMock.mockResolvedValue([]);
|
getConfiguredConnectionLaunchArgsMock.mockResolvedValue([]);
|
||||||
getConfiguredConnectionIssuesMock.mockResolvedValue({});
|
getConfiguredConnectionIssuesMock.mockResolvedValue({});
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(null);
|
resolveVerifiedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(null);
|
||||||
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null);
|
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null);
|
||||||
resolveAgentTeamsMcpLaunchSpecMock.mockResolvedValue({
|
resolveAgentTeamsMcpLaunchSpecMock.mockResolvedValue({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
|
|
@ -360,7 +359,7 @@ describe('buildProviderAwareCliEnv', () => {
|
||||||
'current',
|
'current',
|
||||||
'opencode'
|
'opencode'
|
||||||
);
|
);
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(appManagedBinaryPath);
|
resolveVerifiedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(appManagedBinaryPath);
|
||||||
|
|
||||||
const { buildProviderAwareCliEnv } =
|
const { buildProviderAwareCliEnv } =
|
||||||
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
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 () => {
|
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'
|
'/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';
|
import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
|
||||||
|
|
||||||
class FakeChildProcess extends EventEmitter {
|
class FakeChildProcess extends EventEmitter {
|
||||||
pid = 43123;
|
pid: number;
|
||||||
stderr = new EventEmitter();
|
stderr = new EventEmitter();
|
||||||
|
|
||||||
|
constructor(pid = 43123) {
|
||||||
|
super();
|
||||||
|
this.pid = pid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function allocateLoopbackPort(): Promise<number> {
|
async function allocateLoopbackPort(): Promise<number> {
|
||||||
|
|
@ -145,6 +150,71 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
expect(spawnProcess).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('fails startup promptly when the child exits before readiness', async () => {
|
||||||
const child = new FakeChildProcess();
|
const child = new FakeChildProcess();
|
||||||
const server = new AgentTeamsMcpHttpServer({
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@ describe('OpenCodeTeamLaunchReadinessService', () => {
|
||||||
state: 'not_installed',
|
state: 'not_installed',
|
||||||
launchAllowed: false,
|
launchAllowed: false,
|
||||||
hostHealthy: 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.capabilities.detect).not.toHaveBeenCalled();
|
||||||
expect(ports.mcpTools.prove).not.toHaveBeenCalled();
|
expect(ports.mcpTools.prove).not.toHaveBeenCalled();
|
||||||
|
|
@ -219,19 +222,19 @@ function createPorts(
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
inventory: {
|
inventory: {
|
||||||
probe: vi.fn(async () => inventory(overrides.inventory)),
|
probe: vi.fn(() => Promise.resolve(inventory(overrides.inventory))),
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
detect: vi.fn(async () => overrides.capabilities ?? capabilities()),
|
detect: vi.fn(() => Promise.resolve(overrides.capabilities ?? capabilities())),
|
||||||
},
|
},
|
||||||
mcpTools: {
|
mcpTools: {
|
||||||
prove: vi.fn(async () => overrides.toolProof ?? toolProof()),
|
prove: vi.fn(() => Promise.resolve(overrides.toolProof ?? toolProof())),
|
||||||
},
|
},
|
||||||
runtimeStores: {
|
runtimeStores: {
|
||||||
check: vi.fn(async () => overrides.runtimeStores ?? runtimeStores()),
|
check: vi.fn(() => Promise.resolve(overrides.runtimeStores ?? runtimeStores())),
|
||||||
},
|
},
|
||||||
modelExecution: {
|
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 () => {
|
it('keeps deep OpenCode runtime failures provider-scoped instead of model-scoped', async () => {
|
||||||
const runtimeFailure =
|
const runtimeFailure =
|
||||||
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?';
|
'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 () => ({
|
const prepare = vi.fn(async () => ({
|
||||||
ok: false as const,
|
ok: false as const,
|
||||||
providerId: 'opencode' as const,
|
providerId: 'opencode' as const,
|
||||||
|
|
@ -1356,16 +1358,16 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ready).toBe(false);
|
expect(result.ready).toBe(false);
|
||||||
expect(result.message).toBe(runtimeFailure);
|
expect(result.message).toBe(normalizedRuntimeFailure);
|
||||||
expect(result.details).toEqual([runtimeFailure]);
|
expect(result.details).toEqual([normalizedRuntimeFailure]);
|
||||||
expect(result.warnings).toEqual([runtimeFailure]);
|
expect(result.warnings).toEqual([normalizedRuntimeFailure]);
|
||||||
expect(result.issues).toEqual([
|
expect(result.issues).toEqual([
|
||||||
{
|
{
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
scope: 'provider',
|
scope: 'provider',
|
||||||
severity: 'blocking',
|
severity: 'blocking',
|
||||||
code: 'mcp_unavailable',
|
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 () => {
|
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 () => ({
|
const prepare = vi.fn(async () => ({
|
||||||
ok: false as const,
|
ok: false as const,
|
||||||
providerId: 'opencode' as const,
|
providerId: 'opencode' as const,
|
||||||
|
|
@ -1444,27 +1448,62 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ready).toBe(false);
|
expect(result.ready).toBe(false);
|
||||||
expect(result.message).toBe(
|
expect(result.message).toBe(normalizedRuntimeFailure);
|
||||||
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?'
|
expect(result.details).toEqual([normalizedRuntimeFailure]);
|
||||||
);
|
expect(result.warnings).toEqual([normalizedRuntimeFailure]);
|
||||||
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.issues).toEqual([
|
expect(result.issues).toEqual([
|
||||||
{
|
{
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
scope: 'provider',
|
scope: 'provider',
|
||||||
severity: 'blocking',
|
severity: 'blocking',
|
||||||
code: 'mcp_unavailable',
|
code: 'mcp_unavailable',
|
||||||
message:
|
message: normalizedRuntimeFailure,
|
||||||
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?',
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it('normalizes unexpected OpenCode model prepare exceptions into a blocking diagnostic', async () => {
|
||||||
const prepare = vi.fn(async (input: { model?: string }) => {
|
const prepare = vi.fn(async (input: { model?: string }) => {
|
||||||
if (input.model === 'opencode/nemotron-3-super-free') {
|
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 () => {
|
it('preserves dashboard runtime backend refresh errors for the manage dialog', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.cliInstallerState = 'idle';
|
storeState.cliInstallerState = 'idle';
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
getProviderCredentialSummary,
|
getProviderCredentialSummary,
|
||||||
getProviderCurrentRuntimeSummary,
|
getProviderCurrentRuntimeSummary,
|
||||||
isProviderInventoryOnlyFallback,
|
isProviderInventoryOnlyFallback,
|
||||||
|
isOpenCodeCatalogHydrating,
|
||||||
isConnectionManagedRuntimeProvider,
|
isConnectionManagedRuntimeProvider,
|
||||||
shouldShowProviderConnectAction,
|
shouldShowProviderConnectAction,
|
||||||
} from '@renderer/components/runtime/providerConnectionUi';
|
} from '@renderer/components/runtime/providerConnectionUi';
|
||||||
|
|
@ -226,6 +227,47 @@ describe('providerConnectionUi', () => {
|
||||||
expect(getProviderCredentialSummary(provider)).toBe('API key also configured in Manage');
|
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', () => {
|
it('does not describe Anthropic API key mode as subscription connected when the key is missing', () => {
|
||||||
const provider = createAnthropicProvider({
|
const provider = createAnthropicProvider({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
|
|
||||||
|
|
@ -369,7 +369,13 @@ describe('TeamModelSelector disabled Codex models', () => {
|
||||||
},
|
},
|
||||||
models: ['opencode/big-pickle'],
|
models: ['opencode/big-pickle'],
|
||||||
modelCatalog: null,
|
modelCatalog: null,
|
||||||
modelCatalogRefreshState: 'loading',
|
modelCatalogRefreshState: 'idle',
|
||||||
|
runtimeCapabilities: {
|
||||||
|
modelCatalog: {
|
||||||
|
dynamic: true,
|
||||||
|
source: 'app-server',
|
||||||
|
},
|
||||||
|
},
|
||||||
modelVerificationState: 'idle',
|
modelVerificationState: 'idle',
|
||||||
modelAvailability: [],
|
modelAvailability: [],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ describe('ProvisioningProviderStatusList', () => {
|
||||||
await Promise.resolve();
|
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('Selected model checks');
|
||||||
expect(host.textContent).not.toContain('model unavailable');
|
expect(host.textContent).not.toContain('model unavailable');
|
||||||
|
|
||||||
|
|
@ -119,12 +119,14 @@ describe('ProvisioningProviderStatusList', () => {
|
||||||
|
|
||||||
it('gives a concrete hint for missing OpenCode runtime binary failures', () => {
|
it('gives a concrete hint for missing OpenCode runtime binary failures', () => {
|
||||||
expect(
|
expect(
|
||||||
getProvisioningFailureHint('CLI environment is not available - launch is blocked', [
|
getProvisioningFailureHint('Runtime environment is not available - launch is blocked', [
|
||||||
{
|
{
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
backendSummary: null,
|
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(
|
).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', () => {
|
it('picks the first real failure detail instead of a verified line', () => {
|
||||||
expect(
|
expect(
|
||||||
getPrimaryProvisioningFailureDetail([
|
getPrimaryProvisioningFailureDetail([
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSel
|
||||||
|
|
||||||
import type { TeamProviderId, TeamProvisioningPrepareResult } from '@shared/types';
|
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>(): {
|
function createDeferred<T>(): {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
resolve: (value: T) => void;
|
resolve: (value: T) => void;
|
||||||
|
|
@ -340,9 +345,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
ready: false,
|
ready: false,
|
||||||
message: 'OpenCode: mcp_unavailable',
|
message: 'OpenCode: mcp_unavailable',
|
||||||
details: [
|
details: [OPENCODE_RAW_MCP_UNREACHABLE],
|
||||||
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?',
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -355,7 +358,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
|
|
||||||
expect(result.status).toBe('failed');
|
expect(result.status).toBe('failed');
|
||||||
expect(result.details).toEqual([
|
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',
|
'OpenCode: mcp_unavailable',
|
||||||
]);
|
]);
|
||||||
expect(result.modelResultsById).toEqual({});
|
expect(result.modelResultsById).toEqual({});
|
||||||
|
|
@ -406,14 +409,14 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe('failed');
|
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.modelResultsById).toEqual({});
|
||||||
expect(result.details.join('\n')).not.toContain('big-pickle - unavailable');
|
expect(result.details.join('\n')).not.toContain('big-pickle - unavailable');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deduplicates repeated OpenCode provider runtime failure details', async () => {
|
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<
|
const prepareProvisioning = vi.fn<
|
||||||
(
|
(
|
||||||
cwd?: string,
|
cwd?: string,
|
||||||
|
|
@ -426,9 +429,9 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
>(() =>
|
>(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
ready: false,
|
ready: false,
|
||||||
message: runtimeFailure,
|
message: OPENCODE_RAW_MCP_UNREACHABLE,
|
||||||
details: [runtimeFailure],
|
details: [OPENCODE_RAW_MCP_UNREACHABLE],
|
||||||
warnings: [runtimeFailure],
|
warnings: [OPENCODE_RAW_MCP_UNREACHABLE],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -440,8 +443,8 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe('failed');
|
expect(result.status).toBe('failed');
|
||||||
expect(result.details).toEqual([runtimeFailure]);
|
expect(result.details).toEqual([OPENCODE_NORMALIZED_MCP_UNREACHABLE]);
|
||||||
expect(result.warnings).toEqual([runtimeFailure]);
|
expect(result.warnings).toEqual([OPENCODE_NORMALIZED_MCP_UNREACHABLE]);
|
||||||
expect(result.modelResultsById).toEqual({});
|
expect(result.modelResultsById).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -543,8 +546,6 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps stale OpenCode model-scoped runtime failures provider-scoped', async () => {
|
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<
|
const prepareProvisioning = vi.fn<
|
||||||
(
|
(
|
||||||
cwd?: string,
|
cwd?: string,
|
||||||
|
|
@ -557,8 +558,10 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
>(() =>
|
>(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
ready: false,
|
ready: false,
|
||||||
message: `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. ${runtimeFailure}`],
|
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.status).toBe('failed');
|
||||||
expect(result.details).toEqual([runtimeFailure]);
|
expect(result.details).toEqual([OPENCODE_NORMALIZED_MCP_UNREACHABLE]);
|
||||||
expect(result.warnings).toEqual([]);
|
expect(result.warnings).toEqual([]);
|
||||||
expect(result.modelResultsById).toEqual({});
|
expect(result.modelResultsById).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
@ -663,9 +666,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ready: true,
|
ready: true,
|
||||||
message: 'CLI is ready to launch',
|
message: 'CLI is ready to launch',
|
||||||
details: [
|
details: ['Selected model opencode/big-pickle is compatible. Deep verification pending.'],
|
||||||
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -674,9 +675,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ready: false,
|
ready: false,
|
||||||
message: 'OpenCode: mcp_unavailable',
|
message: 'OpenCode: mcp_unavailable',
|
||||||
details: [
|
details: [OPENCODE_RAW_MCP_UNREACHABLE],
|
||||||
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?',
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -689,7 +688,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
|
|
||||||
expect(result.status).toBe('failed');
|
expect(result.status).toBe('failed');
|
||||||
expect(result.details).toEqual([
|
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',
|
'OpenCode: mcp_unavailable',
|
||||||
]);
|
]);
|
||||||
expect(result.modelResultsById).toEqual({});
|
expect(result.modelResultsById).toEqual({});
|
||||||
|
|
@ -713,9 +712,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ready: true,
|
ready: true,
|
||||||
message: 'CLI is ready to launch',
|
message: 'CLI is ready to launch',
|
||||||
details: [
|
details: ['Selected model opencode/big-pickle is compatible. Deep verification pending.'],
|
||||||
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -767,9 +764,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ready: true,
|
ready: true,
|
||||||
message: 'CLI is ready to launch',
|
message: 'CLI is ready to launch',
|
||||||
details: [
|
details: ['Selected model opencode/big-pickle is compatible. Deep verification pending.'],
|
||||||
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -830,9 +825,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ready: true,
|
ready: true,
|
||||||
message: 'CLI is ready to launch',
|
message: 'CLI is ready to launch',
|
||||||
details: [
|
details: ['Selected model opencode/big-pickle is compatible. Deep verification pending.'],
|
||||||
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -866,6 +859,52 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
|
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 () => {
|
it('keeps OpenCode deep selected-model failures scoped to the selected model', async () => {
|
||||||
const prepareProvisioning = vi.fn<
|
const prepareProvisioning = vi.fn<
|
||||||
(
|
(
|
||||||
|
|
@ -892,7 +931,8 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
expect(selectedModels).toEqual(['openrouter/example/not-available']);
|
expect(selectedModels).toEqual(['openrouter/example/not-available']);
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ready: false,
|
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'
|
'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