diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 64898634..fc32c7f8 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -242,7 +242,17 @@ import type { import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser'; const logger = createLogger('IPC:teams'); -const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000; +// Runtime relay continues in the background after this race; keep sendMessage IPC off the +// 25s OpenCode turn-settled guard while still giving prompt acceptance/reconcile time. +const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 6_000; +const OPENCODE_RUNTIME_DELIVERY_STATUS_AFTER_UI_TIMEOUT_MS = 1_000; +const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON = + 'opencode_runtime_delivery_ui_timeout_pending'; + +type OpenCodeMemberInboxRelayResult = Awaited< + ReturnType +>; +type OpenCodeMemberInboxDelivery = NonNullable; type VisibleDirectReplyProtocol = 'send_message' | 'agent_teams_message_send'; @@ -320,6 +330,158 @@ async function withTimeoutValue( } } +async function waitForOpenCodeRuntimeRelayForUi(input: { + provisioning: TeamProvisioningService; + teamName: string; + memberName: string; + messageId: string; + relayPromise: Promise; + timeoutMs?: number; +}): Promise { + let timer: ReturnType | null = null; + let timedOut = false; + + void input.relayPromise.then( + (relay) => { + if (!timedOut) return; + const delivery = relay.lastDelivery; + if (delivery && !delivery.delivered && delivery.reason !== 'recipient_is_not_opencode') { + logger.warn( + `OpenCode runtime delivery after sendMessage completed after UI timeout for teammate "${input.memberName}" with failure: ${ + delivery.reason ?? 'unknown error' + }` + ); + } + }, + (error: unknown) => { + if (!timedOut) return; + logger.warn( + `OpenCode runtime delivery after sendMessage rejected after UI timeout for teammate "${input.memberName}": ${getErrorMessage(error)}` + ); + } + ); + + try { + const outcome = await Promise.race< + { kind: 'relay'; relay: OpenCodeMemberInboxRelayResult } | { kind: 'timeout' } + >([ + input.relayPromise.then((relay) => ({ kind: 'relay' as const, relay })), + new Promise<{ kind: 'timeout' }>((resolve) => { + timer = setTimeout(() => { + timedOut = true; + resolve({ kind: 'timeout' }); + }, input.timeoutMs ?? OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS); + timer.unref?.(); + }), + ]); + + if (outcome.kind === 'relay') { + return outcome.relay; + } + + try { + const status = await withTimeoutValue( + input.provisioning.getOpenCodeRuntimeDeliveryStatus(input.teamName, input.messageId), + OPENCODE_RUNTIME_DELIVERY_STATUS_AFTER_UI_TIMEOUT_MS, + null + ); + if (status) { + return openCodeRuntimeDeliveryStatusToRelayResult(status); + } + } catch (error) { + const reason = getErrorMessage(error); + logger.warn( + `OpenCode runtime delivery status after UI timeout failed for teammate "${input.memberName}": ${reason}` + ); + return buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult([ + `${OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON}: status lookup failed: ${reason}`, + ]); + } + + return buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult(); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +function openCodeRuntimeDeliveryStatusToRelayResult( + status: OpenCodeRuntimeDeliveryStatus +): OpenCodeMemberInboxRelayResult { + const lastDelivery: OpenCodeMemberInboxDelivery = { + delivered: status.delivered, + ...(typeof status.responsePending === 'boolean' + ? { responsePending: status.responsePending } + : {}), + ...(typeof status.acceptanceUnknown === 'boolean' + ? { acceptanceUnknown: status.acceptanceUnknown } + : {}), + ...(status.responseState ? { responseState: status.responseState } : {}), + ...(status.ledgerStatus ? { ledgerStatus: status.ledgerStatus } : {}), + ...(status.visibleReplyMessageId + ? { visibleReplyMessageId: status.visibleReplyMessageId } + : {}), + ...(status.visibleReplyCorrelation + ? { visibleReplyCorrelation: status.visibleReplyCorrelation } + : {}), + ...(status.queuedBehindMessageId + ? { queuedBehindMessageId: status.queuedBehindMessageId } + : {}), + ...(status.reason ? { reason: status.reason } : {}), + ...(status.diagnostics ? { diagnostics: status.diagnostics } : {}), + ...(shouldPreserveOpenCodeRuntimeDeliveryStatusImpact(status) + ? { userVisibleImpact: status.userVisibleImpact } + : {}), + }; + return { + relayed: 0, + attempted: 1, + delivered: status.delivered && status.responsePending !== true ? 1 : 0, + failed: status.delivered ? 0 : 1, + lastDelivery, + diagnostics: status.diagnostics, + }; +} + +function shouldPreserveOpenCodeRuntimeDeliveryStatusImpact( + status: OpenCodeRuntimeDeliveryStatus +): boolean { + if (!status.userVisibleImpact) { + return false; + } + if ( + status.userVisibleImpact.state === 'none' && + (status.responsePending === true || + status.acceptanceUnknown === true || + Boolean(status.queuedBehindMessageId)) + ) { + return false; + } + return true; +} + +function buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult( + extraDiagnostics: string[] = [] +): OpenCodeMemberInboxRelayResult { + const diagnostics = [OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON, ...extraDiagnostics]; + return { + relayed: 0, + attempted: 1, + delivered: 0, + failed: 1, + lastDelivery: { + delivered: true, + accepted: false, + responsePending: true, + acceptanceUnknown: true, + responseState: 'not_observed', + reason: OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON, + diagnostics, + }, + }; +} + function noteHeavyTeamDataWorkerFallback(operation: string): void { if (!app.isPackaged) { return; @@ -3077,8 +3239,12 @@ async function handleSendMessage( // } if (isOpenCodeRecipient) { try { - const relay = await withTimeoutValue( - provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, { + const relay = await waitForOpenCodeRuntimeRelayForUi({ + provisioning, + teamName: tn, + memberName, + messageId: result.messageId, + relayPromise: provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, { onlyMessageId: result.messageId, source: 'ui-send', deliveryMetadata: { @@ -3087,23 +3253,7 @@ async function handleSendMessage( taskRefs: validatedTaskRefs.value, }, }), - OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS, - { - relayed: 0, - attempted: 1, - delivered: 0, - failed: 1, - lastDelivery: { - delivered: true, - accepted: false, - responsePending: true, - acceptanceUnknown: true, - responseState: 'not_observed', - reason: 'opencode_runtime_delivery_ui_timeout_pending', - diagnostics: ['opencode_runtime_delivery_ui_timeout_pending'], - }, - } - ); + }); const delivery = relay.lastDelivery ?? { delivered: relay.relayed > 0, reason: relay.relayed > 0 ? undefined : 'opencode_message_delivery_not_attempted', diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 045d3392..4dae1a17 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -8540,14 +8540,23 @@ export class TeamProvisioningService { } if (active && active.inboxMessageId !== messageId) { const activeDueMs = active.nextAttemptAt ? Date.parse(active.nextAttemptAt) : NaN; + const activeRetryDelayMs = Number.isFinite(activeDueMs) + ? Math.max(500, activeDueMs - Date.now()) + : OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS; this.scheduleOpenCodePromptDeliveryWatchdog({ teamName, memberName: canonicalMemberName, messageId: active.inboxMessageId, - delayMs: Number.isFinite(activeDueMs) - ? Math.max(500, activeDueMs - Date.now()) - : OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS, + delayMs: activeRetryDelayMs, }); + if (messageId) { + this.scheduleOpenCodeMemberInboxDeliveryWake({ + teamName, + memberName: canonicalMemberName, + messageId, + delayMs: activeRetryDelayMs + 500, + }); + } return { delivered: true, accepted: false, @@ -22892,6 +22901,18 @@ export class TeamProvisioningService { break; } if (delivery.responsePending) { + if ( + typeof delivery.queuedBehindMessageId === 'string' && + delivery.queuedBehindMessageId.trim() && + delivery.queuedBehindMessageId !== message.messageId + ) { + this.scheduleOpenCodeMemberInboxDeliveryWake({ + teamName, + memberName, + messageId: message.messageId, + delayMs: OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS, + }); + } result.diagnostics = [ ...(result.diagnostics ?? []), ...(delivery.diagnostics ?? [delivery.reason ?? 'opencode_delivery_response_pending']), diff --git a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts index 70ff30b4..c5b03107 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts @@ -48,6 +48,11 @@ const MANAGED_ENV_IDENTITY_MARKERS = [ 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=', 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=', ] as const; +const MANAGED_INLINE_OPENCODE_CONFIG_PATTERNS = [ + /OPENCODE_CONFIG_CONTENT=[\s\S]*"mcp"\s*:\s*\{[\s\S]*"agent-teams(?:-runtime-\d+)?"/i, + /OPENCODE_CONFIG_CONTENT=[\s\S]*"claude-multimodel runtime orchestration"/i, + /OPENCODE_CONFIG_CONTENT=[\s\S]*"(?:agent-teams|agent_teams|mcp__agent-teams|mcp__agent_teams)_\*"/i, +] as const; export async function cleanupManagedOpenCodeServeProcesses( options: OpenCodeManagedHostProcessCleanupOptions @@ -202,7 +207,8 @@ export function isAppManagedWindowsOpenCodeServeCommand(command: string): boolea export function isManagedOpenCodeServeProcessDetails(details: string): boolean { return ( processDetailsIncludeMarkers(details, MANAGED_ENV_MARKERS) && - MANAGED_ENV_IDENTITY_MARKERS.some((marker) => processDetailsIncludeMarker(details, marker)) + (MANAGED_ENV_IDENTITY_MARKERS.some((marker) => processDetailsIncludeMarker(details, marker)) || + MANAGED_INLINE_OPENCODE_CONFIG_PATTERNS.some((pattern) => pattern.test(details))) ); } diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 3b39d005..4a43277a 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -12,6 +12,7 @@ import type { BoardTaskLogStreamResponse, InboxMessage, MessagesPage, + OpenCodeRuntimeDeliveryStatus, SendMessageResult, TeamCreateRequest, TeamLaunchRequest, @@ -318,6 +319,7 @@ describe('ipc teams handlers', () => { } | undefined, })), + getOpenCodeRuntimeDeliveryStatus: vi.fn(async () => null as OpenCodeRuntimeDeliveryStatus | null), buildOpenCodeRuntimeDeliveryUserVisibleImpact: vi.fn(() => ({ state: 'none' })), getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]), getCurrentLeadSessionId: vi.fn(() => null as string | null), @@ -868,6 +870,7 @@ describe('ipc teams handlers', () => { attempted: true, delivered: true, }); + expect(provisioningService.getOpenCodeRuntimeDeliveryStatus).not.toHaveBeenCalled(); }); it('returns runtimeDelivery failure without hiding the persisted OpenCode message', async () => { @@ -950,6 +953,7 @@ describe('ipc teams handlers', () => { provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce( new Promise(() => undefined) ); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce(null); const sendHandler = handlers.get(TEAM_SEND_MESSAGE); expect(sendHandler).toBeDefined(); @@ -958,7 +962,7 @@ describe('ipc teams handlers', () => { text: 'Ping bob', }) as Promise<{ success: boolean; data?: SendMessageResult }>; - await vi.advanceTimersByTimeAsync(12_000); + await vi.advanceTimersByTimeAsync(6_000); const result = await resultPromise; expect(result.success).toBe(true); @@ -971,6 +975,281 @@ describe('ipc teams handlers', () => { responseState: 'not_observed', reason: 'opencode_runtime_delivery_ui_timeout_pending', }); + expect(provisioningService.getOpenCodeRuntimeDeliveryStatus).toHaveBeenCalledWith( + 'my-team', + result.data?.messageId + ); + } finally { + vi.useRealTimers(); + } + }); + + it('uses durable OpenCode delivery status when UI relay timeout fires after prompt acceptance', async () => { + vi.useFakeTimers(); + try { + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); + provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce( + new Promise(() => undefined) + ); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce({ + messageId: 'msg-123', + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: true, + responseState: 'not_observed', + ledgerStatus: 'pending', + reason: 'opencode_delivery_response_pending', + diagnostics: ['prompt accepted'], + userVisibleImpact: { state: 'none' }, + }); + provisioningService.buildOpenCodeRuntimeDeliveryUserVisibleImpact.mockReturnValueOnce({ + state: 'checking', + }); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const resultPromise = sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Ping bob', + }) as Promise<{ success: boolean; data?: SendMessageResult }>; + + await vi.advanceTimersByTimeAsync(6_000); + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.data?.runtimeDelivery).toMatchObject({ + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: true, + responseState: 'not_observed', + ledgerStatus: 'pending', + reason: 'opencode_delivery_response_pending', + diagnostics: ['prompt accepted'], + userVisibleImpact: { state: 'checking' }, + }); + expect(result.data?.runtimeDelivery?.acceptanceUnknown).toBeUndefined(); + const impactCalls = provisioningService.buildOpenCodeRuntimeDeliveryUserVisibleImpact.mock + .calls as unknown as Array<[Partial>]>; + const impactInput = impactCalls.at(-1)?.[0]; + expect(impactInput).toMatchObject({ + delivered: true, + responsePending: true, + }); + expect(impactInput).not.toHaveProperty('userVisibleImpact'); + } finally { + vi.useRealTimers(); + } + }); + + it('preserves terminal durable OpenCode delivery status after UI relay timeout', async () => { + vi.useFakeTimers(); + try { + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); + provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce( + new Promise(() => undefined) + ); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce({ + messageId: 'msg-responded', + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: false, + responseState: 'responded_visible_message', + ledgerStatus: 'responded', + visibleReplyMessageId: 'reply-1', + visibleReplyCorrelation: 'relayOfMessageId', + acceptanceUnknown: false, + diagnostics: [], + userVisibleImpact: { state: 'none' }, + }); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const resultPromise = sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Ping bob', + }) as Promise<{ success: boolean; data?: SendMessageResult }>; + + await vi.advanceTimersByTimeAsync(6_000); + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.data?.runtimeDelivery).toMatchObject({ + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: false, + responseState: 'responded_visible_message', + ledgerStatus: 'responded', + visibleReplyMessageId: 'reply-1', + visibleReplyCorrelation: 'relayOfMessageId', + acceptanceUnknown: false, + userVisibleImpact: { state: 'none' }, + }); + expect(provisioningService.buildOpenCodeRuntimeDeliveryUserVisibleImpact).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not let a slow OpenCode delivery status lookup extend the UI timeout indefinitely', async () => { + vi.useFakeTimers(); + try { + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); + provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce( + new Promise(() => undefined) + ); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockReturnValueOnce( + new Promise(() => undefined) + ); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const resultPromise = sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Ping bob', + }) as Promise<{ success: boolean; data?: SendMessageResult }>; + + await vi.advanceTimersByTimeAsync(6_000); + await flushMicrotasks(); + let settled = false; + void resultPromise.then(() => { + settled = true; + }); + await flushMicrotasks(); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1_000); + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.data?.runtimeDelivery).toMatchObject({ + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: true, + acceptanceUnknown: true, + reason: 'opencode_runtime_delivery_ui_timeout_pending', + }); + } finally { + vi.useRealTimers(); + } + }); + + it('falls back to acceptance-unknown when OpenCode delivery status lookup rejects after UI timeout', async () => { + vi.useFakeTimers(); + try { + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); + provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce( + new Promise(() => undefined) + ); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockRejectedValueOnce( + new Error('status read failed') + ); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const resultPromise = sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Ping bob', + }) as Promise<{ success: boolean; data?: SendMessageResult }>; + + await vi.advanceTimersByTimeAsync(6_000); + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.data?.runtimeDelivery).toMatchObject({ + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: true, + acceptanceUnknown: true, + reason: 'opencode_runtime_delivery_ui_timeout_pending', + diagnostics: [ + 'opencode_runtime_delivery_ui_timeout_pending', + 'opencode_runtime_delivery_ui_timeout_pending: status lookup failed: status read failed', + ], + }); + expect(vi.mocked(console.warn).mock.calls.some((call) => + call.join(' ').includes('status after UI timeout failed') + )).toBe(true); + vi.mocked(console.warn).mockClear(); + } finally { + vi.useRealTimers(); + } + }); + + it('logs OpenCode relay rejection that happens after the UI timeout fallback', async () => { + vi.useFakeTimers(); + try { + const deferredRelay = createDeferred + >>(); + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); + provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(deferredRelay.promise); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce(null); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const resultPromise = sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Ping bob', + }) as Promise<{ success: boolean; data?: SendMessageResult }>; + + await vi.advanceTimersByTimeAsync(6_000); + await expect(resultPromise).resolves.toMatchObject({ success: true }); + + deferredRelay.reject(new Error('late bridge failure')); + await flushMicrotasks(); + + expect(vi.mocked(console.warn).mock.calls.some((call) => + call.join(' ').includes('rejected after UI timeout') + )).toBe(true); + vi.mocked(console.warn).mockClear(); + } finally { + vi.useRealTimers(); + } + }); + + it('logs OpenCode relay failure result that resolves after the UI timeout fallback', async () => { + vi.useFakeTimers(); + try { + const deferredRelay = createDeferred + >>(); + provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode'); + provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(deferredRelay.promise); + provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce(null); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const resultPromise = sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Ping bob', + }) as Promise<{ success: boolean; data?: SendMessageResult }>; + + await vi.advanceTimersByTimeAsync(6_000); + await expect(resultPromise).resolves.toMatchObject({ success: true }); + + deferredRelay.resolve({ + relayed: 0, + attempted: 1, + delivered: 0, + failed: 1, + lastDelivery: { + delivered: false, + reason: 'late_runtime_failure', + diagnostics: ['late_runtime_failure'], + }, + }); + await flushMicrotasks(); + + expect(vi.mocked(console.warn).mock.calls.some((call) => + call.join(' ').includes('completed after UI timeout') + )).toBe(true); + vi.mocked(console.warn).mockClear(); } finally { vi.useRealTimers(); } diff --git a/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts b/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts index 803d33cf..61e860c0 100644 --- a/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts +++ b/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts @@ -1,5 +1,3 @@ -import { describe, expect, it, vi } from 'vitest'; - import { cleanupManagedOpenCodeServeProcesses, getOpenCodeServeLoopbackBaseUrl, @@ -7,6 +5,7 @@ import { isManagedOpenCodeServeProcessDetails, isOpenCodeServeCommand, } from '@main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup'; +import { describe, expect, it, vi } from 'vitest'; const MANAGED_DETAILS = [ '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171', @@ -27,6 +26,16 @@ const MANAGED_DETAILS_WITH_WORKSPACE_MCP = [ 'OPENCODE_CONFIG_CONTENT={}', 'AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude', ].join(' '); +const MANAGED_DETAILS_WITH_INLINE_OPENCODE_CONFIG_MCP = [ + '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171', + 'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime', + 'OPENCODE_CONFIG_CONTENT={"mcp":{"agent-teams":{"type":"local","command":["node","mcp-server/dist/index.js"],"environment":{"AGENT_TEAMS_MCP_CLAUDE_DIR":"/tmp/claude"},"enabled":true}}}', +].join(' '); +const MANAGED_DETAILS_WITH_INLINE_OPENCODE_AGENT_PERMISSIONS = [ + '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171', + 'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime', + 'OPENCODE_CONFIG_CONTENT={"agent":{"teammate":{"description":"Managed teammate agent for claude-multimodel runtime orchestration.","permission":{"agent-teams_*":"allow","mcp__agent-teams__*":"allow"}}}}', +].join(' '); function resolved(value: T): Promise { return Promise.resolve(value); @@ -63,6 +72,12 @@ describe('OpenCodeManagedHostProcessCleanup', () => { expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS)).toBe(true); expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_REMOTE_MCP)).toBe(true); expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_WORKSPACE_MCP)).toBe(true); + expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_INLINE_OPENCODE_CONFIG_MCP)).toBe( + true + ); + expect( + isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_INLINE_OPENCODE_AGENT_PERMISSIONS) + ).toBe(true); expect( isManagedOpenCodeServeProcessDetails( 'opencode serve CLAUDE_MULTIMODEL_DATA_HOME=/tmp OPENCODE_CONFIG_CONTENT={}' @@ -78,6 +93,16 @@ describe('OpenCodeManagedHostProcessCleanup', () => { 'opencode serve NOT_CLAUDE_MULTIMODEL_DATA_HOME=/tmp OPENCODE_CONFIG_CONTENT={} AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude' ) ).toBe(false); + expect( + isManagedOpenCodeServeProcessDetails( + 'opencode serve OPENCODE_CONFIG_CONTENT={"mcp":{"agent-teams":{"enabled":true}}}' + ) + ).toBe(false); + expect( + isManagedOpenCodeServeProcessDetails( + 'opencode serve OPENCODE_CONFIG_CONTENT={"agent":{"teammate":{"permission":{"agent-teams_*":"allow"}}}}' + ) + ).toBe(false); }); it('extracts only loopback OpenCode serve base URLs for disposal', () => { diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 288bc228..3588f7e1 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -3721,6 +3721,68 @@ Messages: } }); + it('reschedules a queued OpenCode inbox row when delivery is blocked behind an active prompt', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/mock/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + seedMemberInbox(teamName, 'jack', [ + { + from: 'user', + to: 'jack', + text: 'Queued UI message.', + timestamp: '2026-02-23T17:00:01.000Z', + read: false, + messageId: 'opencode-queued-current', + }, + ]); + const wakeSpy = vi + .spyOn(service, 'scheduleOpenCodeMemberInboxDeliveryWake') + .mockImplementation(() => undefined); + vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockResolvedValue({ + delivered: true, + accepted: false, + responsePending: true, + queuedBehindMessageId: 'opencode-active-blocker', + reason: 'opencode_delivery_response_pending', + diagnostics: ['OpenCode delivery is queued behind opencode-active-blocker.'], + }); + + const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack', { + onlyMessageId: 'opencode-queued-current', + source: 'watchdog', + deliveryMetadata: { replyRecipient: 'user' }, + }); + + expect(relay).toMatchObject({ + attempted: 1, + delivered: 0, + failed: 0, + lastDelivery: { + delivered: true, + responsePending: true, + queuedBehindMessageId: 'opencode-active-blocker', + }, + }); + expect(wakeSpy).toHaveBeenCalledWith({ + teamName, + memberName: 'jack', + messageId: 'opencode-queued-current', + delayMs: 3_000, + }); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); + expect(rows[0]).toMatchObject({ messageId: 'opencode-queued-current', read: false }); + }); + it('treats an already-read specific OpenCode inbox row as delivered for UI-send relay', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team';