fix(opencode): stabilize runtime message delivery

This commit is contained in:
777genius 2026-05-28 16:39:04 +03:00
parent c49d6c373e
commit 6b8ab10414
6 changed files with 570 additions and 27 deletions

View file

@ -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<TeamProvisioningService['relayOpenCodeMemberInboxMessages']>
>;
type OpenCodeMemberInboxDelivery = NonNullable<OpenCodeMemberInboxRelayResult['lastDelivery']>;
type VisibleDirectReplyProtocol = 'send_message' | 'agent_teams_message_send';
@ -320,6 +330,158 @@ async function withTimeoutValue<T>(
}
}
async function waitForOpenCodeRuntimeRelayForUi(input: {
provisioning: TeamProvisioningService;
teamName: string;
memberName: string;
messageId: string;
relayPromise: Promise<OpenCodeMemberInboxRelayResult>;
timeoutMs?: number;
}): Promise<OpenCodeMemberInboxRelayResult> {
let timer: ReturnType<typeof setTimeout> | 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',

View file

@ -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']),

View file

@ -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)))
);
}

View file

@ -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<NonNullable<SendMessageResult['runtimeDelivery']>>]>;
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<Awaited<
ReturnType<typeof provisioningService.relayOpenCodeMemberInboxMessages>
>>();
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<Awaited<
ReturnType<typeof provisioningService.relayOpenCodeMemberInboxMessages>
>>();
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();
}

View file

@ -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<T>(value: T): Promise<T> {
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', () => {

View file

@ -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';