import { buildProviderPrepareMembersSignature, buildProviderPrepareModelChecksSignature, buildProviderPrepareRequestSignature, buildProviderPrepareRuntimeStatusSignature, } from '@renderer/components/team/dialogs/providerPrepareRequestSignature'; import { describe, expect, it } from 'vitest'; import type { CliProviderStatus, TeamProviderId } from '@shared/types'; type RuntimeSignatureProvider = { providerId: TeamProviderId; [key: string]: unknown; }; function providerStatusMap( entries: readonly (readonly [TeamProviderId, RuntimeSignatureProvider])[] ): ReadonlyMap { return new Map(entries) as unknown as ReadonlyMap< TeamProviderId, CliProviderStatus | null | undefined >; } describe('providerPrepareRequestSignature', () => { it('stays stable for semantically identical provider runtime snapshots', () => { const providerIds = ['codex'] as const; const first = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'codex', { providerId: 'codex', supported: true, authenticated: true, authMethod: 'chatgpt', verificationState: 'verified', modelVerificationState: 'verified', statusMessage: null, detailMessage: null, selectedBackendId: 'codex-native', resolvedBackendId: 'codex-native', models: ['gpt-5.4', 'gpt-5.4-mini'], modelCatalog: { source: 'app-server', status: 'ready', models: [{ id: 'gpt-5.4-mini' }, { id: 'gpt-5.4' }], }, availableBackends: [ { id: 'codex-native', available: true, selectable: true, state: 'ready', recommended: true, audience: 'general', }, ], capabilities: { teamLaunch: true, oneShot: true, extensions: { displayAvailable: true, installAvailable: true, }, }, canLoginFromUi: true, }, ], ]) ); const second = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'codex', { providerId: 'codex', supported: true, authenticated: true, authMethod: 'chatgpt', verificationState: 'verified', modelVerificationState: 'verified', statusMessage: null, detailMessage: null, selectedBackendId: 'codex-native', resolvedBackendId: 'codex-native', models: ['gpt-5.4-mini', 'gpt-5.4'], modelCatalog: { source: 'app-server', status: 'ready', models: [{ id: 'gpt-5.4' }, { id: 'gpt-5.4-mini' }], }, availableBackends: [ { id: 'codex-native', available: true, selectable: true, state: 'ready', recommended: true, audience: 'general', }, ], capabilities: { teamLaunch: true, oneShot: true, extensions: { displayAvailable: true, installAvailable: true, }, }, canLoginFromUi: true, }, ], ]) ); expect(first).toBe(second); }); it('changes when a provider auth/runtime field that affects preflight changes', () => { const providerIds = ['codex'] as const; const authenticated = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'codex', { providerId: 'codex', supported: true, authenticated: true, authMethod: 'chatgpt', verificationState: 'verified', models: ['gpt-5.4'], capabilities: { teamLaunch: true, oneShot: true, extensions: { displayAvailable: true, installAvailable: true, }, }, canLoginFromUi: true, }, ], ]) ); const unauthenticated = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'codex', { providerId: 'codex', supported: true, authenticated: false, authMethod: null, verificationState: 'error', detailMessage: 'Reconnect required', models: ['gpt-5.4'], capabilities: { teamLaunch: true, oneShot: true, extensions: { displayAvailable: true, installAvailable: true, }, }, canLoginFromUi: true, }, ], ]) ); expect(authenticated).not.toBe(unauthenticated); }); it('changes when provider connection auth truth changes even if model lists stay the same', () => { const providerIds = ['codex'] as const; const first = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'codex', { providerId: 'codex', supported: true, authenticated: true, authMethod: 'chatgpt', verificationState: 'verified', models: ['gpt-5.4'], connection: { supportsOAuth: false, supportsApiKey: true, configurableAuthModes: ['auto', 'chatgpt', 'api_key'], configuredAuthMode: 'chatgpt', apiKeyConfigured: true, apiKeySource: 'environment', codex: { preferredAuthMode: 'chatgpt', effectiveAuthMode: 'chatgpt', appServerState: 'healthy', appServerStatusMessage: null, managedAccount: { type: 'chatgpt', email: 'user@example.com', }, requiresOpenaiAuth: false, localAccountArtifactsPresent: true, localActiveChatgptAccountPresent: true, login: { status: 'idle', error: null, }, rateLimits: null, launchAllowed: true, launchIssueMessage: null, launchReadinessState: 'ready_chatgpt', }, }, capabilities: { teamLaunch: true, oneShot: true, extensions: { displayAvailable: true, installAvailable: true, }, }, canLoginFromUi: true, }, ], ]) ); const second = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'codex', { providerId: 'codex', supported: true, authenticated: true, authMethod: 'chatgpt', verificationState: 'verified', models: ['gpt-5.4'], connection: { supportsOAuth: false, supportsApiKey: true, configurableAuthModes: ['auto', 'chatgpt', 'api_key'], configuredAuthMode: 'api_key', apiKeyConfigured: true, apiKeySource: 'environment', codex: { preferredAuthMode: 'auto', effectiveAuthMode: 'api_key', appServerState: 'healthy', appServerStatusMessage: null, managedAccount: { type: 'chatgpt', email: 'user@example.com', }, requiresOpenaiAuth: false, localAccountArtifactsPresent: true, localActiveChatgptAccountPresent: true, login: { status: 'idle', error: null, }, rateLimits: null, launchAllowed: true, launchIssueMessage: null, launchReadinessState: 'ready_api_key', }, }, capabilities: { teamLaunch: true, oneShot: true, extensions: { displayAvailable: true, installAvailable: true, }, }, canLoginFromUi: true, }, ], ]) ); expect(first).not.toBe(second); }); it('ignores volatile provider status copy that should not retrigger preflight', () => { const providerIds = ['opencode'] as const; const first = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'opencode', { providerId: 'opencode', supported: true, authenticated: true, authMethod: 'oauth', verificationState: 'verified', modelVerificationState: 'verified', statusMessage: 'Syncing provider details...', detailMessage: 'Polling host readiness', models: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'], modelCatalog: { source: 'live', status: 'ready', models: [ { id: 'opencode/minimax-m2.5-free' }, { id: 'opencode/nemotron-3-super-free' }, ], }, modelAvailability: [ { modelId: 'opencode/minimax-m2.5-free', status: 'available', reason: 'Warm host pending', }, ], }, ], ]) ); const second = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'opencode', { providerId: 'opencode', supported: true, authenticated: true, authMethod: 'oauth', verificationState: 'verified', modelVerificationState: 'verified', statusMessage: 'Healthy', detailMessage: 'MCP ready', models: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'], modelCatalog: { source: 'live', status: 'ready', models: [ { id: 'opencode/minimax-m2.5-free' }, { id: 'opencode/nemotron-3-super-free' }, ], }, modelAvailability: [ { modelId: 'opencode/minimax-m2.5-free', status: 'available', reason: 'Deep probe still running', }, ], }, ], ]) ); expect(first).toBe(second); }); it('ignores OpenCode catalog expansion that can happen while preflight is already running', () => { const providerIds = ['opencode'] as const; const first = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'opencode', { providerId: 'opencode', supported: true, authenticated: true, authMethod: 'oauth', selectedBackendId: 'opencode-cli', resolvedBackendId: 'opencode-cli', models: ['opencode/minimax-m2.5-free'], modelCatalog: { source: 'live', status: 'checking', models: [{ id: 'opencode/minimax-m2.5-free' }], }, }, ], ]) ); const second = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'opencode', { providerId: 'opencode', supported: true, authenticated: true, authMethod: 'oauth', selectedBackendId: 'opencode-cli', resolvedBackendId: 'opencode-cli', models: [ 'opencode/minimax-m2.5-free', 'opencode/qwen3.6-plus-free', 'openrouter/google/gemma-4-26b-a4b-it', ], modelCatalog: { source: 'live', status: 'ready', models: [ { id: 'opencode/minimax-m2.5-free' }, { id: 'opencode/qwen3.6-plus-free' }, { id: 'openrouter/google/gemma-4-26b-a4b-it' }, ], }, }, ], ]) ); expect(first).toBe(second); }); it('still changes the full request signature when selected OpenCode model checks change', () => { const runtimeStatusSignature = buildProviderPrepareRuntimeStatusSignature( ['opencode'], providerStatusMap([ [ 'opencode', { providerId: 'opencode', supported: true, authenticated: true, authMethod: 'oauth', selectedBackendId: 'opencode-cli', resolvedBackendId: 'opencode-cli', models: ['opencode/minimax-m2.5-free', 'opencode/qwen3.6-plus-free'], modelCatalog: { source: 'live', status: 'ready', models: [{ id: 'opencode/minimax-m2.5-free' }, { id: 'opencode/qwen3.6-plus-free' }], }, }, ], ]) ); const first = buildProviderPrepareRequestSignature({ cwd: '/tmp/project', selectedProviderId: 'opencode', selectedModel: 'opencode/minimax-m2.5-free', selectedMemberProviders: ['opencode'], runtimeStatusSignature, modelChecksSignature: buildProviderPrepareModelChecksSignature( new Map([['opencode', ['opencode/minimax-m2.5-free']]]) ), }); const second = buildProviderPrepareRequestSignature({ cwd: '/tmp/project', selectedProviderId: 'opencode', selectedModel: 'opencode/qwen3.6-plus-free', selectedMemberProviders: ['opencode'], runtimeStatusSignature, modelChecksSignature: buildProviderPrepareModelChecksSignature( new Map([['opencode', ['opencode/qwen3.6-plus-free']]]) ), }); expect(first).not.toBe(second); }); it('ignores live verification fields that can drift while preflight is already running', () => { const providerIds = ['opencode'] as const; const first = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'opencode', { providerId: 'opencode', supported: true, authenticated: true, authMethod: 'oauth', verificationState: 'unknown', modelVerificationState: 'unknown', models: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'], modelCatalog: { source: 'live', status: 'ready', models: [ { id: 'opencode/minimax-m2.5-free' }, { id: 'opencode/nemotron-3-super-free' }, ], }, modelAvailability: [ { modelId: 'opencode/minimax-m2.5-free', status: 'unknown', reason: null, }, ], }, ], ]) ); const second = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'opencode', { providerId: 'opencode', supported: true, authenticated: true, authMethod: 'oauth', verificationState: 'verified', modelVerificationState: 'verified', models: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'], modelCatalog: { source: 'live', status: 'ready', models: [ { id: 'opencode/minimax-m2.5-free' }, { id: 'opencode/nemotron-3-super-free' }, ], }, modelAvailability: [ { modelId: 'opencode/minimax-m2.5-free', status: 'available', reason: 'verified', }, ], }, ], ]) ); expect(first).toBe(second); }); it('ignores backend option status churn when the selected backend identity is unchanged', () => { const providerIds = ['opencode'] as const; const first = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'opencode', { providerId: 'opencode', supported: true, authenticated: true, authMethod: 'oauth', selectedBackendId: 'opencode-cli', resolvedBackendId: 'opencode-cli', availableBackends: [ { id: 'opencode-cli', available: false, selectable: true, state: 'degraded', recommended: true, audience: 'general', statusMessage: 'PONG probe still running', }, ], }, ], ]) ); const second = buildProviderPrepareRuntimeStatusSignature( providerIds, providerStatusMap([ [ 'opencode', { providerId: 'opencode', supported: true, authenticated: true, authMethod: 'oauth', selectedBackendId: 'opencode-cli', resolvedBackendId: 'opencode-cli', availableBackends: [ { id: 'opencode-cli', available: true, selectable: true, state: 'ready', recommended: true, audience: 'general', statusMessage: 'Managed runtime verified', }, ], }, ], ]) ); expect(first).toBe(second); }); it('ignores volatile member draft ids in provider prepare signatures', () => { const first = buildProviderPrepareMembersSignature([ { id: 'draft-before-poll', name: 'tom', roleSelection: '', customRole: 'Developer', providerId: 'opencode', model: 'opencode/big-pickle', }, ]); const second = buildProviderPrepareMembersSignature([ { id: 'draft-after-poll', name: 'tom', roleSelection: '', customRole: 'Developer', providerId: 'opencode', model: 'opencode/big-pickle', }, ]); expect(first).toBe(second); }); it('builds a stable composite request signature for unchanged member/model selections', () => { const membersSignature = buildProviderPrepareMembersSignature([ { id: 'member-1', name: 'alice', roleSelection: '', customRole: 'Reviewer', providerId: 'codex', model: 'gpt-5.4', }, ]); const modelChecksSignature = buildProviderPrepareModelChecksSignature( new Map([ ['codex', ['gpt-5.4', 'default']], ['opencode', ['opencode/nemotron-3-super-free']], ]) ); expect( buildProviderPrepareRequestSignature({ cwd: '/tmp/project', selectedProviderId: 'codex', selectedModel: 'gpt-5.4', selectedMemberProviders: ['codex', 'opencode'], limitContext: false, runtimeStatusSignature: 'runtime-a', membersSignature, modelChecksSignature, }) ).toBe( buildProviderPrepareRequestSignature({ cwd: '/tmp/project', selectedProviderId: 'codex', selectedModel: 'gpt-5.4', selectedMemberProviders: ['opencode', 'codex'], limitContext: false, runtimeStatusSignature: 'runtime-a', membersSignature, modelChecksSignature, }) ); }); it('changes model checks signature when selected effort changes', () => { const medium = buildProviderPrepareModelChecksSignature( new Map([['anthropic', [{ model: 'claude-opus-4-6[1m]', effort: 'medium' }]]]) ); const high = buildProviderPrepareModelChecksSignature( new Map([['anthropic', [{ model: 'claude-opus-4-6[1m]', effort: 'high' }]]]) ); expect(medium).not.toBe(high); expect(medium).toContain('"effort":"medium"'); expect(high).toContain('"effort":"high"'); }); });