fix(opencode): harden local runtime bridge support
This commit is contained in:
parent
26a57f87d4
commit
2cee9cabaf
4 changed files with 188 additions and 16 deletions
|
|
@ -88,6 +88,7 @@ import {
|
|||
} from '@main/services/team/TeamMcpConfigBuilder';
|
||||
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
|
||||
import { killTrackedCliProcesses } from '@main/utils/childProcess';
|
||||
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
||||
import { getWindowsElevationStatus } from '@main/utils/windowsElevation';
|
||||
import {
|
||||
APP_GET_WINDOWS_ELEVATION_STATUS,
|
||||
|
|
@ -396,7 +397,10 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
}
|
||||
|
||||
reportProgress('runtime-environment', 'Preparing runtime environment...');
|
||||
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
||||
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({
|
||||
...process.env,
|
||||
PATH: buildMergedCliPath(binaryPath),
|
||||
});
|
||||
applyAgentTeamsIdentityEnv(bridgeEnv);
|
||||
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
|
||||
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
||||
|
|
|
|||
|
|
@ -727,13 +727,42 @@ function mergeRuntimeCapabilitiesForCatalogHydration(
|
|||
};
|
||||
}
|
||||
|
||||
function shouldPromoteHydratedAuthState(
|
||||
liveProvider: CliProviderStatus,
|
||||
hydratedProvider: CliProviderStatus
|
||||
): boolean {
|
||||
return (
|
||||
liveProvider.providerId === 'opencode' &&
|
||||
liveProvider.authenticated !== true &&
|
||||
hydratedProvider.authenticated === true
|
||||
);
|
||||
}
|
||||
|
||||
function mergeProviderCatalogFields(
|
||||
liveProvider: CliProviderStatus,
|
||||
hydratedProvider: CliProviderStatus
|
||||
): CliProviderStatus {
|
||||
const modelCatalog = hydratedProvider.modelCatalog ?? liveProvider.modelCatalog ?? null;
|
||||
const promoteHydratedAuthState = shouldPromoteHydratedAuthState(liveProvider, hydratedProvider);
|
||||
return {
|
||||
...liveProvider,
|
||||
authenticated: promoteHydratedAuthState
|
||||
? hydratedProvider.authenticated
|
||||
: liveProvider.authenticated,
|
||||
authMethod: promoteHydratedAuthState ? hydratedProvider.authMethod : liveProvider.authMethod,
|
||||
verificationState: promoteHydratedAuthState
|
||||
? hydratedProvider.verificationState
|
||||
: liveProvider.verificationState,
|
||||
capabilities: promoteHydratedAuthState
|
||||
? hydratedProvider.capabilities
|
||||
: liveProvider.capabilities,
|
||||
statusMessage: promoteHydratedAuthState
|
||||
? hydratedProvider.statusMessage
|
||||
: liveProvider.statusMessage,
|
||||
detailMessage: promoteHydratedAuthState
|
||||
? hydratedProvider.detailMessage
|
||||
: liveProvider.detailMessage,
|
||||
backend: promoteHydratedAuthState ? hydratedProvider.backend : liveProvider.backend,
|
||||
models: hydratedProvider.models.length > 0 ? hydratedProvider.models : liveProvider.models,
|
||||
modelCatalog,
|
||||
modelCatalogRefreshState: modelCatalog
|
||||
|
|
|
|||
|
|
@ -78,8 +78,9 @@ function buildOpenCodeBridgeSupportCopyText(input: {
|
|||
const command = formatDiagnosticValue(input.details.command, input.result.command);
|
||||
const requestId = formatDiagnosticValue(input.details.requestId, input.result.requestId);
|
||||
const stderrPreview = formatPreview(input.details.stderrPreview);
|
||||
const likelyCause = formatLikelyCause(input.details);
|
||||
|
||||
return [
|
||||
const lines = [
|
||||
'Agent Teams OpenCode diagnostics',
|
||||
`Time: ${input.createdAt}`,
|
||||
'Provider: opencode',
|
||||
|
|
@ -93,6 +94,8 @@ function buildOpenCodeBridgeSupportCopyText(input: {
|
|||
'Bridge command:',
|
||||
`command: ${command}`,
|
||||
`requestId: ${requestId}`,
|
||||
`binaryPath: ${formatDiagnosticPathValue(input.details.binaryPath)}`,
|
||||
`cwd: ${formatDiagnosticPathValue(input.details.cwd)}`,
|
||||
`attempts: ${formatDiagnosticValue(input.details.attempts)}`,
|
||||
`exitCode: ${formatDiagnosticValue(input.details.exitCode)}`,
|
||||
`timedOut: ${formatDiagnosticValue(input.details.timedOut)}`,
|
||||
|
|
@ -108,10 +111,22 @@ function buildOpenCodeBridgeSupportCopyText(input: {
|
|||
`appVersion: ${formatDiagnosticValue(input.appVersion)}`,
|
||||
`projectPath: ${redactDiagnosticPath(input.projectPath)}`,
|
||||
`selectedModel: ${formatDiagnosticValue(input.selectedModel)}`,
|
||||
'',
|
||||
'stderrPreview:',
|
||||
stderrPreview,
|
||||
].join('\n');
|
||||
];
|
||||
|
||||
if (likelyCause) {
|
||||
lines.push('', 'Likely cause:', likelyCause);
|
||||
}
|
||||
|
||||
lines.push('', 'stderrPreview:', stderrPreview);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatLikelyCause(details: Record<string, unknown>): string | null {
|
||||
if (details.exitCode !== 9009) {
|
||||
return null;
|
||||
}
|
||||
return 'Windows could not start the bridge launcher. Check that the runtime launcher and its dependencies, such as Bun for cli-dev.cmd, are available in PATH.';
|
||||
}
|
||||
|
||||
function formatDiagnosticValue(value: unknown, fallback: unknown = undefined): string {
|
||||
|
|
@ -128,6 +143,13 @@ function formatDiagnosticValue(value: unknown, fallback: unknown = undefined): s
|
|||
return redactBridgeDiagnosticText(JSON.stringify(resolved));
|
||||
}
|
||||
|
||||
function formatDiagnosticPathValue(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
return formatDiagnosticValue(value);
|
||||
}
|
||||
return redactDiagnosticPath(value);
|
||||
}
|
||||
|
||||
function formatPreview(value: unknown): string {
|
||||
const formatted = formatDiagnosticValue(value);
|
||||
return formatted === '(none)' ? '(empty)' : formatted;
|
||||
|
|
|
|||
|
|
@ -408,9 +408,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
expect(provider.detailMessage).toContain(
|
||||
'OpenCode runtime status did not return before the desktop timeout.'
|
||||
);
|
||||
expect(provider.detailMessage).toContain(
|
||||
'not necessarily that OpenCode auth is missing'
|
||||
);
|
||||
expect(provider.detailMessage).toContain('not necessarily that OpenCode auth is missing');
|
||||
expect(provider.detailMessage).toContain('provider/model inventory');
|
||||
expect(provider.detailMessage).toContain('Raw timeout detail: Command timed out after 30000ms');
|
||||
expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).toEqual([
|
||||
|
|
@ -508,11 +506,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
const calls = execCliMock.mock.calls.map((call) => call[1].join(' '));
|
||||
|
||||
expect(execCliMock).toHaveBeenCalledTimes(3);
|
||||
expect(execCliMock.mock.calls.map((call) => call[2]?.timeout)).toEqual([
|
||||
30000,
|
||||
30000,
|
||||
30000,
|
||||
]);
|
||||
expect(execCliMock.mock.calls.map((call) => call[2]?.timeout)).toEqual([30000, 30000, 30000]);
|
||||
expect(calls).toEqual([
|
||||
'runtime status --json --provider anthropic --summary',
|
||||
'runtime status --json --provider codex --summary',
|
||||
|
|
@ -524,8 +518,9 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
'opencode',
|
||||
]);
|
||||
expect(providers.every((provider) => provider.verificationState === 'error')).toBe(true);
|
||||
expect(providers.every((provider) => provider.statusMessage === 'Provider status unavailable'))
|
||||
.toBe(true);
|
||||
expect(
|
||||
providers.every((provider) => provider.statusMessage === 'Provider status unavailable')
|
||||
).toBe(true);
|
||||
expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([
|
||||
expect.stringContaining(
|
||||
'Provider-scoped runtime status timed out for anthropic, codex, opencode'
|
||||
|
|
@ -795,6 +790,128 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
expect(hydratedCodex?.modelCatalog?.models.map((model) => model.id)).toEqual(['gpt-5.4']);
|
||||
});
|
||||
|
||||
it('promotes OpenCode auth when full catalog hydration proves built-in free access', async () => {
|
||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
|
||||
if (normalizedArgs === 'runtime status --json --provider opencode --summary') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
opencode: {
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: false,
|
||||
statusMessage: 'No OpenCode providers connected',
|
||||
models: [],
|
||||
capabilities: { teamLaunch: false, oneShot: false },
|
||||
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedArgs === 'runtime status --json --provider opencode') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
opencode: {
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_builtin_free',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: false,
|
||||
statusMessage: null,
|
||||
detailMessage: '3 built-in free models',
|
||||
models: ['opencode/big-pickle'],
|
||||
capabilities: { teamLaunch: true, oneShot: false },
|
||||
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
|
||||
backend: {
|
||||
kind: 'opencode-cli',
|
||||
label: 'OpenCode CLI',
|
||||
authMethodDetail: 'built-in free models',
|
||||
},
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-25T00:00:00.000Z',
|
||||
staleAt: '2026-05-25T00:10:00.000Z',
|
||||
defaultModelId: 'opencode/big-pickle',
|
||||
defaultLaunchModel: 'opencode/big-pickle',
|
||||
models: [
|
||||
{
|
||||
id: 'opencode/big-pickle',
|
||||
launchModel: 'opencode/big-pickle',
|
||||
displayName: 'big-pickle',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||
});
|
||||
|
||||
const { ClaudeMultimodelBridgeService } =
|
||||
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
const onCatalogUpdate = vi.fn();
|
||||
|
||||
const provider = await service.getProviderStatus(
|
||||
'/mock/agent_teams_orchestrator',
|
||||
'opencode',
|
||||
onCatalogUpdate
|
||||
);
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
authenticated: false,
|
||||
statusMessage: 'No OpenCode providers connected',
|
||||
modelCatalogRefreshState: 'loading',
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(onCatalogUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onCatalogUpdate.mock.calls[0]?.[0]).toMatchObject({
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_builtin_free',
|
||||
statusMessage: null,
|
||||
capabilities: { teamLaunch: true },
|
||||
modelCatalogRefreshState: 'ready',
|
||||
backend: { authMethodDetail: 'built-in free models' },
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates a single provider catalog after summary refresh', async () => {
|
||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
|
|
|
|||
Loading…
Reference in a new issue