fix(runtime): improve opencode diagnostics

This commit is contained in:
777genius 2026-05-18 03:36:26 +03:00
parent d25c65381f
commit 88e01ae87d
15 changed files with 194 additions and 26 deletions

View file

@ -44,8 +44,11 @@ const bubblePath = computed(() => {
position: var(--robot-bubble-position, relative); position: var(--robot-bubble-position, relative);
z-index: var(--robot-bubble-z-index, auto); z-index: var(--robot-bubble-z-index, auto);
display: inline-grid; display: inline-grid;
width: max-content;
inline-size: max-content;
min-width: var(--robot-bubble-min-width, 86px); min-width: var(--robot-bubble-min-width, 86px);
max-width: var(--robot-bubble-max-width, 184px); max-width: var(--robot-bubble-max-width, 184px);
max-inline-size: var(--robot-bubble-max-width, 184px);
min-height: var(--robot-bubble-min-height, 42px); min-height: var(--robot-bubble-min-height, 42px);
box-sizing: border-box; box-sizing: border-box;
color: var(--robot-bubble-color, #07111d); color: var(--robot-bubble-color, #07111d);
@ -85,12 +88,15 @@ const bubblePath = computed(() => {
justify-self: stretch; justify-self: stretch;
box-sizing: border-box; box-sizing: border-box;
min-width: 0; min-width: 0;
width: 100%;
max-width: 100%;
padding: var(--robot-bubble-padding, 8px 16px 16px); padding: var(--robot-bubble-padding, 8px 16px 16px);
text-align: center; text-align: center;
white-space: normal; white-space: normal;
overflow-wrap: anywhere; word-break: normal;
overflow-wrap: break-word;
hyphens: auto; hyphens: auto;
text-wrap: balance; text-wrap: normal;
} }
.robot-speech-bubble--tail-right .robot-speech-bubble__text { .robot-speech-bubble--tail-right .robot-speech-bubble__text {

View file

@ -14,6 +14,7 @@ const EXACT_STRIP_ENV_KEYS = new Set([
'CLAUDE_CODE_GEMINI_BACKEND', 'CLAUDE_CODE_GEMINI_BACKEND',
'CLAUDE_CODE_CODEX_BACKEND', 'CLAUDE_CODE_CODEX_BACKEND',
'CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH', 'CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH',
'OPENCODE_BIN_PATH',
'CODEX_HOME', 'CODEX_HOME',
]); ]);

View file

@ -342,6 +342,25 @@ function describeMemberWorkSyncReviewPickupEscalationReason(reason: string): str
return 'The current review request is still waiting for explicit review pickup.'; return 'The current review request is still waiting for explicit review pickup.';
} }
async function resolveOpenCodeRuntimeBinaryForBridgeEnv(): Promise<string | null> {
const manifestBinaryPath = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
if (manifestBinaryPath) {
return manifestBinaryPath;
}
try {
const status = await openCodeRuntimeInstallerService?.getStatus();
return status?.installed === true && status.binaryPath ? status.binaryPath : null;
} catch (error) {
logger.warn(
`[OpenCode] Runtime installer status unavailable while resolving bridge binary: ${
error instanceof Error ? error.message : String(error)
}`
);
return null;
}
}
async function createOpenCodeRuntimeAdapterRegistry( async function createOpenCodeRuntimeAdapterRegistry(
reportProgress: (phase: string, message: string) => void = () => undefined reportProgress: (phase: string, message: string) => void = () => undefined
): Promise<TeamRuntimeAdapterRegistry> { ): Promise<TeamRuntimeAdapterRegistry> {
@ -416,7 +435,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
await ensureOpenCodeBridgeRuntimeBinaryEnv({ await ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv, targetEnv,
bridgeEnv, bridgeEnv,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath, resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv,
onWarning: (message) => logger.warn(message), onWarning: (message) => logger.warn(message),
}); });
}; };

View file

@ -1,6 +1,10 @@
import { getErrorMessage } from '@shared/utils/errorHandling'; import { getErrorMessage } from '@shared/utils/errorHandling';
import { applyOpenCodeRuntimeBinaryEnv } from './openCodeRuntimeBinaryEnv'; import {
applyOpenCodeRuntimeBinaryEnv,
OPENCODE_LEGACY_BINARY_PATH_ENV,
OPENCODE_RUNTIME_BINARY_PATH_ENV,
} from './openCodeRuntimeBinaryEnv';
export interface EnsureOpenCodeBridgeRuntimeBinaryEnvOptions { export interface EnsureOpenCodeBridgeRuntimeBinaryEnvOptions {
targetEnv: NodeJS.ProcessEnv; targetEnv: NodeJS.ProcessEnv;
@ -15,7 +19,10 @@ export async function ensureOpenCodeBridgeRuntimeBinaryEnv({
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath, resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
onWarning, onWarning,
}: EnsureOpenCodeBridgeRuntimeBinaryEnvOptions): Promise<void> { }: EnsureOpenCodeBridgeRuntimeBinaryEnvOptions): Promise<void> {
if (targetEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH?.trim()) { if (
targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim() ||
targetEnv[OPENCODE_LEGACY_BINARY_PATH_ENV]?.trim()
) {
applyOpenCodeRuntimeBinaryEnv(targetEnv, null); applyOpenCodeRuntimeBinaryEnv(targetEnv, null);
return; return;
} }
@ -25,10 +32,10 @@ export async function ensureOpenCodeBridgeRuntimeBinaryEnv({
applyOpenCodeRuntimeBinaryEnv(targetEnv, appManagedOpenCodeBinary); applyOpenCodeRuntimeBinaryEnv(targetEnv, appManagedOpenCodeBinary);
if ( if (
targetEnv !== bridgeEnv && targetEnv !== bridgeEnv &&
targetEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH && targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV] &&
!bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH !bridgeEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]
) { ) {
applyOpenCodeRuntimeBinaryEnv(bridgeEnv, targetEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH); applyOpenCodeRuntimeBinaryEnv(bridgeEnv, targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]);
} }
} catch (error) { } catch (error) {
onWarning?.( onWarning?.(

View file

@ -1,6 +1,7 @@
import path from 'node:path'; import path from 'node:path';
export const OPENCODE_RUNTIME_BINARY_PATH_ENV = 'CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH'; export const OPENCODE_RUNTIME_BINARY_PATH_ENV = 'CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH';
export const OPENCODE_LEGACY_BINARY_PATH_ENV = 'OPENCODE_BIN_PATH';
function normalizePathEntryForCompare(value: string): string { function normalizePathEntryForCompare(value: string): string {
const normalized = path.resolve(value.trim()); const normalized = path.resolve(value.trim());
@ -33,7 +34,9 @@ export function applyOpenCodeRuntimeBinaryEnv(
discoveredBinaryPath: string | null | undefined discoveredBinaryPath: string | null | undefined
): void { ): void {
const existingBinaryPath = env[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim(); const existingBinaryPath = env[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim();
const nextBinaryPath = existingBinaryPath || discoveredBinaryPath?.trim() || ''; const existingLegacyBinaryPath = env[OPENCODE_LEGACY_BINARY_PATH_ENV]?.trim();
const nextBinaryPath =
existingBinaryPath || existingLegacyBinaryPath || discoveredBinaryPath?.trim() || '';
if (!nextBinaryPath) { if (!nextBinaryPath) {
return; return;
} }
@ -41,6 +44,9 @@ export function applyOpenCodeRuntimeBinaryEnv(
if (!existingBinaryPath) { if (!existingBinaryPath) {
env[OPENCODE_RUNTIME_BINARY_PATH_ENV] = nextBinaryPath; env[OPENCODE_RUNTIME_BINARY_PATH_ENV] = nextBinaryPath;
} }
if (!existingLegacyBinaryPath) {
env[OPENCODE_LEGACY_BINARY_PATH_ENV] = nextBinaryPath;
}
if (!path.isAbsolute(nextBinaryPath)) { if (!path.isAbsolute(nextBinaryPath)) {
return; return;
@ -48,6 +54,8 @@ export function applyOpenCodeRuntimeBinaryEnv(
// Facts: // Facts:
// - The app-managed OpenCode status is resolved from the app runtime manifest. // - The app-managed OpenCode status is resolved from the app runtime manifest.
// - Released claude-multimodel builds have used both OPENCODE_BIN_PATH and
// CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH while the managed runtime path evolved.
// - Older claude-multimodel readiness inventory still resolves "opencode" through PATH. // - Older claude-multimodel readiness inventory still resolves "opencode" through PATH.
// - Exposing the selected binary directory keeps both checks on the same runtime. // - Exposing the selected binary directory keeps both checks on the same runtime.
prependPathEntry(env, path.dirname(nextBinaryPath)); prependPathEntry(env, path.dirname(nextBinaryPath));

View file

@ -588,15 +588,13 @@ function shouldShowOpenCodeInstallAction(
showSkeleton: boolean, showSkeleton: boolean,
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null openCodeRuntimeStatus: OpenCodeRuntimeStatus | null
): boolean { ): boolean {
return ( const runtimeReady =
provider.providerId === 'opencode' && openCodeRuntimeStatus?.installed === true &&
!showSkeleton && (openCodeRuntimeStatus.source === 'path' ||
!provider.supported && (openCodeRuntimeStatus.source === 'app-managed' && openCodeRuntimeStatus.state !== 'failed'));
!provider.authenticated && const runtimeNeedsInstall = !runtimeReady;
provider.backend == null &&
openCodeRuntimeStatus?.source !== 'path' && return provider.providerId === 'opencode' && !showSkeleton && runtimeNeedsInstall;
!(openCodeRuntimeStatus?.source === 'app-managed' && openCodeRuntimeStatus.state !== 'failed')
);
} }
function shouldShowCodexInstallAction( function shouldShowCodexInstallAction(
@ -1054,7 +1052,7 @@ const InstalledBanner = ({
title={ title={
openCodeRuntimeStatus?.error ?? openCodeRuntimeStatus?.error ??
openCodeRuntimeStatus?.progress?.detail ?? openCodeRuntimeStatus?.progress?.detail ??
'Install OpenCode CLI into app data' 'Install OpenCode runtime into app data'
} }
> >
{isRuntimeInstalling( {isRuntimeInstalling(

View file

@ -690,6 +690,13 @@ export function getProvisioningFailureHint(
if (combined.includes('provider is not configured for runtime use')) { if (combined.includes('provider is not configured for runtime use')) {
return 'Configure the selected provider runtime, then reopen this dialog.'; return 'Configure the selected provider runtime, then reopen this dialog.';
} }
if (
combined.includes('opencode cli not detected on path') ||
combined.includes('opencode cli not found') ||
combined.includes('opencode runtime binary is not installed')
) {
return 'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.';
}
if ( if (
combined.includes('spawn ') || combined.includes('spawn ') ||
combined.includes(' enoent') || combined.includes(' enoent') ||

View file

@ -640,7 +640,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
return ( return (
providerStatus.detailMessage ?? providerStatus.detailMessage ??
providerStatus.statusMessage ?? providerStatus.statusMessage ??
'OpenCode CLI is not installed.' 'OpenCode runtime is not installed.'
); );
} }
if (!providerStatus.authenticated) { if (!providerStatus.authenticated) {

View file

@ -440,11 +440,26 @@ function createRuntimeWarningLines(result: TeamProvisioningPrepareResult): strin
return uniquePrepareLines(result.warnings ?? []); return uniquePrepareLines(result.warnings ?? []);
} }
function normalizeRuntimeFailureDetailLine(detail: string | null | undefined): string | null {
const trimmed = detail?.trim();
if (!trimmed) {
return null;
}
if (/opencode cli (?:not detected on path|not found)/i.test(trimmed)) {
return 'OpenCode runtime binary is not installed or not reachable by launch preflight.';
}
return trimmed;
}
function createRuntimeFailureDetailLines( function createRuntimeFailureDetailLines(
runtimeDetailLines: readonly string[], runtimeDetailLines: readonly string[],
message: string | null | undefined message: string | null | undefined
): string[] { ): string[] {
return uniquePrepareLines([...runtimeDetailLines, message]); return uniquePrepareLines(
[...runtimeDetailLines, message].map(normalizeRuntimeFailureDetailLine).filter(Boolean)
);
} }
function extractTimedOutPreflightProbeModelId(detail: string): string | null { function extractTimedOutPreflightProbeModelId(detail: string): string | null {

View file

@ -15,7 +15,8 @@ describe('workspaceTrustPreflightEnv', () => {
CLAUDE_TEAM_CONTROL_URL: 'http://127.0.0.1:1234', CLAUDE_TEAM_CONTROL_URL: 'http://127.0.0.1:1234',
CLAUDE_TEAM_ANTHROPIC_AUTH_MODE: 'api_key_helper', CLAUDE_TEAM_ANTHROPIC_AUTH_MODE: 'api_key_helper',
CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER: '1', CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER: '1',
CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH: '/tmp/helper-settings.json', CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH:
'/Users/tester/helper-settings.json',
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1', CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1',
CLAUDE_CODE_ENTRY_PROVIDER: 'codex', CLAUDE_CODE_ENTRY_PROVIDER: 'codex',
CLAUDE_CODE_USE_OPENAI: '1', CLAUDE_CODE_USE_OPENAI: '1',
@ -25,10 +26,11 @@ describe('workspaceTrustPreflightEnv', () => {
CLAUDE_CODE_USE_GEMINI: '1', CLAUDE_CODE_USE_GEMINI: '1',
CLAUDE_CODE_CODEX_BACKEND: 'codex-native', CLAUDE_CODE_CODEX_BACKEND: 'codex-native',
CLAUDE_CODE_GEMINI_BACKEND: 'api', CLAUDE_CODE_GEMINI_BACKEND: 'api',
CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: '/tmp/opencode', CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: '/Users/tester/bin/opencode',
CODEX_HOME: '/tmp/codex-home', OPENCODE_BIN_PATH: '/Users/tester/bin/opencode',
AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT: '/tmp/spool', CODEX_HOME: '/Users/tester/codex-home',
AGENT_TEAMS_MCP_CLAUDE_DIR: '/tmp/claude-dir', AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT: '/Users/tester/spool',
AGENT_TEAMS_MCP_CLAUDE_DIR: '/Users/tester/claude-dir',
CLAUDE_TEAM_BOOTSTRAP_TOKEN: 'bootstrap-token', CLAUDE_TEAM_BOOTSTRAP_TOKEN: 'bootstrap-token',
}); });
@ -55,6 +57,7 @@ describe('workspaceTrustPreflightEnv', () => {
expect(env.CLAUDE_CODE_CODEX_BACKEND).toBeUndefined(); expect(env.CLAUDE_CODE_CODEX_BACKEND).toBeUndefined();
expect(env.CLAUDE_CODE_GEMINI_BACKEND).toBeUndefined(); expect(env.CLAUDE_CODE_GEMINI_BACKEND).toBeUndefined();
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined(); expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined();
expect(env.OPENCODE_BIN_PATH).toBeUndefined();
expect(env.CODEX_HOME).toBeUndefined(); expect(env.CODEX_HOME).toBeUndefined();
expect(env.AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT).toBeUndefined(); expect(env.AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT).toBeUndefined();
expect(env.AGENT_TEAMS_MCP_CLAUDE_DIR).toBeUndefined(); expect(env.AGENT_TEAMS_MCP_CLAUDE_DIR).toBeUndefined();

View file

@ -17,6 +17,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
}); });
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
expect(env.OPENCODE_BIN_PATH).toBe(binaryPath);
expect(env.PATH?.split(path.delimiter)).toEqual([ expect(env.PATH?.split(path.delimiter)).toEqual([
path.dirname(binaryPath), path.dirname(binaryPath),
'/usr/bin', '/usr/bin',
@ -48,11 +49,32 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
}); });
expect(commandEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); expect(commandEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
expect(commandEnv.OPENCODE_BIN_PATH).toBe(binaryPath);
expect(commandEnv.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath)); expect(commandEnv.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath));
expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
expect(bridgeEnv.OPENCODE_BIN_PATH).toBe(binaryPath);
expect(bridgeEnv.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath)); expect(bridgeEnv.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath));
}); });
it('honors a legacy OpenCode binary override already present in the command env', async () => {
const binaryPath = path.join(process.cwd(), 'legacy opencode', 'opencode');
const env: NodeJS.ProcessEnv = {
OPENCODE_BIN_PATH: binaryPath,
PATH: '/usr/bin',
};
const resolver = vi.fn<() => Promise<string | null>>();
await ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv: env,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: 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('keeps bridge startup non-fatal when the managed resolver fails', async () => { it('keeps bridge startup non-fatal when the managed resolver fails', async () => {
const onWarning = vi.fn(); const onWarning = vi.fn();
const env: NodeJS.ProcessEnv = { const env: NodeJS.ProcessEnv = {
@ -72,6 +94,7 @@ describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
'[OpenCode] Runtime adapter bundled OpenCode binary unresolved: manifest unreadable' '[OpenCode] Runtime adapter bundled 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.PATH).toBe('/usr/bin'); expect(env.PATH).toBe('/usr/bin');
}); });
}); });

View file

@ -14,6 +14,7 @@ describe('applyOpenCodeRuntimeBinaryEnv', () => {
applyOpenCodeRuntimeBinaryEnv(env, binaryPath); applyOpenCodeRuntimeBinaryEnv(env, binaryPath);
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
expect(env.OPENCODE_BIN_PATH).toBe(binaryPath);
expect(env.PATH?.split(path.delimiter)).toEqual([ expect(env.PATH?.split(path.delimiter)).toEqual([
path.dirname(binaryPath), path.dirname(binaryPath),
'/usr/bin', '/usr/bin',
@ -32,6 +33,21 @@ describe('applyOpenCodeRuntimeBinaryEnv', () => {
applyOpenCodeRuntimeBinaryEnv(env, discoveredBinaryPath); applyOpenCodeRuntimeBinaryEnv(env, discoveredBinaryPath);
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(explicitBinaryPath); expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(explicitBinaryPath);
expect(env.OPENCODE_BIN_PATH).toBe(explicitBinaryPath);
expect(env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(explicitBinaryPath));
});
it('mirrors a legacy OpenCode binary override into the managed env var', () => {
const explicitBinaryPath = path.join(process.cwd(), 'legacy opencode', 'opencode');
const env: NodeJS.ProcessEnv = {
OPENCODE_BIN_PATH: explicitBinaryPath,
PATH: '/usr/bin',
};
applyOpenCodeRuntimeBinaryEnv(env, null);
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(explicitBinaryPath);
expect(env.OPENCODE_BIN_PATH).toBe(explicitBinaryPath);
expect(env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(explicitBinaryPath)); expect(env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(explicitBinaryPath));
}); });

View file

@ -371,11 +371,13 @@ describe('buildProviderAwareCliEnv', () => {
expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith( expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: appManagedBinaryPath, CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: appManagedBinaryPath,
OPENCODE_BIN_PATH: appManagedBinaryPath,
}), }),
'opencode', 'opencode',
undefined undefined
); );
expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(appManagedBinaryPath); expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(appManagedBinaryPath);
expect(result.env.OPENCODE_BIN_PATH).toBe(appManagedBinaryPath);
expect(result.env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(appManagedBinaryPath)); expect(result.env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(appManagedBinaryPath));
}); });
@ -392,6 +394,7 @@ describe('buildProviderAwareCliEnv', () => {
}); });
expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(explicitBinaryPath); expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(explicitBinaryPath);
expect(result.env.OPENCODE_BIN_PATH).toBe(explicitBinaryPath);
expect(result.env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(explicitBinaryPath)); expect(result.env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(explicitBinaryPath));
}); });
@ -407,6 +410,7 @@ describe('buildProviderAwareCliEnv', () => {
}); });
expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined(); expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined();
expect(result.env.OPENCODE_BIN_PATH).toBeUndefined();
}); });
it('injects the verified app-managed Codex binary for Codex launches', async () => { it('injects the verified app-managed Codex binary for Codex launches', async () => {

View file

@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
deriveEffectiveProvisioningPrepareState, deriveEffectiveProvisioningPrepareState,
getPrimaryProvisioningFailureDetail, getPrimaryProvisioningFailureDetail,
getProvisioningFailureHint,
getProvisioningProviderBackendSummary, getProvisioningProviderBackendSummary,
ProvisioningProviderStatusList, ProvisioningProviderStatusList,
createInitialProviderChecks, createInitialProviderChecks,
@ -116,6 +117,21 @@ describe('ProvisioningProviderStatusList', () => {
}); });
}); });
it('gives a concrete hint for missing OpenCode runtime binary failures', () => {
expect(
getProvisioningFailureHint('CLI environment is not available - launch is blocked', [
{
providerId: 'opencode',
status: 'failed',
backendSummary: null,
details: ['OpenCode runtime binary is not installed or not reachable by launch preflight.'],
},
])
).toBe(
'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.'
);
});
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([

View file

@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest';
import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
import type { TeamProviderId, TeamProvisioningPrepareResult } from '@shared/types';
type PrepareProvisioningFn = (
cwd?: string,
providerId?: TeamProviderId,
providerIds?: TeamProviderId[],
selectedModels?: string[],
limitContext?: boolean,
modelVerificationMode?: 'compatibility' | 'deep'
) => Promise<TeamProvisioningPrepareResult>;
describe('runProviderPrepareDiagnostics OpenCode runtime failures', () => {
it('normalizes missing OpenCode binary diagnostics for packaged launch preflight', async () => {
const prepareProvisioning = vi.fn<PrepareProvisioningFn>().mockResolvedValue({
ready: false,
message: 'OpenCode CLI not detected on PATH',
details: ['OpenCode CLI not found'],
});
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({});
expect(prepareProvisioning).toHaveBeenCalledWith(
'/Users/tester/project',
'opencode',
['opencode'],
['opencode/big-pickle'],
undefined,
'compatibility'
);
});
});