diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index e9602ce6..8dbf3086 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -545,6 +545,7 @@ export class TeamGraphAdapter { spawnLaunchState: spawn?.launchState, spawnLivenessSource: spawn?.livenessSource, spawnRuntimeAlive: spawn?.runtimeAlive, + spawnBootstrapConfirmed: spawn?.bootstrapConfirmed, spawnBootstrapStalled: spawn?.bootstrapStalled, runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index ca412410..6c762279 100644 --- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -325,6 +325,7 @@ const MemberPopoverContent = ({ spawnLaunchState: spawnEntry?.launchState, spawnLivenessSource: spawnEntry?.livenessSource, spawnRuntimeAlive: spawnEntry?.runtimeAlive, + spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed, spawnBootstrapStalled: spawnEntry?.bootstrapStalled, runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index c754e437..ff45aecf 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -228,6 +228,24 @@ import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser'; const logger = createLogger('IPC:teams'); const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000; + +type VisibleDirectReplyProtocol = 'send_message' | 'agent_teams_message_send'; + +function resolveVisibleDirectReplyProtocol(input: { + providerId?: TeamProviderId; + isLeadRecipient: boolean; + replyRecipient: string; +}): VisibleDirectReplyProtocol { + if ( + !input.isLeadRecipient && + input.replyRecipient.trim().toLowerCase() === 'user' && + input.providerId === 'codex' + ) { + return 'agent_teams_message_send'; + } + + return 'send_message'; +} const TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS = 250; function isPlainObject(value: unknown): value is Record { @@ -2473,7 +2491,11 @@ function buildMessageDeliveryText( opts: { actionMode?: AgentActionMode; isLeadRecipient: boolean; + memberName?: string; + messageId?: string; + protocol?: VisibleDirectReplyProtocol; replyRecipient?: string; + teamName?: string; } ): string { const hiddenBlocks: string[] = []; @@ -2482,22 +2504,49 @@ function buildMessageDeliveryText( hiddenBlocks.push(actionModeBlock); } if (!opts.isLeadRecipient) { - const replyRecipient = + const rawReplyRecipient = typeof opts.replyRecipient === 'string' && opts.replyRecipient.trim().length > 0 ? opts.replyRecipient.trim() : 'user'; - const senderDescriptor = replyRecipient === 'user' ? 'the human user' : `"${replyRecipient}"`; + const isUserReplyRecipient = rawReplyRecipient.toLowerCase() === 'user'; + const replyRecipient = isUserReplyRecipient ? 'user' : rawReplyRecipient; + const senderDescriptor = isUserReplyRecipient ? 'the human user' : `"${replyRecipient}"`; + const protocol = opts.protocol ?? 'send_message'; + const canUseAgentTeamsMessageSend = + protocol === 'agent_teams_message_send' && + isUserReplyRecipient && + typeof opts.teamName === 'string' && + opts.teamName.trim().length > 0 && + typeof opts.memberName === 'string' && + opts.memberName.trim().length > 0 && + typeof opts.messageId === 'string' && + opts.messageId.trim().length > 0; + const replyInstructionLines = canUseAgentTeamsMessageSend + ? [ + 'CRITICAL: Reply using the Agent Teams MCP message_send tool, not SendMessage.', + 'Use tool agent-teams_message_send or mcp__agent-teams__message_send, whichever exposed name is available.', + `CRITICAL: The tool input must include teamName="${opts.teamName!.trim()}", to="user", from="${opts.memberName!.trim()}", text, summary, source="runtime_delivery", and relayOfMessageId="${opts.messageId!.trim()}".`, + 'Do NOT answer only with normal assistant text when the Agent Teams message_send tool is available because that will not appear in the UI message thread.', + ] + : [ + 'CRITICAL: Reply using the SendMessage tool, not plain assistant text.', + `CRITICAL: The destination must be exactly to="${replyRecipient}".`, + 'CRITICAL: The SendMessage tool input must use the exact field names `to`, `summary`, and `message`.', + 'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.', + ]; hiddenBlocks.push( [ AGENT_BLOCK_OPEN, `You received a direct message from ${senderDescriptor} via the UI.`, - 'CRITICAL: Reply using the SendMessage tool, not plain assistant text.', - `CRITICAL: The destination must be exactly to="${replyRecipient}".`, - 'CRITICAL: The SendMessage tool input must use the exact field names `to`, `summary`, and `message`.', - 'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.', + ...replyInstructionLines, `Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`, 'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").', - ...(replyRecipient === 'user' + ...(canUseAgentTeamsMessageSend + ? [ + 'If neither Agent Teams MCP message_send tool name is available before any visible-message tool attempt, write exactly the concise reply text as normal assistant text so the runtime can relay it.', + ] + : []), + ...(isUserReplyRecipient ? [ 'CRITICAL: If the user asks you to check with the lead or another teammate before you can fully answer, FIRST send a short acknowledgement to "user" so the human sees you started (for example: "Принял, сейчас уточню и вернусь с ответом.").', 'Only after that first acknowledgement may you message the lead or another teammate.', @@ -2848,22 +2897,37 @@ async function handleSendMessage( typeof payload.from === 'string' && payload.from.trim().length > 0 ? payload.from.trim() : 'user'; - const isOpenCodeRecipient = - !isLeadRecipient && (await provisioning.isOpenCodeRuntimeRecipient(tn, memberName)); + const storedFrom = replyRecipient.toLowerCase() === 'user' ? 'user' : replyRecipient; + const recipientProviderId = !isLeadRecipient + ? await provisioning.resolveRuntimeRecipientProviderId(tn, memberName) + : undefined; + const isOpenCodeRecipient = recipientProviderId === 'opencode'; + const directReplyProtocol = resolveVisibleDirectReplyProtocol({ + isLeadRecipient, + replyRecipient, + ...(recipientProviderId ? { providerId: recipientProviderId } : {}), + }); + const inboxMessageId = + directReplyProtocol === 'agent_teams_message_send' ? crypto.randomUUID() : undefined; const memberDeliveryText = buildMessageDeliveryText(baseText, { actionMode, isLeadRecipient, + memberName, + protocol: directReplyProtocol, replyRecipient, + teamName: tn, + ...(inboxMessageId ? { messageId: inboxMessageId } : {}), }); const inboxText = isOpenCodeRecipient ? baseText : memberDeliveryText; const result = await getTeamDataService().sendMessage(tn, { member: memberName, text: inboxText, summary: payload.summary, - from: payload.from, + from: storedFrom, actionMode, source: 'user_sent', taskRefs: validatedTaskRefs.value, + ...(inboxMessageId ? { messageId: inboxMessageId } : {}), }); // Teammate inbox relay DISABLED (2026-03-23). diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index e5fdbcdb..5cc686d8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2417,6 +2417,11 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: { ]), ]; const runtimeAlive = true; + const livenessKind = + input.current.livenessKind === 'runtime_process' || + input.current.livenessKind === 'confirmed_bootstrap' + ? input.current.livenessKind + : 'confirmed_bootstrap'; return { ...input.previous, ...input.current, @@ -2428,12 +2433,7 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: { hardFailureReason: undefined, runtimeRunId: input.session.runId ?? input.current.runtimeRunId, runtimeSessionId: input.session.id, - livenessKind: runtimeAlive - ? input.current.livenessKind - : input.current.livenessKind === 'runtime_process' || - input.current.livenessKind === 'runtime_process_candidate' - ? input.current.livenessKind - : 'confirmed_bootstrap', + livenessKind, runtimeDiagnostic: 'OpenCode bootstrap evidence committed.', runtimeDiagnosticSeverity: 'info', firstSpawnAcceptedAt: @@ -6280,17 +6280,28 @@ export class TeamProvisioningService { ); } - async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise { + async resolveRuntimeRecipientProviderId( + teamName: string, + memberName: string + ): Promise { const normalizedMemberName = memberName.trim().toLowerCase(); if (!normalizedMemberName) { - return false; + return undefined; } const [config, metaMembers] = await Promise.all([ this.readConfigSnapshot(teamName).catch(() => null), this.membersMetaStore.getMembers(teamName).catch(() => []), ]); - return this.isOpenCodeRuntimeRecipientFromSources(normalizedMemberName, config, metaMembers); + return this.resolveRuntimeRecipientProviderIdFromSources( + normalizedMemberName, + config, + metaMembers + ); + } + + async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise { + return (await this.resolveRuntimeRecipientProviderId(teamName, memberName)) === 'opencode'; } private isOpenCodeDeliveryResponseReadCommitAllowed(input: { @@ -18829,10 +18840,17 @@ export class TeamProvisioningService { continue; } const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata); + const metadataLivenessKind = + current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive' + ? metadata.livenessKind === 'runtime_process' || + metadata.livenessKind === 'confirmed_bootstrap' + ? metadata.livenessKind + : current.livenessKind + : metadata.livenessKind; const nextEntry: MemberSpawnStatusEntry = { ...current, ...(metadata.model ? { runtimeModel: metadata.model } : {}), - ...(metadata.livenessKind ? { livenessKind: metadata.livenessKind } : {}), + ...(metadataLivenessKind ? { livenessKind: metadataLivenessKind } : {}), ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), ...(metadata.runtimeDiagnosticSeverity ? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity } @@ -19740,7 +19758,8 @@ export class TeamProvisioningService { return ( previous?.launchState !== member.launchState || previous?.bootstrapConfirmed !== member.bootstrapConfirmed || - previous?.runtimeSessionId !== member.runtimeSessionId + previous?.runtimeSessionId !== member.runtimeSessionId || + previous?.livenessKind !== member.livenessKind ); }); } @@ -19773,7 +19792,9 @@ export class TeamProvisioningService { previous: PersistedTeamLaunchMemberState | null ): boolean { if (current.launchState === 'confirmed_alive' && current.bootstrapConfirmed) { - return false; + return ( + current.livenessKind !== 'confirmed_bootstrap' && current.livenessKind !== 'runtime_process' + ); } if ( previous?.launchState === 'confirmed_alive' && @@ -25257,7 +25278,7 @@ export class TeamProvisioningService { if (!hasSpawnFailures && !hasPendingBootstrap) { // Fire "Team Launched" notification only for clean launches. void this.fireTeamLaunchedNotification(run); - } else { + } else if (hasSpawnFailures) { void this.fireTeamLaunchIncompleteNotification( run, failedSpawnMembers, @@ -25442,7 +25463,7 @@ export class TeamProvisioningService { if (!hasSpawnFailures && !hasPendingBootstrap) { // Fire "Team Launched" notification only for clean launches. void this.fireTeamLaunchedNotification(run); - } else { + } else if (hasSpawnFailures) { void this.fireTeamLaunchIncompleteNotification( run, failedSpawnMembers, @@ -25551,6 +25572,9 @@ export class TeamProvisioningService { failedMembers, snapshot ); + if (failedNames.length === 0) { + return; + } const pendingNames = this.getLaunchIncompletePendingNames( run, expectedMembers, @@ -25627,6 +25651,15 @@ export class TeamProvisioningService { const failedNames = new Set(failedMembers.map((member) => member.name).filter(Boolean)); for (const memberName of expectedMembers) { const { live, persisted } = this.getLaunchIncompleteMemberEvidence(run, snapshot, memberName); + const liveResolved = + live?.launchState === 'confirmed_alive' || + live?.bootstrapConfirmed === true || + live?.launchState === 'skipped_for_launch' || + live?.skippedForLaunch === true; + if (liveResolved) { + failedNames.delete(memberName); + continue; + } if ( live?.launchState === 'failed_to_start' || persisted?.launchState === 'failed_to_start' || diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 66a1a8ab..60ed3d0b 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -169,6 +169,7 @@ export const MemberCard = memo(function MemberCard({ spawnLaunchState, spawnLivenessSource, spawnRuntimeAlive, + spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed, spawnBootstrapStalled: spawnEntry?.bootstrapStalled, runtimeEntry, runtimeAdvisory: member.runtimeAdvisory, diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 625069dd..b9606264 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -252,6 +252,7 @@ export const MemberDetailDialog = ({ spawnLaunchState={spawnEntry?.launchState} spawnLivenessSource={spawnEntry?.livenessSource} spawnRuntimeAlive={spawnEntry?.runtimeAlive} + spawnBootstrapConfirmed={spawnEntry?.bootstrapConfirmed} spawnBootstrapStalled={spawnEntry?.bootstrapStalled} runtimeEntry={runtimeEntry} isLaunchSettling={isLaunchSettling} diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index cb82f777..cf5569ae 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -38,6 +38,7 @@ interface MemberDetailHeaderProps { spawnLaunchState?: MemberLaunchState; spawnLivenessSource?: MemberSpawnLivenessSource; spawnRuntimeAlive?: boolean; + spawnBootstrapConfirmed?: boolean; spawnBootstrapStalled?: boolean; isLaunchSettling?: boolean; onUpdateRole?: (newRole: string | undefined) => Promise | void; @@ -55,6 +56,7 @@ export const MemberDetailHeader = ({ spawnLaunchState, spawnLivenessSource, spawnRuntimeAlive, + spawnBootstrapConfirmed, spawnBootstrapStalled, isLaunchSettling, onUpdateRole, @@ -81,6 +83,7 @@ export const MemberDetailHeader = ({ spawnLaunchState, spawnLivenessSource, spawnRuntimeAlive, + spawnBootstrapConfirmed, spawnBootstrapStalled, runtimeEntry, runtimeAdvisory: member.runtimeAdvisory, diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index d5ce4def..e87c81a8 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -132,6 +132,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({ spawnLaunchState: spawnEntry?.launchState, spawnLivenessSource: spawnEntry?.livenessSource, spawnRuntimeAlive: spawnEntry?.runtimeAlive, + spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed, spawnBootstrapStalled: spawnEntry?.bootstrapStalled, runtimeEntry, runtimeAdvisory: member.runtimeAdvisory, diff --git a/src/renderer/components/team/teamRuntimeDisplayRows.ts b/src/renderer/components/team/teamRuntimeDisplayRows.ts index 0e3d5eb4..60527737 100644 --- a/src/renderer/components/team/teamRuntimeDisplayRows.ts +++ b/src/renderer/components/team/teamRuntimeDisplayRows.ts @@ -232,11 +232,14 @@ function buildSpawnBackedDisplayRow( }; } - if (spawn.status === 'online' && hasConfirmedSpawnLiveness(spawn)) { + if ( + (spawn.status === 'online' && hasConfirmedSpawnLiveness(spawn)) || + isConfirmedSpawnLaunch(spawn) + ) { return { memberName, state: 'running', - stateReason: spawn.runtimeDiagnostic ?? 'Spawn status is online', + stateReason: spawn.runtimeDiagnostic ?? 'Bootstrap confirmed', source: 'spawn-status', updatedAt: spawn.livenessLastCheckedAt ?? spawn.lastHeartbeatAt ?? spawn.updatedAt, runtimeModel: spawn.runtimeModel, @@ -299,6 +302,10 @@ function hasConfirmedSpawnLiveness(spawn: MemberSpawnStatusEntry): boolean { ); } +function isConfirmedSpawnLaunch(spawn: MemberSpawnStatusEntry): boolean { + return spawn.launchState === 'confirmed_alive' && spawn.bootstrapConfirmed === true; +} + function formatRuntimePidLabel(runtime: TeamAgentRuntimeEntry): string | undefined { const runtimePid = getFinitePid(runtime.runtimePid); if (runtimePid != null) return `runtime pid ${runtimePid}`; diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index af2d9a1d..690b4725 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -746,6 +746,7 @@ export function buildMemberLaunchPresentation({ spawnLaunchState, spawnLivenessSource, spawnRuntimeAlive, + spawnBootstrapConfirmed, spawnBootstrapStalled, runtimeAdvisory, runtimeEntry, @@ -759,6 +760,7 @@ export function buildMemberLaunchPresentation({ spawnLaunchState: MemberLaunchState | undefined; spawnLivenessSource: MemberSpawnLivenessSource | undefined; spawnRuntimeAlive: boolean | undefined; + spawnBootstrapConfirmed?: boolean; spawnBootstrapStalled?: boolean; runtimeAdvisory: MemberRuntimeAdvisory | undefined; runtimeEntry?: TeamAgentRuntimeEntry; @@ -767,12 +769,19 @@ export function buildMemberLaunchPresentation({ isTeamProvisioning?: boolean; leadActivity?: LeadActivityState; }): MemberLaunchPresentation { + const hasConfirmedSpawnLaunch = + spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true; + const effectiveSpawnStatus = + hasConfirmedSpawnLaunch && (spawnStatus === 'waiting' || spawnStatus === 'spawning') + ? 'online' + : spawnStatus; + const effectiveSpawnRuntimeAlive = hasConfirmedSpawnLaunch ? true : spawnRuntimeAlive; const presenceLabel = getLaunchAwarePresenceLabel( member, - spawnStatus, + effectiveSpawnStatus, spawnLaunchState, spawnLivenessSource, - spawnRuntimeAlive, + effectiveSpawnRuntimeAlive, runtimeAdvisory, isLaunchSettling, isTeamAlive, @@ -781,18 +790,18 @@ export function buildMemberLaunchPresentation({ ); const baseDotClass = getSpawnAwareDotClass( member, - spawnStatus, + effectiveSpawnStatus, spawnLaunchState, - spawnRuntimeAlive, + effectiveSpawnRuntimeAlive, isLaunchSettling, isTeamAlive, isTeamProvisioning, leadActivity ); const cardClass = getSpawnCardClass( - spawnStatus, + effectiveSpawnStatus, spawnLaunchState, - spawnRuntimeAlive, + effectiveSpawnRuntimeAlive, isLaunchSettling, isTeamAlive, isTeamProvisioning @@ -812,18 +821,23 @@ export function buildMemberLaunchPresentation({ launchVisualState = 'permission_pending'; } else if (spawnBootstrapStalled === true) { launchVisualState = 'bootstrap_stalled'; - } else if (runtimeEntry?.livenessKind === 'shell_only') { + } else if (!hasConfirmedSpawnLaunch && runtimeEntry?.livenessKind === 'shell_only') { launchVisualState = 'shell_only'; - } else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') { + } else if ( + !hasConfirmedSpawnLaunch && + runtimeEntry?.livenessKind === 'runtime_process_candidate' + ) { launchVisualState = 'runtime_candidate'; - } else if (runtimeEntry?.livenessKind === 'registered_only') { + } else if (!hasConfirmedSpawnLaunch && runtimeEntry?.livenessKind === 'registered_only') { launchVisualState = 'registered_only'; } else if ( - runtimeEntry?.livenessKind === 'stale_metadata' || - runtimeEntry?.livenessKind === 'not_found' + !hasConfirmedSpawnLaunch && + (runtimeEntry?.livenessKind === 'stale_metadata' || + runtimeEntry?.livenessKind === 'not_found') ) { launchVisualState = 'stale_runtime'; } else if ( + !hasConfirmedSpawnLaunch && isQueuedOpenCodeLaunch( member, spawnStatus, @@ -835,6 +849,7 @@ export function buildMemberLaunchPresentation({ ) { launchVisualState = 'queued'; } else if ( + !hasConfirmedSpawnLaunch && isLaunchStillStarting( spawnStatus, spawnLaunchState, @@ -844,16 +859,13 @@ export function buildMemberLaunchPresentation({ ) { launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting'; } else if ( + !hasConfirmedSpawnLaunch && spawnLaunchState === 'runtime_pending_bootstrap' && (runtimeEntry?.livenessKind === 'runtime_process' || (spawnStatus === 'online' && spawnRuntimeAlive === true)) ) { launchVisualState = 'runtime_pending'; - } else if ( - isLaunchSettling && - spawnStatus === 'online' && - spawnLaunchState === 'confirmed_alive' - ) { + } else if (isLaunchSettling && spawnLaunchState === 'confirmed_alive') { launchVisualState = 'settling'; } } diff --git a/src/renderer/utils/memberRuntimeSummary.ts b/src/renderer/utils/memberRuntimeSummary.ts index 66c0b6cd..f5f763aa 100644 --- a/src/renderer/utils/memberRuntimeSummary.ts +++ b/src/renderer/utils/memberRuntimeSummary.ts @@ -11,6 +11,35 @@ import type { TeamProviderId, } from '@shared/types'; +function shouldShowRuntimeMemory( + spawnEntry: MemberSpawnStatusEntry | undefined, + runtimeEntry: TeamAgentRuntimeEntry | undefined +): boolean { + if (typeof runtimeEntry?.rssBytes !== 'number' || runtimeEntry.rssBytes <= 0) { + return false; + } + + if ( + spawnEntry?.status === 'offline' || + spawnEntry?.status === 'skipped' || + spawnEntry?.launchState === 'skipped_for_launch' + ) { + return false; + } + + if (!spawnEntry) { + return runtimeEntry.alive === true; + } + + return ( + runtimeEntry.alive === true || + spawnEntry.runtimeAlive === true || + spawnEntry.bootstrapConfirmed === true || + spawnEntry.livenessSource === 'process' || + spawnEntry.livenessSource === 'heartbeat' + ); +} + function normalizeMemberBackendLabel( providerId: TeamProviderId, backendLabel: string | undefined @@ -101,10 +130,9 @@ export function resolveMemberRuntimeSummary( configuredProvider, formatTeamProviderBackendLabel(configuredProvider, configuredProviderBackendId) ); - const memorySuffix = - typeof runtimeEntry?.rssBytes === 'number' && runtimeEntry.rssBytes > 0 - ? ` · ${formatBytes(runtimeEntry.rssBytes)}` - : ''; + const memorySuffix = shouldShowRuntimeMemory(spawnEntry, runtimeEntry) + ? ` · ${formatBytes(runtimeEntry!.rssBytes!)}` + : ''; if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) { const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider; diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index f011669b..d8a159b7 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -14,6 +14,7 @@ import type { SendMessageResult, TeamViewSnapshot, TeamCreateRequest, + TeamProviderId, TeamProvisioningProgress, } from '@shared/types/team'; @@ -217,7 +218,16 @@ describe('ipc teams handlers', () => { getLeadMemberName: vi.fn(async () => 'team-lead'), getTeamDisplayName: vi.fn(async () => 'My Team'), updateConfig: vi.fn(async () => ({ name: 'My Team' })), - sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })), + sendMessage: vi.fn( + async (_teamName: string, _request: unknown) => ({ deliveredToInbox: true, messageId: 'm1' }) + ) as ReturnType< + typeof vi.fn< + ( + teamName: string, + request: unknown + ) => Promise<{ deliveredToInbox: boolean; messageId: string }> + > + >, sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })), createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })), requestReview: vi.fn(async () => undefined), @@ -269,6 +279,14 @@ describe('ipc teams handlers', () => { pushLiveLeadProcessMessage: vi.fn(), relayLeadInboxMessages: vi.fn(async () => 0), relayMemberInboxMessages: vi.fn(async () => 0), + resolveRuntimeRecipientProviderId: vi.fn( + async (_teamName: string, _memberName: string): Promise => + undefined + ) as ReturnType< + typeof vi.fn< + (teamName: string, memberName: string) => Promise + > + >, isOpenCodeRuntimeRecipient: vi.fn(async () => false), relayOpenCodeMemberInboxMessages: vi.fn(async () => ({ relayed: 0, @@ -348,6 +366,8 @@ describe('ipc teams handlers', () => { mockTeamDataWorkerClient.findLogsForTask.mockReset(); mockTeamDataWorkerClient.invalidateTeamConfig.mockReset(); mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset(); + provisioningService.resolveRuntimeRecipientProviderId.mockReset(); + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValue(undefined); launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 }); initializeTeamHandlers( service as never, @@ -645,8 +665,63 @@ describe('ipc teams handlers', () => { expect(result.success).toBe(false); }); + it('uses Agent Teams MCP reply instructions for Codex user direct messages', async () => { + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('codex'); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const result = (await sendHandler!({} as never, 'my-team', { + member: 'jack', + from: ' User ', + text: 'Здесь?', + })) as { success: boolean }; + + expect(result.success).toBe(true); + const request = service.sendMessage.mock.calls.at(-1)?.[1] as + | { from?: string; text?: string; messageId?: string } + | undefined; + expect(request).toBeDefined(); + expect(request?.from).toBe('user'); + expect(request?.messageId).toEqual(expect.any(String)); + expect(request?.text).toContain('agent-teams_message_send'); + expect(request?.text).toContain('mcp__agent-teams__message_send'); + expect(request?.text).toContain('teamName="my-team"'); + expect(request?.text).toContain('to="user"'); + expect(request?.text).toContain('from="jack"'); + expect(request?.text).toContain('source="runtime_delivery"'); + expect(request?.text).toContain(`relayOfMessageId="${request?.messageId}"`); + expect(request?.text).toContain('before any visible-message tool attempt'); + expect(request?.text).not.toContain('tool call fails before sending'); + expect(request?.text).not.toContain('Reply using the SendMessage tool'); + }); + + it.each([ + ['anthropic' as const], + ['gemini' as const], + [undefined], + ])('keeps SendMessage reply instructions for %s user direct messages', async (providerId) => { + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce(providerId); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const result = (await sendHandler!({} as never, 'my-team', { + member: 'alice', + text: 'Здесь?', + })) as { success: boolean }; + + expect(result.success).toBe(true); + const request = service.sendMessage.mock.calls.at(-1)?.[1] as + | { text?: string; messageId?: string } + | undefined; + expect(request).toBeDefined(); + expect(request).not.toHaveProperty('messageId'); + expect(request?.text).toContain('Reply using the SendMessage tool'); + expect(request?.text).toContain('to="user"'); + expect(request?.text).not.toContain('agent-teams_message_send'); + }); + it('stores base text and returns runtimeDelivery success for OpenCode teammate sends', async () => { - provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true); + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({ relayed: 1, attempted: 1, @@ -699,7 +774,7 @@ describe('ipc teams handlers', () => { }); it('returns runtimeDelivery failure without hiding the persisted OpenCode message', async () => { - provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true); + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({ relayed: 0, attempted: 1, @@ -734,7 +809,7 @@ describe('ipc teams handlers', () => { }); it('returns runtimeDelivery acceptanceUnknown for OpenCode observe-pending timeout sends', async () => { - provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true); + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({ relayed: 0, attempted: 1, @@ -774,7 +849,7 @@ describe('ipc teams handlers', () => { it('maps OpenCode UI relay timeout to pending acceptance-unknown delivery', async () => { vi.useFakeTimers(); try { - provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true); + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce( new Promise(() => undefined) ); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 7a5ec26f..54bfbc4e 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -583,9 +583,214 @@ describe('TeamProvisioningService', () => { await expect(svc.warmup()).resolves.not.toThrow(); expect(spawnCli).toHaveBeenCalled(); }); + }); describe('team launch notifications', () => { + it('does not fire incomplete notification for pending-only teammates still joining', async () => { + const { NotificationManager } = + await import('@main/services/infrastructure/NotificationManager'); + const addTeamNotification = vi.fn(async (_payload: unknown) => undefined); + NotificationManager.setInstance({ addTeamNotification } as never); + + try { + const svc = new TeamProvisioningService(); + const run = { + runId: 'run-beacon-desk-15', + teamName: 'beacon-desk-15', + isLaunch: true, + request: { + cwd: tempClaudeRoot, + displayName: 'beacon-desk-15', + }, + expectedMembers: ['alice', 'bob', 'jack', 'tom'], + allEffectiveMembers: [ + { name: 'alice' }, + { name: 'bob' }, + { name: 'jack' }, + { name: 'tom' }, + ], + memberSpawnStatuses: new Map( + ['alice', 'bob', 'jack', 'tom'].map((name) => [ + name, + createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + }), + ]) + ), + }; + const pendingSnapshot = { + expectedMembers: ['alice', 'bob', 'jack', 'tom'], + members: Object.fromEntries( + ['alice', 'bob', 'jack', 'tom'].map((name) => [ + name, + { + name, + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-13T10:00:00.000Z', + }, + ]) + ), + summary: { + confirmedCount: 0, + pendingCount: 4, + failedCount: 0, + runtimeAlivePendingCount: 4, + }, + }; + + await (svc as any).fireTeamLaunchIncompleteNotification( + run, + [], + pendingSnapshot.summary, + pendingSnapshot + ); + } finally { + NotificationManager.resetInstance(); + } + + expect(addTeamNotification).not.toHaveBeenCalled(); + }); + + it('ignores stale failed summary without concrete failed member evidence', async () => { + const { NotificationManager } = + await import('@main/services/infrastructure/NotificationManager'); + const addTeamNotification = vi.fn(async (_payload: unknown) => undefined); + NotificationManager.setInstance({ addTeamNotification } as never); + + try { + const svc = new TeamProvisioningService(); + const run = { + runId: 'run-stale-summary', + teamName: 'stale-summary-team', + isLaunch: true, + request: { + cwd: tempClaudeRoot, + displayName: 'stale-summary-team', + }, + expectedMembers: ['alice'], + allEffectiveMembers: [{ name: 'alice' }], + memberSpawnStatuses: new Map([ + [ + 'alice', + createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + }), + ], + ]), + }; + const staleSnapshot = { + expectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-13T10:00:00.000Z', + }, + }, + summary: { + confirmedCount: 0, + pendingCount: 0, + failedCount: 1, + runtimeAlivePendingCount: 0, + }, + }; + + await (svc as any).fireTeamLaunchIncompleteNotification( + run, + [], + staleSnapshot.summary, + staleSnapshot + ); + } finally { + NotificationManager.resetInstance(); + } + + expect(addTeamNotification).not.toHaveBeenCalled(); + }); + + it('prefers live confirmed evidence over stale persisted failed member evidence', async () => { + const { NotificationManager } = + await import('@main/services/infrastructure/NotificationManager'); + const addTeamNotification = vi.fn(async (_payload: unknown) => undefined); + NotificationManager.setInstance({ addTeamNotification } as never); + + try { + const svc = new TeamProvisioningService(); + const run = { + runId: 'run-live-confirmed', + teamName: 'live-confirmed-team', + isLaunch: true, + request: { + cwd: tempClaudeRoot, + displayName: 'live-confirmed-team', + }, + expectedMembers: ['alice'], + allEffectiveMembers: [{ name: 'alice' }], + memberSpawnStatuses: new Map([ + [ + 'alice', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + ], + ]), + }; + const staleSnapshot = { + expectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'stale failure', + lastEvaluatedAt: '2026-04-13T10:00:00.000Z', + }, + }, + summary: { + confirmedCount: 0, + pendingCount: 0, + failedCount: 1, + runtimeAlivePendingCount: 0, + }, + }; + + await (svc as any).fireTeamLaunchIncompleteNotification( + run, + [], + staleSnapshot.summary, + staleSnapshot + ); + } finally { + NotificationManager.resetInstance(); + } + + expect(addTeamNotification).not.toHaveBeenCalled(); + }); + it('uses live member evidence instead of stale summary for incomplete launch copy', async () => { const { NotificationManager } = await import('@main/services/infrastructure/NotificationManager'); @@ -12752,6 +12957,7 @@ describe('TeamProvisioningService', () => { bootstrapConfirmed: false, hardFailure: false, hardFailureReason: undefined, + livenessKind: 'registered_only', diagnostics: [ 'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.', ], @@ -12767,6 +12973,7 @@ describe('TeamProvisioningService', () => { agentToolAccepted: true, bootstrapConfirmed: true, runtimeAlive: true, + livenessKind: 'confirmed_bootstrap', }); const persisted = JSON.parse( await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8') @@ -12776,6 +12983,7 @@ describe('TeamProvisioningService', () => { bootstrapConfirmed: true, runtimeAlive: true, runtimeSessionId: 'ses-tom', + livenessKind: 'confirmed_bootstrap', }); }); @@ -12884,6 +13092,74 @@ describe('TeamProvisioningService', () => { expect(persistedAfterMissingWrite.teamLaunchState).toBe('clean_success'); }); + it('normalizes stale confirmed OpenCode secondary liveness from committed bootstrap evidence', async () => { + const teamName = 'zz-opencode-committed-overlay-normalizes-liveness'; + const leadSessionId = 'lead-session'; + const laneId = 'secondary:opencode:tom'; + const runId = 'opencode-run-tom'; + + writeMembersMeta(teamName, [{ name: 'tom', providerId: 'opencode' }]); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId, + state: 'active', + }); + await writeCommittedOpenCodeSessionStore({ + teamName, + laneId, + runId, + sessions: [ + { + id: 'ses-tom', + teamName, + memberName: 'tom', + laneId, + runId, + observedAt: '2026-04-22T12:00:00.000Z', + source: 'runtime_bootstrap_checkin', + }, + ], + }); + writeLaunchState(teamName, leadSessionId, { + tom: { + providerId: 'opencode', + laneId, + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimeSessionId: 'ses-tom', + livenessKind: 'registered_only', + runtimeDiagnostic: 'OpenCode bootstrap evidence committed.', + diagnostics: ['opencode_bootstrap_evidence_committed'], + }, + }); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + runtimeAlive: true, + livenessKind: 'confirmed_bootstrap', + }); + const persisted = JSON.parse( + await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8') + ); + expect(persisted.members.tom).toMatchObject({ + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + runtimeAlive: true, + livenessKind: 'confirmed_bootstrap', + }); + }); + it('marks a live teammate bootstrap as confirmed from transcript even when runtime discovery is stale', async () => { allowConsoleLogs(); const teamName = 'zz-live-bootstrap-transcript-success-without-runtime'; diff --git a/test/renderer/components/team/teamRuntimeDisplayRows.test.ts b/test/renderer/components/team/teamRuntimeDisplayRows.test.ts index a5ac7463..186c52f9 100644 --- a/test/renderer/components/team/teamRuntimeDisplayRows.test.ts +++ b/test/renderer/components/team/teamRuntimeDisplayRows.test.ts @@ -92,6 +92,29 @@ describe('buildTeamRuntimeDisplayRows', () => { }); }); + it('treats confirmed spawn bootstrap as running even if stale status is still waiting', () => { + const rows = buildTeamRuntimeDisplayRows({ + members: [{ name: 'alice' }], + spawnStatuses: { + alice: createSpawnStatus({ + status: 'waiting', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + runtimeAlive: true, + livenessKind: 'registered_only', + }), + }, + }); + + expect(rows[0]).toMatchObject({ + memberName: 'alice', + state: 'running', + source: 'spawn-status', + stateReason: 'Bootstrap confirmed', + actionsAllowed: false, + }); + }); + it('maps a non-alive runtime with error diagnostics to degraded', () => { const rows = buildTeamRuntimeDisplayRows({ members: [{ name: 'alice' }], diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 5cb99d59..8cd3f956 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -385,6 +385,7 @@ describe('memberHelpers spawn-aware presence', () => { spawnLaunchState: 'confirmed_alive', spawnLivenessSource: 'process', spawnRuntimeAlive: true, + spawnBootstrapConfirmed: true, runtimeEntry: { memberName: 'alice', alive: false, @@ -399,10 +400,38 @@ describe('memberHelpers spawn-aware presence', () => { isTeamProvisioning: false, }) ).toMatchObject({ - presenceLabel: 'registered', - launchVisualState: 'registered_only', - launchStatusLabel: 'registered', - dotClass: expect.stringContaining('bg-zinc-400'), + presenceLabel: 'online', + launchVisualState: null, + launchStatusLabel: null, + dotClass: expect.stringContaining('bg-emerald-400'), + }); + + expect( + buildMemberLaunchPresentation({ + member, + spawnStatus: 'waiting', + spawnLaunchState: 'confirmed_alive', + spawnLivenessSource: 'process', + spawnRuntimeAlive: true, + spawnBootstrapConfirmed: true, + runtimeEntry: { + memberName: 'alice', + alive: false, + restartable: true, + livenessKind: 'registered_only', + runtimeDiagnostic: 'registered runtime metadata without live process', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + runtimeAdvisory: undefined, + isLaunchSettling: false, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ).toMatchObject({ + presenceLabel: 'online', + launchVisualState: null, + launchStatusLabel: null, + dotClass: expect.stringContaining('bg-emerald-400'), }); }); diff --git a/test/renderer/utils/memberRuntimeSummary.test.ts b/test/renderer/utils/memberRuntimeSummary.test.ts index 80e10ec1..0417c1ff 100644 --- a/test/renderer/utils/memberRuntimeSummary.test.ts +++ b/test/renderer/utils/memberRuntimeSummary.test.ts @@ -114,6 +114,30 @@ describe('resolveMemberRuntimeSummary', () => { ); }); + it('hides stale runtime memory when the spawn state is explicitly offline', () => { + const member = createMember({ model: 'gpt-5.4-mini' }); + const spawnEntry = createSpawnEntry({ + status: 'offline', + launchState: 'failed_to_start', + runtimeAlive: false, + bootstrapConfirmed: false, + }); + const runtimeEntry = { + memberName: 'alice', + alive: true, + restartable: false, + providerId: 'opencode', + pid: 333, + pidSource: 'opencode_bridge', + rssBytes: 97.3 * 1024 * 1024, + updatedAt: '2026-04-24T12:00:00.000Z', + }; + + expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry, runtimeEntry as never)).toBe( + '5.4 Mini · Medium · Codex' + ); + }); + it('keeps the persisted backend lane visible in the runtime summary', () => { const member = createMember({ model: 'gpt-5.4-mini' });