diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index fd01934c..477e7bef 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -32,20 +32,43 @@ interface FailedSpawnDetail { reason: string | null; } -function countPermissionBlockedMembers(memberSpawnStatuses: MemberSpawnStatusCollection): number { - if (!memberSpawnStatuses) { - return 0; +function countPermissionBlockedMembers(params: { + memberSpawnStatuses: MemberSpawnStatusCollection; + memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; +}): number { + const names = new Set(); + if (params.memberSpawnStatuses instanceof Map) { + for (const name of params.memberSpawnStatuses.keys()) { + names.add(name); + } + } else if (params.memberSpawnStatuses) { + for (const name of Object.keys(params.memberSpawnStatuses)) { + names.add(name); + } + } + for (const name of Object.keys(params.memberSpawnSnapshotStatuses ?? {})) { + names.add(name); } - const entries = - memberSpawnStatuses instanceof Map - ? [...memberSpawnStatuses.values()] - : Object.values(memberSpawnStatuses); - return entries.filter( - (entry) => + let count = 0; + for (const name of names) { + const liveEntry = + params.memberSpawnStatuses instanceof Map + ? params.memberSpawnStatuses.get(name) + : params.memberSpawnStatuses?.[name]; + const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name]; + const entry = liveEntry ?? snapshotEntry; + if (!entry) { + continue; + } + if ( entry.launchState === 'runtime_pending_permission' || (entry.pendingPermissionRequestIds?.length ?? 0) > 0 - ).length; + ) { + count += 1; + } + } + return count; } function buildAwaitingPermissionPhrase(count: number): string { @@ -185,7 +208,9 @@ export function buildTeamProvisioningPresentation({ progress: TeamProvisioningProgress | null | undefined; members: readonly ProvisioningMemberLike[]; memberSpawnStatuses?: MemberSpawnStatusCollection; - memberSpawnSnapshot?: Pick; + memberSpawnSnapshot?: Pick & { + statuses?: MemberSpawnStatusesSnapshot['statuses']; + }; }): TeamProvisioningPresentation | null { if (!progress) { return null; @@ -223,7 +248,10 @@ export function buildTeamProvisioningPresentation({ failedSpawnCount, expectedTeammateCount ); - const permissionBlockedCount = countPermissionBlockedMembers(memberSpawnStatuses); + const permissionBlockedCount = countPermissionBlockedMembers({ + memberSpawnStatuses, + memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, + }); const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } = getLaunchJoinState({ diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index 349a244c..af00073d 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -460,6 +460,71 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.panelMessage).toBe('1 teammate awaiting permission approval'); }); + it('trusts persisted snapshot permission state when live member spawn statuses are absent', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-4f', + teamName: 'opencode-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: {}, + memberSpawnSnapshot: { + expectedMembers: ['bob'], + statuses: { + bob: { + status: 'online', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-04-13T10:00:07.000Z', + runtimeAlive: true, + livenessSource: 'process', + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + pendingPermissionRequestIds: ['perm_1'], + firstSpawnAcceptedAt: '2026-04-13T10:00:01.000Z', + }, + }, + summary: { + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }, + }, + }); + + expect(presentation?.compactTitle).toBe('Finishing launch'); + expect(presentation?.compactDetail).toBe('1 teammate awaiting permission approval'); + expect(presentation?.panelMessage).toBe('1 teammate awaiting permission approval'); + }); + it('keeps a generic failed teammate message while launch is still active if only persisted failure counts remain', () => { const presentation = buildTeamProvisioningPresentation({ progress: {