diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2de62192..4b72a2b6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -6245,6 +6245,7 @@ export class TeamProvisioningService { cwd: targetCwd, providerId: 'opencode', model: selectedModelIds[0], + runtimeOnly: selectedModelIds.length === 0, skipPermissions: true, expectedMembers: [], previousLaunchState: null, diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index 6938ba3e..f560ef74 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -1,5 +1,6 @@ import { assertOpenCodeProductionE2EArtifactGate, + buildOpenCodeProjectPathFingerprint, type OpenCodeProductionE2EEvidence, } from '../e2e/OpenCodeProductionE2EEvidence'; import { @@ -51,7 +52,7 @@ export interface OpenCodeReadinessBridgeOptions { } export interface OpenCodeProductionE2EEvidenceReadPort { - read(input?: { selectedModel?: string | null }): Promise<{ + read(input?: { selectedModel?: string | null; projectPathFingerprint?: string | null }): Promise<{ ok: boolean; evidence: OpenCodeProductionE2EEvidence | null; artifactPath: string; @@ -128,8 +129,12 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { } const expectedModel = input.readiness.modelId ?? input.input.selectedModel; + const projectPathFingerprint = buildOpenCodeProjectPathFingerprint(input.input.projectPath); const evidenceRead = this.options.productionE2eEvidence - ? await this.options.productionE2eEvidence.read({ selectedModel: expectedModel }) + ? await this.options.productionE2eEvidence.read({ + selectedModel: expectedModel, + projectPathFingerprint, + }) : { ok: false, evidence: null, @@ -145,6 +150,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { binaryFingerprint: input.runtime.binaryFingerprint, capabilitySnapshotId: input.runtime.capabilitySnapshotId, selectedModel: expectedModel, + projectPathFingerprint, requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => buildOpenCodeCanonicalMcpToolId('agent-teams', tool) ), diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts index 4306af63..cbcf83ed 100644 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts +++ b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts @@ -1,3 +1,6 @@ +import { createHash } from 'node:crypto'; +import * as path from 'node:path'; + export const OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION = 1; export const OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION = 1; @@ -92,6 +95,7 @@ export interface OpenCodeProductionE2EGateExpectation { binaryFingerprint: string | null; capabilitySnapshotId: string | null; selectedModel: string | null; + projectPathFingerprint?: string | null; requiredMcpTools?: string[]; } @@ -100,6 +104,18 @@ export interface OpenCodeProductionE2EGateResult { diagnostics: string[]; } +export function buildOpenCodeProjectPathFingerprint( + projectPath: string | null | undefined +): string | null { + const trimmed = projectPath?.trim() ?? ''; + if (!trimmed) { + return null; + } + + const normalized = path.resolve(trimmed).replace(/\\/g, '/'); + return `project:${createHash('sha256').update(normalized).digest('hex')}`; +} + export function validateOpenCodeProductionE2EEvidence( value: unknown ): OpenCodeProductionE2EEvidence { @@ -368,6 +384,15 @@ function collectExpectedRuntimeDiagnostics( ); } + if ( + expected.projectPathFingerprint && + evidence.projectPathFingerprint !== expected.projectPathFingerprint + ) { + diagnostics.push( + 'OpenCode production E2E evidence project context does not match the current working directory' + ); + } + const requiredTools = expected.requiredMcpTools ?? []; if (requiredTools.length > 0) { const observedTools = new Set(evidence.mcpTools.observedTools); @@ -418,19 +443,14 @@ function validateOpenCodeProductionE2EEvidenceCollection( } const entries: Record = {}; - for (const [modelId, rawEvidence] of Object.entries(entriesRecord)) { - const trimmedModelId = modelId.trim(); - if (!trimmedModelId) { - throw new Error('OpenCode production E2E evidence collection model id must be non-empty'); + for (const [entryKey, rawEvidence] of Object.entries(entriesRecord)) { + const trimmedEntryKey = entryKey.trim(); + if (!trimmedEntryKey) { + throw new Error('OpenCode production E2E evidence collection key must be non-empty'); } const evidence = validateOpenCodeProductionE2EEvidence(rawEvidence); - if (evidence.selectedModel !== trimmedModelId) { - throw new Error( - `OpenCode production E2E evidence collection key ${trimmedModelId} does not match selectedModel ${evidence.selectedModel}` - ); - } - entries[trimmedModelId] = evidence; + entries[trimmedEntryKey] = evidence; } return { diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts index 77ec72b8..9811b2c0 100644 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts +++ b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts @@ -26,6 +26,7 @@ export interface OpenCodeProductionE2EEvidenceStoreOptions { export interface OpenCodeProductionE2EEvidenceStoreReadOptions { selectedModel?: string | null; + projectPathFingerprint?: string | null; } export class OpenCodeProductionE2EEvidenceStore { @@ -62,7 +63,7 @@ export class OpenCodeProductionE2EEvidenceStore { }; } - const selection = selectEvidence(result.data, options.selectedModel); + const selection = selectEvidence(result.data, options); return { ok: true, evidence: selection.evidence, @@ -90,7 +91,7 @@ export class OpenCodeProductionE2EEvidenceStore { function selectEvidence( data: OpenCodeProductionE2EEvidenceStoreData, - selectedModel: string | null | undefined + options: OpenCodeProductionE2EEvidenceStoreReadOptions ): { evidence: OpenCodeProductionE2EEvidence | null; diagnostics: string[]; @@ -103,13 +104,43 @@ function selectEvidence( return { evidence: data, diagnostics: [] }; } - const modelId = selectedModel?.trim() ?? ''; + const modelId = options.selectedModel?.trim() ?? ''; + const projectPathFingerprint = options.projectPathFingerprint?.trim() ?? ''; if (modelId) { + const entries = Object.values(data.entriesByModel).filter( + (entry) => entry.selectedModel === modelId + ); + if (entries.length === 0) { + return { + evidence: null, + diagnostics: [ + `OpenCode production E2E evidence artifact has no entry for selected model ${modelId}`, + ], + }; + } + + if (projectPathFingerprint) { + const exactMatch = pickNewestEvidence( + entries.filter((entry) => entry.projectPathFingerprint === projectPathFingerprint) + ); + if (exactMatch) { + return { + evidence: exactMatch, + diagnostics: [], + }; + } + + return { + evidence: null, + diagnostics: [ + `OpenCode production E2E evidence artifact has no entry for selected model ${modelId} and the current working directory`, + ], + }; + } + return { - evidence: data.entriesByModel[modelId] ?? null, - diagnostics: data.entriesByModel[modelId] - ? [] - : [`OpenCode production E2E evidence artifact has no entry for selected model ${modelId}`], + evidence: pickNewestEvidence(entries), + diagnostics: [], }; } @@ -140,9 +171,33 @@ function upsertEvidence( entriesByModel[current.selectedModel] = current; } - entriesByModel[evidence.selectedModel] = evidence; + entriesByModel[buildEvidenceKey(evidence)] = evidence; return { collectionSchemaVersion: 1, entriesByModel, }; } + +function buildEvidenceKey(evidence: OpenCodeProductionE2EEvidence): string { + return [evidence.selectedModel, evidence.projectPathFingerprint ?? 'global'].join('::'); +} + +function pickNewestEvidence( + entries: OpenCodeProductionE2EEvidence[] +): OpenCodeProductionE2EEvidence | null { + if (entries.length === 0) { + return null; + } + + return entries.slice(1).reduce((latest, entry) => { + const latestAt = Date.parse(latest.createdAt); + const entryAt = Date.parse(entry.createdAt); + if (!Number.isFinite(entryAt)) { + return latest; + } + if (!Number.isFinite(latestAt) || entryAt >= latestAt) { + return entry; + } + return latest; + }, entries[0]!); +} diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 6bf9d983..a0f687f3 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -66,8 +66,8 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { ) {} async prepare(input: TeamRuntimeLaunchInput): Promise { - const launchMode = resolveOpenCodeTeamLaunchMode(this.options); - if (launchMode === 'disabled') { + const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options); + if (configuredLaunchMode === 'disabled') { return { ok: false, providerId: this.providerId, @@ -80,11 +80,12 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { }; } + const runtimeOnly = input.runtimeOnly === true; const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({ projectPath: input.cwd, selectedModel: input.model ?? null, - requireExecutionProbe: true, - launchMode, + requireExecutionProbe: !runtimeOnly, + launchMode: runtimeOnly ? undefined : configuredLaunchMode, }); if (!readiness.launchAllowed) { @@ -99,13 +100,17 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { } const warnings = - launchMode === 'dogfood' + configuredLaunchMode === 'dogfood' && !runtimeOnly ? [ 'OpenCode dogfood launch mode is active. This is local test mode and may run without production E2E evidence.', ] : []; - if (launchMode === 'production' && readiness.supportLevel !== 'production_supported') { + if ( + !runtimeOnly && + configuredLaunchMode === 'production' && + readiness.supportLevel !== 'production_supported' + ) { return { ok: false, providerId: this.providerId, @@ -128,6 +133,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { } async launch(input: TeamRuntimeLaunchInput): Promise { + const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options); const prepared = await this.prepare(input); if (!prepared.ok) { return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings); @@ -149,7 +155,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null; this.lastProjectPathByTeamName.set(input.teamName, input.cwd); const data = await this.bridge.launchOpenCodeTeam({ - mode: resolveOpenCodeTeamLaunchMode(this.options), + mode: configuredLaunchMode, runId: input.runId, teamId: input.teamName, teamName: input.teamName, diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index f4d1a846..f4252b16 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -30,6 +30,11 @@ export interface TeamRuntimeLaunchInput { providerId: TeamRuntimeProviderId; model?: string; effort?: EffortLevel; + /** + * Runtime-only preflight skips model-scoped execution/evidence checks. + * Use only for warm-up diagnostics before a concrete launch model is selected. + */ + runtimeOnly?: boolean; skipPermissions: boolean; expectedMembers: TeamRuntimeMemberSpec[]; previousLaunchState: PersistedTeamLaunchSnapshot | null; diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index b855f5ef..b9bb6bd9 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -494,6 +494,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); useEffect(() => { + if (!open) { + return; + } + setMembersDrafts((prev) => { const sanitized = clearInheritedMemberModelsUnavailableForProvider({ members: prev, @@ -502,7 +506,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }); return sanitized.changed ? sanitized.members : prev; }); - }, [runtimeProviderStatusById, selectedProviderId]); + }, [membersDrafts, open, runtimeProviderStatusById, selectedProviderId]); useEffect(() => { if (multimodelEnabled) { diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts index f1e47d6a..e1c02717 100644 --- a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts @@ -154,6 +154,12 @@ function normalizeModelReason(rawReason: string | null | undefined): string | nu if (/The requested model is not available for your account\./i.test(trimmed)) { return 'Not available for this account'; } + if (/token refresh failed:\s*401/i.test(trimmed)) { + return 'OpenCode provider authentication failed (token refresh 401)'; + } + if (/unauthorized|forbidden|\b401\b|\b403\b/i.test(trimmed)) { + return 'OpenCode provider authentication failed'; + } if ( trimmed.toLowerCase().includes('timeout running:') || trimmed.toLowerCase().includes('timed out') || @@ -247,6 +253,19 @@ function extractTimedOutPreflightProbeModelId(detail: string): string | null { return match?.[1]?.trim() || null; } +function isSuppressibleGenericPreflightWarning(detail: string): boolean { + const lower = detail.trim().toLowerCase(); + if (!lower) { + return false; + } + + return ( + lower.includes('preflight check failed') || + (lower.includes('preflight check for `') && lower.includes('-p` did not complete')) || + lower.includes('preflight ping completed but did not return the expected pong') + ); +} + function suppressSupersededRuntimeWarnings(params: { runtimeDetailLines: string[]; runtimeWarnings: string[]; @@ -256,16 +275,23 @@ function suppressSupersededRuntimeWarnings(params: { runtimeWarnings: string[]; } { const suppressedEntries = new Set(); + const allSelectedModelsReady = + params.modelResultsById.size > 0 && + Array.from(params.modelResultsById.values()).every((result) => result.status === 'ready'); for (const warning of params.runtimeWarnings) { const probedModelId = extractTimedOutPreflightProbeModelId(warning); - if (!probedModelId) { + if (probedModelId) { + if (params.modelResultsById.get(probedModelId)?.status !== 'ready') { + continue; + } + suppressedEntries.add(warning); continue; } - if (params.modelResultsById.get(probedModelId)?.status !== 'ready') { - continue; + + if (allSelectedModelsReady && isSuppressibleGenericPreflightWarning(warning)) { + suppressedEntries.add(warning); } - suppressedEntries.add(warning); } return { @@ -283,9 +309,11 @@ function resolveModelResultFromBatch( isOnlyModel: boolean ): ProviderPrepareDiagnosticsModelResult { const modelScopedEntries = getModelScopedEntries(modelId, result); - const normalizedReason = - getScopedModelReason(modelId, modelScopedEntries) ?? - (isOnlyModel ? normalizeModelReason(result.message) : null); + const hasModelScopedEntries = modelScopedEntries.length > 0; + const scopedReason = getScopedModelReason(modelId, modelScopedEntries); + const fallbackBatchReason = isOnlyModel + ? (getResultReason(modelId, result) ?? normalizeModelReason(result.message)) + : null; const hasVerifiedLine = modelScopedEntries.some((entry) => /selected model .* verified for launch\./i.test(entry) @@ -304,7 +332,12 @@ function resolveModelResultFromBatch( if (hasUnavailableLine || (!result.ready && isOnlyModel)) { return { status: 'failed', - line: buildModelFailureLine(providerId, modelId, 'unavailable', normalizedReason), + line: buildModelFailureLine( + providerId, + modelId, + 'unavailable', + scopedReason ?? fallbackBatchReason + ), warningLine: null, }; } @@ -312,8 +345,11 @@ function resolveModelResultFromBatch( const hasVerificationWarningLine = modelScopedEntries.some((entry) => /selected model .* could not be verified\./i.test(entry) ); - if (hasVerificationWarningLine || ((result.warnings?.length ?? 0) > 0 && isOnlyModel)) { - const line = buildModelFailureLine(providerId, modelId, 'check failed', normalizedReason); + if ( + hasVerificationWarningLine || + ((result.warnings?.length ?? 0) > 0 && isOnlyModel && hasModelScopedEntries) + ) { + const line = buildModelFailureLine(providerId, modelId, 'check failed', scopedReason); return { status: 'notes', line, @@ -333,7 +369,7 @@ function resolveModelResultFromBatch( providerId, modelId, 'check failed', - normalizedReason ?? 'Model verification failed' + scopedReason ?? 'Model verification failed' ); return { status: 'notes', diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index b5db3be0..d0630bf8 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -48,7 +48,8 @@ export type TeamModelRuntimeProviderStatus = Pick< | 'backend' | 'authenticated' | 'supported' ->; +> & + Partial>; export type TeamRuntimeModelOption = TeamProviderModelOption & { availabilityStatus?: CliProviderModelAvailabilityStatus | null; @@ -77,6 +78,34 @@ export function isTeamModelUiDisabled( return getTeamModelUiDisabledReason(providerId, model, providerStatus) !== null; } +export function isTeamProviderModelVerificationPending( + providerId: SupportedProviderId | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): boolean { + if (!providerId || providerId === 'anthropic' || !providerStatus) { + return false; + } + + if (providerStatus.modelVerificationState === 'verifying') { + return true; + } + + if (providerStatus.verificationState !== 'unknown') { + return false; + } + + const hasRuntimeModelTruth = + providerStatus.models.length > 0 || + (providerStatus.modelCatalog?.models.length ?? 0) > 0 || + (providerStatus.modelAvailability?.length ?? 0) > 0; + if (hasRuntimeModelTruth) { + return false; + } + + const statusMessage = providerStatus.statusMessage?.trim().toLowerCase() ?? ''; + return statusMessage.length === 0 || statusMessage === 'checking...'; +} + function getFallbackTeamProviderModels(providerId: SupportedProviderId): string[] { return getVisibleTeamProviderModels( providerId, @@ -304,6 +333,10 @@ export function getAvailableTeamProviderModelOptions( return [{ value: '', label: 'Default', badgeLabel: 'Default' }]; } + if (isTeamProviderModelVerificationPending(providerId, providerStatus)) { + return getFallbackTeamProviderModelOptions(providerId, providerStatus); + } + const visibleModels = getRuntimeSelectorModels(providerId, providerStatus); return [ { value: '', label: 'Default', badgeLabel: 'Default' }, @@ -348,6 +381,10 @@ export function isTeamModelAvailableForUi( return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available'; } + if (isTeamProviderModelVerificationPending(providerId, providerStatus)) { + return true; + } + return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available'; } @@ -382,6 +419,10 @@ export function normalizeTeamModelForUi( return ''; } + if (isTeamProviderModelVerificationPending(providerId, providerStatus)) { + return normalized; + } + const visibleModels = getVisibleRuntimeModels(providerId, providerStatus); if (!visibleModels.includes(trimmed)) { return ''; @@ -416,6 +457,10 @@ export function getTeamModelSelectionError( return `Model "${trimmed}" is waiting for ${getTeamProviderLabel(providerId) ?? providerId} runtime verification. Wait for the model list to load or use Default.`; } + if (isTeamProviderModelVerificationPending(providerId, providerStatus)) { + return null; + } + const visibleModels = getVisibleRuntimeModels(providerId, providerStatus); if (!visibleModels.includes(trimmed)) { return `Model "${trimmed}" is not available for the current ${getTeamProviderLabel(providerId) ?? providerId} runtime. Pick one of the listed models or use Default.`; diff --git a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts b/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts index 5fe26978..8bbb961c 100644 --- a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts +++ b/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { assertOpenCodeProductionE2EArtifactGate, + buildOpenCodeProjectPathFingerprint, OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, validateOpenCodeProductionE2EEvidence, @@ -43,6 +44,7 @@ describe('OpenCodeProductionE2EEvidence', () => { binaryFingerprint: 'version:1.14.19', capabilitySnapshotId: 'cap-1', selectedModel: 'openai/gpt-5.4-mini', + projectPathFingerprint: 'project-a', requiredMcpTools: ['agent-teams_runtime_deliver_message'], }, }) @@ -73,6 +75,7 @@ describe('OpenCodeProductionE2EEvidence', () => { binaryFingerprint: 'version:1.14.19', capabilitySnapshotId: 'cap-1', selectedModel: 'openai/gpt-5.4-mini', + projectPathFingerprint: 'project-a', requiredMcpTools: ['agent-teams_runtime_deliver_message'], }, }) @@ -170,6 +173,56 @@ describe('OpenCodeProductionE2EEvidence', () => { ], }); }); + + it('stores production evidence for the same raw model across multiple project contexts', async () => { + const filePath = path.join(tempDir, 'production-e2e-evidence.json'); + const store = new OpenCodeProductionE2EEvidenceStore({ + filePath, + clock: () => now, + }); + + await store.write( + passingEvidence({ + evidenceId: 'e2e-project-a', + selectedModel: 'opencode/minimax-m2.5-free', + projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'), + }) + ); + await store.write( + passingEvidence({ + evidenceId: 'e2e-project-b', + selectedModel: 'opencode/minimax-m2.5-free', + projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'), + }) + ); + + await expect( + store.read({ + selectedModel: 'opencode/minimax-m2.5-free', + projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'), + }) + ).resolves.toMatchObject({ + ok: true, + evidence: { + evidenceId: 'e2e-project-b', + selectedModel: 'opencode/minimax-m2.5-free', + }, + diagnostics: [], + }); + + await expect( + store.read({ + selectedModel: 'opencode/minimax-m2.5-free', + projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-c'), + }) + ).resolves.toMatchObject({ + ok: true, + evidence: null, + diagnostics: [ + 'OpenCode production E2E evidence artifact has no entry for selected model opencode/minimax-m2.5-free and the current working directory', + ], + }); + }); }); function passingEvidence( diff --git a/test/main/services/team/OpenCodeProductionGate.live.test.ts b/test/main/services/team/OpenCodeProductionGate.live.test.ts index bb992063..7fa3b1e2 100644 --- a/test/main/services/team/OpenCodeProductionGate.live.test.ts +++ b/test/main/services/team/OpenCodeProductionGate.live.test.ts @@ -17,6 +17,7 @@ import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/open import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; import { assertOpenCodeProductionE2EArtifactGate, + buildOpenCodeProjectPathFingerprint, OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS, OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, @@ -29,6 +30,7 @@ import { REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS, } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder'; +import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy'; import type { OpenCodeBridgeRuntimeSnapshot, @@ -99,8 +101,8 @@ liveDescribe('OpenCode production gate live e2e', () => { selectedModel, requireExecutionProbe: false, }); - const runtime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH); - if (!runtime) { + const initialRuntime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH); + if (!initialRuntime) { throw new Error( `OpenCode live readiness did not return runtime snapshot: ${[ ...readiness.diagnostics, @@ -108,8 +110,8 @@ liveDescribe('OpenCode production gate live e2e', () => { ].join('; ')}` ); } - expect(runtime?.version).toBe('1.14.19'); - expect(runtime?.capabilitySnapshotId).toBeTruthy(); + expect(initialRuntime?.version).toBe('1.14.19'); + expect(initialRuntime?.capabilitySnapshotId).toBeTruthy(); const runId = `opencode-e2e-${Date.now()}`; const teamName = `opencode-e2e-team-${Date.now()}`; @@ -136,7 +138,7 @@ liveDescribe('OpenCode production gate live e2e', () => { }, ], leadPrompt: 'Live OpenCode production gate e2e', - expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null, + expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null, manifestHighWatermark: null, }); @@ -148,7 +150,7 @@ liveDescribe('OpenCode production gate live e2e', () => { teamId: teamName, teamName, projectPath: PROJECT_PATH, - expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null, + expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null, manifestHighWatermark: null, expectedMembers: [{ name: memberName, model: selectedModel }], reason: 'production_gate_e2e', @@ -182,19 +184,36 @@ liveDescribe('OpenCode production gate live e2e', () => { teamId: teamName, teamName, projectPath: PROJECT_PATH, - expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null, + expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null, manifestHighWatermark: null, reason: 'production_gate_e2e_cleanup', force: true, }); expect(stop.stopped).toBe(true); + const finalReadiness = await readinessBridge.checkOpenCodeTeamLaunchReadiness({ + projectPath: PROJECT_PATH, + selectedModel, + requireExecutionProbe: true, + }); + const finalRuntime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH); + if (!finalRuntime) { + throw new Error( + `OpenCode final readiness did not return runtime snapshot: ${[ + ...finalReadiness.diagnostics, + ...finalReadiness.missing, + ].join('; ')}` + ); + } + expect(finalRuntime.version).toBe('1.14.19'); + expect(finalRuntime.capabilitySnapshotId).toBeTruthy(); + const candidate = buildCandidateEvidence({ runId, teamName, memberName, selectedModel, - runtime: runtime!, + runtime: finalRuntime, readinessObservedTools: readiness.evidence.observedMcpTools, launch, reconcile, @@ -207,10 +226,11 @@ liveDescribe('OpenCode production gate live e2e', () => { evidence: candidate, artifactPath: candidate.artifactPath, expected: { - opencodeVersion: runtime?.version ?? null, - binaryFingerprint: runtime?.binaryFingerprint ?? null, - capabilitySnapshotId: runtime?.capabilitySnapshotId ?? null, + opencodeVersion: finalRuntime.version ?? null, + binaryFingerprint: finalRuntime.binaryFingerprint ?? null, + capabilitySnapshotId: finalRuntime.capabilitySnapshotId ?? null, selectedModel, + projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH), requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => buildOpenCodeCanonicalMcpToolId('agent-teams', tool) ), @@ -230,7 +250,7 @@ liveDescribe('OpenCode production gate live e2e', () => { teamId: teamName, teamName, projectPath: PROJECT_PATH, - expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null, + expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null, manifestHighWatermark: null, reason: 'production_gate_e2e_finally_cleanup', force: true, @@ -373,7 +393,7 @@ function buildCandidateEvidence(input: { binaryFingerprint: input.runtime.binaryFingerprint ?? 'unknown', capabilitySnapshotId: input.runtime.capabilitySnapshotId ?? 'unknown', selectedModel: input.selectedModel, - projectPathFingerprint: null, + projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH), requiredSignals: { ...Object.fromEntries( OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true]) @@ -433,23 +453,9 @@ function withBunOnPath(pathValue: string): string { function createStableBridgeEnv(): NodeJS.ProcessEnv { const realHome = os.userInfo().homedir; - const passThroughKeys = [ - 'USER', - 'LOGNAME', - 'SHELL', - 'TMPDIR', - 'LANG', - 'LC_ALL', - 'OPENCODE_E2E_MODEL', - 'OPENCODE_DISABLE_CLAUDE_CODE', - 'OPENCODE_DISABLE_AUTOUPDATE', - ]; + const env = applyOpenCodeAutoUpdatePolicy({ ...process.env }); return { - ...Object.fromEntries( - passThroughKeys - .map((key) => [key, process.env[key]] as const) - .filter((entry): entry is readonly [string, string] => typeof entry[1] === 'string') - ), + ...env, HOME: realHome, USERPROFILE: realHome, }; diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index 0b717134..2d6bb09f 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -8,6 +8,7 @@ import { import { OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, + buildOpenCodeProjectPathFingerprint, type OpenCodeProductionE2EEvidence, } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; import { @@ -167,6 +168,7 @@ describe('OpenCodeReadinessBridge', () => { }); expect(evidence.read).toHaveBeenCalledWith({ selectedModel: 'openai/gpt-5.4-mini', + projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'), }); }); @@ -365,7 +367,7 @@ function productionEvidence( binaryFingerprint: 'bin-1', capabilitySnapshotId: 'cap-1', selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: 'project-a', + projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'), requiredSignals: Object.fromEntries( OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true]) ) as OpenCodeProductionE2EEvidence['requiredSignals'], diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 091dfdd7..7bf64b11 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -38,6 +38,25 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); }); + it('uses runtime-only readiness for model-less preflight checks', async () => { + const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true, modelId: null })); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + + await expect(adapter.prepare(launchInput({ model: undefined, runtimeOnly: true }))).resolves + .toMatchObject({ + ok: true, + providerId: 'opencode', + modelId: null, + }); + + expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledWith({ + projectPath: '/repo', + selectedModel: null, + requireExecutionProbe: false, + launchMode: undefined, + }); + }); + it('fails closed when launch mode is disabled', async () => { const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true })); const adapter = new OpenCodeTeamRuntimeAdapter( diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index bd60b5d5..5689b01f 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -92,6 +92,7 @@ vi.mock('@main/utils/childProcess', () => ({ import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime'; import { spawnCli } from '@main/utils/childProcess'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; @@ -327,6 +328,59 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(ClaudeBinaryResolver.resolve).not.toHaveBeenCalled(); }); + it('marks model-less OpenCode prepare as runtime-only and keeps model checks strict', async () => { + const prepare = vi.fn(async () => ({ + ok: true as const, + providerId: 'opencode' as const, + modelId: null, + diagnostics: [], + 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); + + await expect( + svc.prepareForProvisioning(tempRoot, { + providerId: 'opencode', + forceFresh: true, + }) + ).resolves.toMatchObject({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + expect(prepare).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + providerId: 'opencode', + model: undefined, + runtimeOnly: true, + }) + ); + + await svc.prepareForProvisioning(tempRoot, { + providerId: 'opencode', + forceFresh: true, + modelIds: ['opencode/minimax-m2.5-free'], + }); + expect(prepare).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + runtimeOnly: false, + }) + ); + }); + it('keys the prepare probe cache by cwd', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts index 03d98f97..51001be9 100644 --- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -261,9 +261,9 @@ vi.mock('@renderer/utils/geminiUiFreeze', () => ({ })); vi.mock('@renderer/utils/teamModelAvailability', () => ({ - getTeamModelSelectionError: () => null, - isTeamModelAvailableForUi: () => true, - normalizeExplicitTeamModelForUi: (_providerId: string, model: string) => model, + getTeamModelSelectionError: vi.fn(() => null), + isTeamModelAvailableForUi: vi.fn(() => true), + normalizeExplicitTeamModelForUi: vi.fn((_providerId: string, model: string) => model), })); vi.mock('@renderer/components/team/dialogs/providerPrepareCacheKey', () => ({ @@ -356,6 +356,8 @@ vi.mock('@renderer/components/team/dialogs/CodexFastModeSelector', () => ({ import { api } from '@renderer/api'; import { LaunchTeamDialog } from '@renderer/components/team/dialogs/LaunchTeamDialog'; +import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics'; +import { isTeamModelAvailableForUi } from '@renderer/utils/teamModelAvailability'; async function flush(): Promise { await Promise.resolve(); @@ -484,6 +486,115 @@ describe('LaunchTeamDialog', () => { }); }); + it('clears stale inherited member models after saved launch hydration for OpenCode', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.mocked(isTeamModelAvailableForUi).mockImplementation( + (_providerId, model, providerStatus) => providerStatus?.models?.includes(model ?? '') ?? false + ); + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [ + { + providerId: 'opencode', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + statusMessage: null, + detailMessage: null, + models: ['opencode/minimax-m2.5-free'], + capabilities: { + teamLaunch: true, + oneShot: false, + }, + }, + ], + } as any; + vi.mocked(api.teams.getSavedRequest).mockResolvedValue({ + teamName: 'team-alpha', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + members: [ + { + name: 'alice', + role: 'Reviewer', + model: 'gemini-3-pro-preview', + }, + ], + } as any); + + const onLaunch = vi.fn< + (request: { providerId?: string; model?: string }) => Promise + >(async () => {}); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(LaunchTeamDialog, { + mode: 'launch', + open: true, + teamName: 'team-alpha', + members: [], + defaultProjectPath: '/tmp/project', + provisioningError: null, + clearProvisioningError: vi.fn(), + activeTeams: [], + onClose: vi.fn(), + onLaunch, + }) + ); + await flush(); + await flush(); + await flush(); + }); + + const opencodePrepareCalls = vi + .mocked(runProviderPrepareDiagnostics) + .mock.calls.filter((call) => call[0]?.providerId === 'opencode'); + expect(opencodePrepareCalls.at(-1)?.[0]?.selectedModelIds).toEqual([ + 'opencode/minimax-m2.5-free', + ]); + + const submitButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Launch team' + ); + expect(submitButton).toBeTruthy(); + + await act(async () => { + submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flush(); + await flush(); + }); + + expect(vi.mocked(api.teams.replaceMembers)).toHaveBeenCalledTimes(1); + expect(vi.mocked(api.teams.replaceMembers).mock.calls[0]?.[1]).toMatchObject({ + members: [ + { + name: 'alice', + role: 'Reviewer', + model: '', + }, + ], + }); + expect(onLaunch).toHaveBeenCalledTimes(1); + const launchRequest = (onLaunch.mock.calls as Array< + [{ providerId?: string; model?: string }] + >)[0]?.[0] as + | { providerId?: string; model?: string } + | undefined; + expect(launchRequest).toMatchObject({ + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); + it('prefills and saves Anthropic schedule runtime contract including max effort and fast mode', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts index f2d502fa..c4ec3bc9 100644 --- a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -413,4 +413,83 @@ describe('runProviderPrepareDiagnostics', () => { expect(result.warnings).toEqual([]); expect(result.details).toEqual(['5.4 Mini - verified', '5.4 - verified']); }); + + it('suppresses a generic runtime preflight note when all selected models verify', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is ready to launch (see notes)', + warnings: ['orchestrator-cli preflight check failed (exit code 1).'], + }); + } + + return Promise.resolve({ + ready: true, + message: 'CLI is ready to launch (see notes)', + warnings: ['orchestrator-cli preflight check failed (exit code 1).'], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.4'], + prepareProvisioning, + }); + + expect(result.status).toBe('ready'); + expect(result.warnings).toEqual([]); + expect(result.details).toEqual(['5.4 - verified']); + expect(result.modelResultsById).toEqual({ + 'gpt-5.4': { + status: 'ready', + line: '5.4 - verified', + warningLine: null, + }, + }); + }); + + it('prefers detailed OpenCode auth diagnostics over a generic not_authenticated batch message', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + + return Promise.resolve({ + ready: false, + message: 'OpenCode: not_authenticated', + details: ['Token refresh failed: 401'], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'opencode', + selectedModelIds: ['openai/gpt-5.2-codex'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + 'GPT-5.2 Codex - unavailable - OpenCode provider authentication failed (token refresh 401)', + ]); + }); }); diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts index a179d0db..dc5d9b8e 100644 --- a/test/renderer/utils/teamModelAvailability.test.ts +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -4,6 +4,9 @@ import { getAvailableTeamProviderModelOptions, getAvailableTeamProviderModels, getTeamModelSelectionError, + GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, + GPT_5_2_CODEX_UI_DISABLED_REASON, + GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, normalizeTeamModelForUi, type TeamModelRuntimeProviderStatus, } from '@renderer/utils/teamModelAvailability'; @@ -188,6 +191,47 @@ describe('teamModelAvailability', () => { expect(getTeamModelSelectionError('codex', '')).toBeNull(); }); + it('keeps known Codex selections stable while the runtime is still on placeholder checking state', () => { + const providerStatus = createCodexProviderStatus([], { + authMethod: null, + backend: null, + authenticated: false, + supported: false, + verificationState: 'unknown', + modelVerificationState: 'idle', + statusMessage: 'Checking...', + }); + + expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4'); + expect(getTeamModelSelectionError('codex', 'gpt-5.4', providerStatus)).toBeNull(); + expect(getAvailableTeamProviderModelOptions('codex', providerStatus)).toEqual([ + { value: '', label: 'Default', badgeLabel: 'Default' }, + { value: 'gpt-5.4', label: '5.4', badgeLabel: '5.4' }, + { value: 'gpt-5.4-mini', label: '5.4 Mini', badgeLabel: '5.4-mini' }, + { value: 'gpt-5.3-codex', label: '5.3 Codex', badgeLabel: '5.3-codex' }, + { + value: 'gpt-5.3-codex-spark', + label: '5.3 Codex Spark', + badgeLabel: '5.3-codex-spark', + uiDisabledReason: GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, + }, + { value: 'gpt-5.2', label: '5.2', badgeLabel: '5.2' }, + { + value: 'gpt-5.2-codex', + label: '5.2 Codex', + badgeLabel: '5.2-codex', + uiDisabledReason: GPT_5_2_CODEX_UI_DISABLED_REASON, + }, + { + value: 'gpt-5.1-codex-mini', + label: '5.1 Codex Mini', + badgeLabel: '5.1-codex-mini', + uiDisabledReason: GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, + }, + { value: 'gpt-5.1-codex-max', label: '5.1 Codex Max', badgeLabel: '5.1-codex-max' }, + ]); + }); + it('keeps runtime models selectable without per-model verification state', () => { const providerStatus = createCodexProviderStatus(['gpt-5.4']); expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4'); diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index 7f978266..045b67dc 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -154,4 +154,67 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.panelMessage).toContain('requested model is not available'); expect(presentation?.compactDetail).toBe('jack failed to start'); }); + + it('prefers live member spawn statuses over a stale persisted launch summary', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-4', + teamName: 'codex-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Launch completed', + messageSeverity: undefined, + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'bob', + agentType: 'engineer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + bob: { + status: 'online', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-04-13T10:00:07.000Z', + runtimeAlive: true, + livenessSource: 'process', + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + firstSpawnAcceptedAt: '2026-04-13T10:00:01.000Z', + }, + }, + memberSpawnSnapshot: { + expectedMembers: ['bob'], + summary: { + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + }, + }); + + expect(presentation?.compactTitle).toBe('Finishing launch'); + expect(presentation?.compactDetail).toBe('1 teammate still joining'); + expect(presentation?.panelMessage).toBe('1 teammate still joining'); + }); });