diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 2904a707..fb49907b 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -573,6 +573,9 @@ export class TeamGraphAdapter { spawnRuntimeAlive: spawn?.runtimeAlive, spawnBootstrapConfirmed: spawn?.bootstrapConfirmed, spawnBootstrapStalled: spawn?.bootstrapStalled, + spawnAgentToolAccepted: spawn?.agentToolAccepted, + spawnHardFailure: spawn?.hardFailure, + spawnLivenessKind: spawn?.livenessKind, runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, isTeamAlive: data.isAlive, diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index 1d19d085..7f12b012 100644 --- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -355,6 +355,9 @@ const MemberPopoverContent = ({ spawnRuntimeAlive: spawnEntry?.runtimeAlive, spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed, spawnBootstrapStalled: spawnEntry?.bootstrapStalled, + spawnAgentToolAccepted: spawnEntry?.agentToolAccepted, + spawnHardFailure: spawnEntry?.hardFailure, + spawnLivenessKind: spawnEntry?.livenessKind, runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false, isTeamAlive: teamData?.isAlive, diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 3ab7a1f2..49c6fc87 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -665,6 +665,9 @@ export const MemberCard = memo(function MemberCard({ spawnRuntimeAlive, spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed, spawnBootstrapStalled: spawnEntry?.bootstrapStalled, + spawnAgentToolAccepted: spawnEntry?.agentToolAccepted, + spawnHardFailure: spawnEntry?.hardFailure, + spawnLivenessKind: spawnEntry?.livenessKind, spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt, spawnUpdatedAt: spawnEntry?.updatedAt, runtimeEntry, diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 0c807e86..f97ebf15 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -290,6 +290,9 @@ export const MemberDetailDialog = ({ spawnRuntimeAlive={spawnEntry?.runtimeAlive} spawnBootstrapConfirmed={spawnEntry?.bootstrapConfirmed} spawnBootstrapStalled={spawnEntry?.bootstrapStalled} + spawnAgentToolAccepted={spawnEntry?.agentToolAccepted} + spawnHardFailure={spawnEntry?.hardFailure} + spawnLivenessKind={spawnEntry?.livenessKind} spawnFirstSpawnAcceptedAt={spawnEntry?.firstSpawnAcceptedAt} spawnUpdatedAt={spawnEntry?.updatedAt} runtimeEntry={runtimeEntry} diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index ae7c91be..14624594 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -40,6 +40,9 @@ interface MemberDetailHeaderProps { spawnRuntimeAlive?: boolean; spawnBootstrapConfirmed?: boolean; spawnBootstrapStalled?: boolean; + spawnAgentToolAccepted?: boolean; + spawnHardFailure?: boolean; + spawnLivenessKind?: TeamAgentRuntimeEntry['livenessKind']; spawnFirstSpawnAcceptedAt?: string; spawnUpdatedAt?: string; isLaunchSettling?: boolean; @@ -60,6 +63,9 @@ export const MemberDetailHeader = ({ spawnRuntimeAlive, spawnBootstrapConfirmed, spawnBootstrapStalled, + spawnAgentToolAccepted, + spawnHardFailure, + spawnLivenessKind, spawnFirstSpawnAcceptedAt, spawnUpdatedAt, isLaunchSettling, @@ -89,6 +95,9 @@ export const MemberDetailHeader = ({ spawnRuntimeAlive, spawnBootstrapConfirmed, spawnBootstrapStalled, + spawnAgentToolAccepted, + spawnHardFailure, + spawnLivenessKind, spawnFirstSpawnAcceptedAt, spawnUpdatedAt, runtimeEntry, diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index 2ee79846..b3d56d11 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -159,6 +159,9 @@ export const MemberHoverCard = memo(function MemberHoverCard({ spawnRuntimeAlive: spawnEntry?.runtimeAlive, spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed, spawnBootstrapStalled: spawnEntry?.bootstrapStalled, + spawnAgentToolAccepted: spawnEntry?.agentToolAccepted, + spawnHardFailure: spawnEntry?.hardFailure, + spawnLivenessKind: spawnEntry?.livenessKind, spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt, spawnUpdatedAt: spawnEntry?.updatedAt, runtimeEntry, diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 19ccac27..0ff6b71c 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -6,6 +6,7 @@ import { LEAD_PARTICIPANT_AVATAR_URL, PARTICIPANT_AVATAR_URLS, } from './memberAvatarCatalog'; +import { isHealthyOpenCodeAppMcpConnectivityAdvisory } from './openCodeAdvisoryHealth'; import type { LeadActivityState, @@ -1170,6 +1171,9 @@ export function buildMemberLaunchPresentation({ spawnRuntimeAlive, spawnBootstrapConfirmed, spawnBootstrapStalled, + spawnAgentToolAccepted, + spawnHardFailure, + spawnLivenessKind, spawnFirstSpawnAcceptedAt, spawnUpdatedAt, runtimeAdvisory, @@ -1187,6 +1191,9 @@ export function buildMemberLaunchPresentation({ spawnRuntimeAlive: boolean | undefined; spawnBootstrapConfirmed?: boolean; spawnBootstrapStalled?: boolean; + spawnAgentToolAccepted?: boolean; + spawnHardFailure?: boolean; + spawnLivenessKind?: TeamAgentRuntimeEntry['livenessKind']; spawnFirstSpawnAcceptedAt?: string; spawnUpdatedAt?: string; runtimeAdvisory: MemberRuntimeAdvisory | undefined; @@ -1205,6 +1212,19 @@ export function buildMemberLaunchPresentation({ ); const hasConfirmedSpawnLaunch = spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true; + const suppressOpenCodeAppMcpAdvisory = isHealthyOpenCodeAppMcpConnectivityAdvisory({ + providerId: member.providerId, + runtimeAdvisory, + spawnStatus, + launchState: spawnLaunchState, + runtimeAlive: spawnRuntimeAlive, + bootstrapConfirmed: spawnBootstrapConfirmed, + agentToolAccepted: spawnAgentToolAccepted, + hardFailure: spawnHardFailure, + livenessKind: spawnLivenessKind ?? runtimeEntry?.livenessKind, + runtimeEntry, + }); + const displayRuntimeAdvisory = suppressOpenCodeAppMcpAdvisory ? undefined : runtimeAdvisory; const effectiveSpawnStatus = hasConfirmedSpawnLaunch && currentRuntimeOfflineVisualState == null && @@ -1223,7 +1243,7 @@ export function buildMemberLaunchPresentation({ spawnLaunchState, spawnLivenessSource, effectiveSpawnRuntimeAlive, - runtimeAdvisory, + displayRuntimeAdvisory, isLaunchSettling, isTeamAlive, isTeamProvisioning, @@ -1247,9 +1267,18 @@ export function buildMemberLaunchPresentation({ isTeamAlive, isTeamProvisioning ); - const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(runtimeAdvisory, member.providerId); - const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(runtimeAdvisory, member.providerId); - const runtimeAdvisoryTone = getMemberRuntimeAdvisoryTone(runtimeAdvisory, member.providerId); + const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel( + displayRuntimeAdvisory, + member.providerId + ); + const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle( + displayRuntimeAdvisory, + member.providerId + ); + const runtimeAdvisoryTone = getMemberRuntimeAdvisoryTone( + displayRuntimeAdvisory, + member.providerId + ); const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling; const startingIsStale = !hasConfirmedSpawnLaunch && diff --git a/src/renderer/utils/memberLaunchDiagnostics.ts b/src/renderer/utils/memberLaunchDiagnostics.ts index 6e67b501..b82e6dca 100644 --- a/src/renderer/utils/memberLaunchDiagnostics.ts +++ b/src/renderer/utils/memberLaunchDiagnostics.ts @@ -1,3 +1,5 @@ +import { isHealthyOpenCodeAppMcpConnectivityAdvisory } from './openCodeAdvisoryHealth'; + import type { MemberLaunchState, MemberRuntimeAdvisory, @@ -443,12 +445,31 @@ export function buildMemberLaunchDiagnosticsPayload(params: { const providerBackendId = runtimeEntry?.providerBackendId ?? params.member?.providerBackendId; const laneId = runtimeEntry?.laneId ?? params.member?.laneId; const laneKind = runtimeEntry?.laneKind ?? params.member?.laneKind; + const livenessKind = spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind; + const launchState = spawnEntry?.launchState ?? params.launchState; + const spawnStatus = spawnEntry?.status ?? params.spawnStatus; const runtimeAdvisoryTitle = boundedString(params.runtimeAdvisoryTitle); const runtimeAdvisoryLabel = boundedString(params.runtimeAdvisoryLabel ?? undefined); const runtimeAdvisoryMessage = boundedString(runtimeAdvisory?.message); - const runtimeAdvisoryCardError = isRuntimeAdvisoryCardError(runtimeAdvisory, providerId) - ? (runtimeAdvisoryTitle ?? runtimeAdvisoryLabel ?? runtimeAdvisoryMessage) - : undefined; + const suppressOpenCodeAppMcpAdvisory = isHealthyOpenCodeAppMcpConnectivityAdvisory({ + providerId, + runtimeAdvisory, + runtimeAdvisoryLabel, + runtimeAdvisoryTitle, + runtimeAdvisoryMessage, + spawnStatus, + launchState, + runtimeAlive: spawnEntry?.runtimeAlive, + bootstrapConfirmed: spawnEntry?.bootstrapConfirmed, + agentToolAccepted: spawnEntry?.agentToolAccepted, + hardFailure: spawnEntry?.hardFailure, + livenessKind, + runtimeEntry, + }); + const runtimeAdvisoryCardError = + !suppressOpenCodeAppMcpAdvisory && isRuntimeAdvisoryCardError(runtimeAdvisory, providerId) + ? (runtimeAdvisoryTitle ?? runtimeAdvisoryLabel ?? runtimeAdvisoryMessage) + : undefined; const runtimeDiagnosticSeverity = spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity; const spawnRuntimeDiagnosticCardError = isRuntimeDiagnosticCardError({ @@ -505,9 +526,6 @@ export function buildMemberLaunchDiagnosticsPayload(params: { const runId = boundedString(params.runId ?? undefined); const runtimeUpdatedAt = maybeString(runtimeEntry?.updatedAt); const spawnUpdatedAt = maybeString(spawnEntry?.updatedAt); - const livenessKind = spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind; - const launchState = spawnEntry?.launchState ?? params.launchState; - const spawnStatus = spawnEntry?.status ?? params.spawnStatus; const diagnosticHints = buildDiagnosticHints({ memberCardError, runtimeDiagnostic, diff --git a/src/renderer/utils/openCodeAdvisoryHealth.ts b/src/renderer/utils/openCodeAdvisoryHealth.ts new file mode 100644 index 00000000..3d3fd3c2 --- /dev/null +++ b/src/renderer/utils/openCodeAdvisoryHealth.ts @@ -0,0 +1,70 @@ +import type { + MemberLaunchState, + MemberRuntimeAdvisory, + MemberSpawnStatus, + TeamAgentRuntimeEntry, + TeamAgentRuntimeLivenessKind, +} from '@shared/types'; + +const OPENCODE_APP_MCP_CONNECTIVITY_NEEDLES = [ + 'attach_failed', + 'readiness check failed', + 'unable to connect', +] as const; + +const OPENCODE_NON_HEALTHY_LIVENESS_KINDS = new Set([ + 'runtime_process_candidate', + 'permission_blocked', + 'shell_only', + 'registered_only', + 'stale_metadata', + 'not_found', +]); + +function hasOpenCodeAppMcpConnectivityEvidence(values: readonly (string | undefined)[]): boolean { + const text = values + .filter((value): value is string => Boolean(value?.trim())) + .join('\n') + .toLowerCase(); + return ( + text.includes('opencode app mcp') && + OPENCODE_APP_MCP_CONNECTIVITY_NEEDLES.some((needle) => text.includes(needle)) + ); +} + +export function isHealthyOpenCodeAppMcpConnectivityAdvisory(input: { + providerId?: string; + runtimeAdvisory?: MemberRuntimeAdvisory; + runtimeAdvisoryLabel?: string | null; + runtimeAdvisoryTitle?: string; + runtimeAdvisoryMessage?: string; + spawnStatus?: MemberSpawnStatus; + launchState?: MemberLaunchState; + runtimeAlive?: boolean; + bootstrapConfirmed?: boolean; + agentToolAccepted?: boolean; + hardFailure?: boolean; + livenessKind?: TeamAgentRuntimeLivenessKind; + runtimeEntry?: TeamAgentRuntimeEntry; +}): boolean { + const livenessKind = input.livenessKind ?? input.runtimeEntry?.livenessKind; + return ( + input.providerId === 'opencode' && + input.runtimeAdvisory?.kind === 'api_error' && + input.runtimeAdvisory.reasonCode === 'network_error' && + hasOpenCodeAppMcpConnectivityEvidence([ + input.runtimeAdvisoryTitle, + input.runtimeAdvisoryLabel ?? undefined, + input.runtimeAdvisoryMessage, + input.runtimeAdvisory.message, + ]) && + input.spawnStatus === 'online' && + input.launchState === 'confirmed_alive' && + input.runtimeAlive === true && + input.bootstrapConfirmed === true && + input.agentToolAccepted === true && + input.hardFailure !== true && + input.runtimeEntry?.alive !== false && + (livenessKind == null || !OPENCODE_NON_HEALTHY_LIVENESS_KINDS.has(livenessKind)) + ); +} diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index cec9fa57..9c559fa5 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -897,11 +897,7 @@ describe('memberHelpers spawn-aware presence', () => { }; expect( - getMemberRuntimeAdvisoryLabel( - advisory, - 'opencode', - Date.parse('2026-05-17T21:45:00.000Z') - ) + getMemberRuntimeAdvisoryLabel(advisory, 'opencode', Date.parse('2026-05-17T21:45:00.000Z')) ).toBe('OpenCode quota error ยท retry 2h 15m'); const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); @@ -923,9 +919,7 @@ describe('memberHelpers spawn-aware presence', () => { const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); - expect(title).toContain( - 'OpenCode delivery completed without required visible/progress proof.' - ); + expect(title).toContain('OpenCode delivery completed without required visible/progress proof.'); expect(title).toContain('OpenCode responded, but did not create a visible message_send reply.'); expect(title).not.toContain('visible_reply_still_required'); }); @@ -954,16 +948,12 @@ describe('memberHelpers spawn-aware presence', () => { message: 'opencode_prompt_acceptance_unknown_after_bridge_timeout', }; - expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe( - 'OpenCode delivery error' - ); + expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode delivery error'); const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); expect(title).toContain('OpenCode runtime delivery error.'); - expect(title).toContain( - 'OpenCode bridge outcome unknown after timeout, retrying/observing.' - ); + expect(title).toContain('OpenCode bridge outcome unknown after timeout, retrying/observing.'); expect(title).not.toContain('Network or connectivity error'); expect(title).not.toContain('opencode_prompt_acceptance_unknown_after_bridge_timeout'); }); @@ -1110,25 +1100,32 @@ describe('memberHelpers spawn-aware presence', () => { expect(title).toContain('permission_denied'); }); - it.each(['permission_denied', 'error', 'failed', 'failure', 'aborted', 'canceled', 'cancelled', 'interrupted', 'enospc'])( - 'does not let refresh pattern consume directly attached failure token _%s', - (suffix) => { - const message = `resolved_behavior_changed:old->new_${suffix}`; - const advisory = { - kind: 'api_error' as const, - observedAt: '2026-05-18T08:31:46.075Z', - reasonCode: 'backend_error' as const, - message, - }; + it.each([ + 'permission_denied', + 'error', + 'failed', + 'failure', + 'aborted', + 'canceled', + 'cancelled', + 'interrupted', + 'enospc', + ])('does not let refresh pattern consume directly attached failure token _%s', (suffix) => { + const message = `resolved_behavior_changed:old->new_${suffix}`; + const advisory = { + kind: 'api_error' as const, + observedAt: '2026-05-18T08:31:46.075Z', + reasonCode: 'backend_error' as const, + message, + }; - expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error'); - expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error'); + expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error'); + expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error'); - const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); - expect(title).toContain('OpenCode API error.'); - expect(title).toContain(message); - } - ); + const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); + expect(title).toContain('OpenCode API error.'); + expect(title).toContain(message); + }); it.each([ 'resolved_behavior_changed:old->new/auth_unavailable', @@ -1212,21 +1209,24 @@ describe('memberHelpers spawn-aware presence', () => { 'OpenCode session is stale (resolved_behavior_changed:old->new); Key limit exceeded (total limit)', 'OpenCode session is stale (resolved_behavior_changed:old->new); 429 too many requests', 'OpenCode session is stale (resolved_behavior_changed:old->new); Free usage exceeded, subscribe to Go', - ])('does not format stale refresh text with quota/rate failures as clean refresh: %s', (message) => { - const advisory = { - kind: 'api_error' as const, - observedAt: '2026-05-18T08:31:46.075Z', - reasonCode: 'backend_error' as const, - message, - }; + ])( + 'does not format stale refresh text with quota/rate failures as clean refresh: %s', + (message) => { + const advisory = { + kind: 'api_error' as const, + observedAt: '2026-05-18T08:31:46.075Z', + reasonCode: 'backend_error' as const, + message, + }; - expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error'); - expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error'); + expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode API error'); + expect(getMemberRuntimeAdvisoryTone(advisory, 'opencode')).toBe('error'); - const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); - expect(title).toContain('OpenCode API error.'); - expect(title).toContain(message); - }); + const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); + expect(title).toContain('OpenCode API error.'); + expect(title).toContain(message); + } + ); it('does not format stale refresh text with unknown extra text as clean refresh', () => { const message = @@ -1339,9 +1339,7 @@ describe('memberHelpers spawn-aware presence', () => { 'opencode' ); - expect(title).toContain( - 'OpenCode created a reply without the required taskRefs metadata.' - ); + expect(title).toContain('OpenCode created a reply without the required taskRefs metadata.'); expect(title).not.toContain('visible_reply_missing_task_refs'); }); @@ -1410,6 +1408,43 @@ describe('memberHelpers spawn-aware presence', () => { expect(presentation.dotClass).not.toContain('bg-red-400'); }); + it('keeps recovered OpenCode App MCP connectivity advisory out of the terminal presentation', () => { + const presentation = buildMemberLaunchPresentation({ + member: { ...member, providerId: 'opencode' }, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + spawnLivenessSource: 'heartbeat', + spawnRuntimeAlive: true, + spawnBootstrapConfirmed: true, + spawnAgentToolAccepted: true, + spawnHardFailure: false, + spawnLivenessKind: 'runtime_process', + runtimeEntry: { + memberName: 'alice', + providerId: 'opencode', + alive: true, + restartable: false, + livenessKind: 'runtime_process', + updatedAt: '2026-05-18T17:21:24.498Z', + }, + runtimeAdvisory: { + kind: 'api_error', + observedAt: '2026-05-18T17:20:36.681Z', + reasonCode: 'network_error', + message: + 'OpenCode app MCP was not connected before message delivery (status=attach_failed, connected=null). OpenCode app MCP readiness check failed: Unable to connect.', + }, + isLaunchSettling: false, + isTeamAlive: true, + isTeamProvisioning: false, + }); + + expect(presentation.presenceLabel).not.toContain('OpenCode API error'); + expect(presentation.runtimeAdvisoryLabel).toBeNull(); + expect(presentation.runtimeAdvisoryTone).toBeNull(); + expect(presentation.dotClass).not.toContain('bg-red-400'); + }); + it('falls back to the existing generic retry wording when no structured reason is present', () => { expect( getMemberRuntimeAdvisoryLabel( diff --git a/test/renderer/utils/memberLaunchDiagnostics.test.ts b/test/renderer/utils/memberLaunchDiagnostics.test.ts index fa4fb4db..32e57b57 100644 --- a/test/renderer/utils/memberLaunchDiagnostics.test.ts +++ b/test/renderer/utils/memberLaunchDiagnostics.test.ts @@ -220,13 +220,166 @@ describe('member launch diagnostics', () => { ); }); + it('does not surface recovered OpenCode App MCP connectivity advisory as card error', () => { + const appMcpMessage = + 'OpenCode app MCP was not connected before message delivery (status=attach_failed, connected=null). OpenCode app MCP readiness check failed: Unable to connect. Is the computer able to access the url?'; + const payload = buildMemberLaunchDiagnosticsPayload({ + memberName: 'bob', + member: { name: 'bob', providerId: 'opencode' }, + runtimeAdvisoryLabel: 'OpenCode API error', + runtimeAdvisoryTitle: `Network or connectivity error. ${appMcpMessage}`, + spawnEntry: { + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + agentToolAccepted: true, + hardFailure: false, + livenessKind: 'runtime_process', + runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation', + runtimeDiagnosticSeverity: 'info', + updatedAt: '2026-05-18T17:15:34.482Z', + }, + runtimeEntry: { + memberName: 'bob', + providerId: 'opencode', + alive: true, + restartable: false, + livenessKind: 'runtime_process', + updatedAt: '2026-05-18T17:21:24.498Z', + }, + runtimeAdvisory: { + kind: 'api_error', + observedAt: '2026-05-18T17:20:36.681Z', + reasonCode: 'network_error', + message: appMcpMessage, + }, + }); + + expect(payload.memberCardError).toBeUndefined(); + expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false); + expect(payload.runtimeAdvisoryReasonCode).toBe('network_error'); + expect(payload.diagnostics).toContain(appMcpMessage); + }); + + it('keeps OpenCode App MCP connectivity advisory as error when health is not clean', () => { + const appMcpMessage = + 'OpenCode app MCP was not connected before message delivery (status=attach_failed, connected=null). OpenCode app MCP readiness check failed: Unable to connect.'; + + for (const spawnEntry of [ + { + status: 'online' as const, + launchState: 'confirmed_alive' as const, + runtimeAlive: true, + bootstrapConfirmed: true, + agentToolAccepted: true, + hardFailure: true, + updatedAt: '2026-05-18T17:15:34.482Z', + }, + { + status: 'error' as const, + launchState: 'failed_to_start' as const, + runtimeAlive: true, + bootstrapConfirmed: true, + agentToolAccepted: true, + hardFailure: false, + updatedAt: '2026-05-18T17:15:34.482Z', + }, + ]) { + const payload = buildMemberLaunchDiagnosticsPayload({ + memberName: 'bob', + member: { name: 'bob', providerId: 'opencode' }, + runtimeAdvisoryLabel: 'OpenCode API error', + runtimeAdvisoryTitle: `Network or connectivity error. ${appMcpMessage}`, + spawnEntry, + runtimeAdvisory: { + kind: 'api_error', + observedAt: '2026-05-18T17:20:36.681Z', + reasonCode: 'network_error', + message: appMcpMessage, + }, + }); + + expect(payload.memberCardError).toBe(`Network or connectivity error. ${appMcpMessage}`); + expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true); + } + }); + + it.each([ + [ + 'quota_exhausted' as const, + 'OpenCode quota exhausted.', + 'Free usage exceeded, subscribe to Go', + ], + ['auth_error' as const, 'OpenCode authentication issue.', 'authentication_failed'], + ['rate_limited' as const, 'OpenCode rate limited the request.', '429 rate limited'], + ])( + 'keeps OpenCode %s advisory as card error on healthy members', + (reasonCode, title, message) => { + const payload = buildMemberLaunchDiagnosticsPayload({ + memberName: 'bob', + member: { name: 'bob', providerId: 'opencode' }, + runtimeAdvisoryLabel: 'OpenCode API error', + runtimeAdvisoryTitle: title, + spawnEntry: { + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + agentToolAccepted: true, + hardFailure: false, + livenessKind: 'runtime_process', + updatedAt: '2026-05-18T17:15:34.482Z', + }, + runtimeAdvisory: { + kind: 'api_error', + observedAt: '2026-05-18T17:20:36.681Z', + reasonCode, + message, + }, + }); + + expect(payload.memberCardError).toBe(title); + expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true); + } + ); + + it('does not suppress non-OpenCode App MCP connectivity advisory', () => { + const appMcpMessage = + 'OpenCode app MCP was not connected before message delivery (status=attach_failed, connected=null). OpenCode app MCP readiness check failed: Unable to connect.'; + const payload = buildMemberLaunchDiagnosticsPayload({ + memberName: 'claude', + member: { name: 'claude', providerId: 'anthropic' }, + runtimeAdvisoryLabel: 'Anthropic API error', + runtimeAdvisoryTitle: `Network or connectivity error. ${appMcpMessage}`, + spawnEntry: { + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + agentToolAccepted: true, + hardFailure: false, + livenessKind: 'runtime_process', + updatedAt: '2026-05-18T17:15:34.482Z', + }, + runtimeAdvisory: { + kind: 'api_error', + observedAt: '2026-05-18T17:20:36.681Z', + reasonCode: 'network_error', + message: appMcpMessage, + }, + }); + + expect(payload.memberCardError).toBe(`Network or connectivity error. ${appMcpMessage}`); + expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true); + }); + it('does not surface recoverable OpenCode session refresh advisory as card error', () => { const payload = buildMemberLaunchDiagnosticsPayload({ memberName: 'tom', member: { name: 'tom', providerId: 'opencode' }, runtimeAdvisoryLabel: 'OpenCode session refresh', - runtimeAdvisoryTitle: - 'OpenCode session changed; refreshing the session before retry.', + runtimeAdvisoryTitle: 'OpenCode session changed; refreshing the session before retry.', spawnEntry: { status: 'online', launchState: 'confirmed_alive', @@ -322,7 +475,8 @@ describe('member launch diagnostics', () => { kind: 'api_error', observedAt: '2026-05-18T08:31:46.075Z', reasonCode: 'backend_error', - message: 'OpenCode API error. opencode_prompt_delivery_session_refresh_scheduled permission denied', + message: + 'OpenCode API error. opencode_prompt_delivery_session_refresh_scheduled permission denied', }, }); @@ -341,8 +495,7 @@ describe('member launch diagnostics', () => { hardFailure: true, error: 'OpenCode API error. resolved_behavior_changed:permission_blocked->pending', hardFailureReason: 'OpenCode API error', - runtimeDiagnostic: - 'resolved_behavior_changed:responded_non_visible_tool->pending', + runtimeDiagnostic: 'resolved_behavior_changed:responded_non_visible_tool->pending', runtimeDiagnosticSeverity: 'error', updatedAt: '2026-05-18T08:13:23.902Z', }, @@ -351,8 +504,7 @@ describe('member launch diagnostics', () => { providerId: 'opencode', alive: true, restartable: false, - runtimeDiagnostic: - 'resolved_behavior_changed:responded_non_visible_tool->pending', + runtimeDiagnostic: 'resolved_behavior_changed:responded_non_visible_tool->pending', runtimeDiagnosticSeverity: 'error', diagnostics: ['resolved_behavior_changed:tool_error->session_error'], updatedAt: '2026-05-18T08:34:47.845Z', @@ -402,8 +554,7 @@ describe('member launch diagnostics', () => { providerId: 'opencode', alive: true, restartable: false, - runtimeDiagnostic: - 'opencode_session_refresh_scheduled_after_resolved_behavior_changed', + runtimeDiagnostic: 'opencode_session_refresh_scheduled_after_resolved_behavior_changed', runtimeDiagnosticSeverity: 'error', diagnostics: ['resolved_behavior_changed:old->new'], updatedAt: '2026-05-18T08:34:47.845Z', @@ -454,9 +605,7 @@ describe('member launch diagnostics', () => { }, }); - expect(payload.memberCardError).toBe( - 'OpenCode API errorresolved_behavior_changed:old->new' - ); + expect(payload.memberCardError).toBe('OpenCode API errorresolved_behavior_changed:old->new'); expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true); }); @@ -469,8 +618,7 @@ describe('member launch diagnostics', () => { launchState: 'failed_to_start', hardFailure: true, error: 'OpenCode API error. resolved_behavior_changed:old->new', - hardFailureReason: - 'opencode_session_refresh_scheduled_after_resolved_behavior_changed', + hardFailureReason: 'opencode_session_refresh_scheduled_after_resolved_behavior_changed', runtimeDiagnostic: 'opencode_app_mcp_transport_changed:old->new', runtimeDiagnosticSeverity: 'error', updatedAt: '2026-05-18T08:13:23.902Z', @@ -478,9 +626,7 @@ describe('member launch diagnostics', () => { }); expect(payload.memberCardError).toBeUndefined(); - expect(payload.diagnostics).toContain( - 'OpenCode API error. resolved_behavior_changed:old->new' - ); + expect(payload.diagnostics).toContain('OpenCode API error. resolved_behavior_changed:old->new'); expect(payload.diagnosticHints).toBeUndefined(); expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false); }); @@ -510,9 +656,7 @@ describe('member launch diagnostics', () => { expect(payload.memberCardError).toBeUndefined(); expect(payload.diagnostics).toContain('resolved_behavior_changed:old->new'); - expect(payload.memberCardError).not.toBe( - 'matched OpenCode runtime pid and process identity' - ); + expect(payload.memberCardError).not.toBe('matched OpenCode runtime pid and process identity'); }); it('uses suppressed spawn runtime diagnostics as refresh evidence for generic OpenCode API errors', () => { @@ -679,7 +823,17 @@ describe('member launch diagnostics', () => { expect(hasMemberLaunchDiagnosticsError(payload)).toBe(true); }); - it.each(['permission_denied', 'error', 'failed', 'failure', 'aborted', 'canceled', 'cancelled', 'interrupted', 'enospc'])( + it.each([ + 'permission_denied', + 'error', + 'failed', + 'failure', + 'aborted', + 'canceled', + 'cancelled', + 'interrupted', + 'enospc', + ])( 'keeps card error when refresh marker directly consumes failure-looking suffix _%s', (suffix) => { const error = `OpenCode API error. resolved_behavior_changed:old->new_${suffix}`;