diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 21b215d4..15354eb0 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -847,6 +847,34 @@ const PREFLIGHT_BINARY_TIMEOUT_MS = 8000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; const OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY = 2; +const OPENCODE_PROVIDER_SCOPED_PREPARE_FAILURE_REASONS = new Set([ + 'not_installed', + 'not_authenticated', + 'unsupported_version', + 'capabilities_missing', + 'runtime_store_blocked', + 'mcp_unavailable', + 'adapter_disabled', +]); + +function pushUniqueLine(lines: string[], line: string): void { + const trimmed = line.trim(); + if (trimmed.length > 0 && !lines.includes(trimmed)) { + lines.push(trimmed); + } +} + +function looksLikeOpenCodeProviderPrepareDiagnostic(value: string): boolean { + const lower = value.trim().toLowerCase(); + return ( + lower.includes('opencode /experimental/tool') || + lower.includes('/experimental/tool') || + lower.includes('mcp_unavailable') || + lower.includes('runtime store') || + lower.includes('opencode cli') || + lower.includes('unable to connect') + ); +} function applyDistinctProvisioningMemberColors< T extends { name: string; color?: string; removedAt?: number }, @@ -17698,6 +17726,30 @@ export class TeamProvisioningService { const primaryReason = prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason; + if (this.isProviderScopedOpenCodePrepareFailure(prepare, primaryReason)) { + pushUniqueLine(details, primaryReason); + pushUniqueLine(blockingMessages, primaryReason); + if ( + !issues.some( + (issue) => + issue.providerId === 'opencode' && + issue.scope === 'provider' && + issue.severity === 'blocking' && + issue.code === prepare.reason && + issue.message === primaryReason + ) + ) { + issues.push({ + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: prepare.reason, + message: primaryReason, + }); + } + continue; + } + const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`; const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`; const issueSeverity = @@ -17736,6 +17788,19 @@ export class TeamProvisioningService { return { details, warnings, blockingMessages, issues }; } + private isProviderScopedOpenCodePrepareFailure( + prepare: Extract, + primaryReason: string + ): boolean { + if (OPENCODE_PROVIDER_SCOPED_PREPARE_FAILURE_REASONS.has(prepare.reason)) { + return true; + } + return ( + prepare.reason === 'unknown_error' && + [primaryReason, ...prepare.diagnostics].some(looksLikeOpenCodeProviderPrepareDiagnostic) + ); + } + private async prepareSelectedOpenCodeModelsCompatibilityBatch({ adapter, cwd, diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 66faa4be..38fbc3b5 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -9488,6 +9488,7 @@ describe('TeamProvisioningService', () => { providerId: 'opencode', memberName: String(input.memberName), sessionId: 'oc-session-bob', + runtimePromptMessageId: 'msg_prompt_manifest_fallback', diagnostics: [], })); const registry = new TeamRuntimeAdapterRegistry([ diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 96b8168d..c4fd1e29 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -1052,6 +1052,51 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(prepare).toHaveBeenCalledTimes(1); }); + it('keeps deep OpenCode runtime failures provider-scoped instead of model-scoped', async () => { + const runtimeFailure = + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?'; + const prepare = vi.fn(async () => ({ + ok: false as const, + providerId: 'opencode' as const, + reason: 'mcp_unavailable', + retryable: true, + diagnostics: [runtimeFailure], + 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: 'deep', + }); + + expect(result.ready).toBe(false); + expect(result.message).toBe(runtimeFailure); + expect(result.details).toEqual([runtimeFailure]); + expect(result.warnings).toEqual([runtimeFailure]); + expect(result.issues).toEqual([ + { + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: 'mcp_unavailable', + message: runtimeFailure, + }, + ]); + }); + it('keeps shared OpenCode auth compatibility failures provider-scoped', async () => { const prepare = vi.fn(async () => ({ ok: false as const, diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts index 0edc25e7..9b8443ce 100644 --- a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -572,6 +572,60 @@ describe('runProviderPrepareDiagnostics', () => { expect(prepareProvisioning).toHaveBeenCalledTimes(2); }); + it('uses structured provider-scoped issues from OpenCode deep verification', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[], + limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' + ) => Promise + >((_cwd, _providerId, _providerIds, selectedModels, _limitContext, modelVerificationMode) => { + if (modelVerificationMode === 'compatibility') { + expect(selectedModels).toEqual(['opencode/big-pickle']); + return Promise.resolve({ + ready: true, + message: 'CLI is ready to launch', + details: [ + 'Selected model opencode/big-pickle is compatible. Deep verification pending.', + ], + }); + } + + expect(modelVerificationMode).toBe('deep'); + expect(selectedModels).toEqual(['opencode/big-pickle']); + return Promise.resolve({ + ready: false, + message: 'Future OpenCode runtime health failed', + details: ['Future OpenCode runtime health failed'], + issues: [ + { + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: 'future_runtime_failure', + message: 'Future OpenCode runtime health failed', + }, + ], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'opencode', + selectedModelIds: ['opencode/big-pickle'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual(['Future OpenCode runtime health failed']); + expect(result.modelResultsById).toEqual({}); + expect(result.details.join('\n')).not.toContain('big-pickle - unavailable'); + expect(prepareProvisioning).toHaveBeenCalledTimes(2); + }); + it('keeps OpenCode deep selected-model failures scoped to the selected model', async () => { const prepareProvisioning = vi.fn< (