diff --git a/src/main/index.ts b/src/main/index.ts index 985f579e..746a21c2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(); diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 73f8d55c..448519cc 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -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 diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeSupportDiagnostics.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeSupportDiagnostics.ts index 2278b098..6c3301b3 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeSupportDiagnostics.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeSupportDiagnostics.ts @@ -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 | 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; diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index f75b7614..739f6296 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -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(' ') : '';