diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index a10ce3d2..0f50bce4 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -231,6 +231,7 @@ export interface OpenCodeDeliveryResponseObservation { export interface OpenCodeSendMessageCommandData { accepted: boolean; + runId?: string; sessionId?: string; memberName: string; runtimePid?: number; @@ -238,6 +239,9 @@ export interface OpenCodeSendMessageCommandData { runtimePromptMessageId?: string; responseObservation?: OpenCodeDeliveryResponseObservation; diagnostics: OpenCodeTeamBridgeDiagnostic[]; + idempotencyKey?: string; + manifestHighWatermark?: number | null; + runtimeStoreManifestHighWatermark?: number | null; } export interface OpenCodeCommandStatusCommandBody { diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index e1c97f8f..05d93827 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -235,20 +235,47 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { let activeRequestId = commandRequestId; let activeBody = body; let usedObservedFallback = false; - const executeSend = (nextBody: OpenCodeSendMessageCommandBody, requestId: string) => - this.bridge.execute( - 'opencode.sendMessage', - nextBody, - { + const executeSend = async ( + nextBody: OpenCodeSendMessageCommandBody, + requestId: string + ): Promise<{ + result: OpenCodeBridgeResult; + requestId: string; + }> => { + if (this.options.stateChangingCommands && nextBody.settlementMode === 'acceptance') { + const result = await this.options.stateChangingCommands.execute< + OpenCodeSendMessageCommandBody, + OpenCodeSendMessageCommandData + >({ + command: 'opencode.sendMessage', + teamName: nextBody.teamName, + laneId: nextBody.laneId, + runId: nextBody.runId ?? null, + capabilitySnapshotId: null, + behaviorFingerprint: null, + body: nextBody, cwd: nextBody.projectPath, timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS, - requestId, - } - ); + }); + return { result, requestId: result.requestId || requestId }; + } + + const result = await this.bridge.execute< + OpenCodeSendMessageCommandBody, + OpenCodeSendMessageCommandData + >('opencode.sendMessage', nextBody, { + cwd: nextBody.projectPath, + timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS, + requestId, + }); + return { result, requestId: result.requestId || requestId }; + }; let result: OpenCodeBridgeResult; try { - result = await executeSend(activeBody, activeRequestId); + const executed = await executeSend(activeBody, activeRequestId); + result = executed.result; + activeRequestId = executed.requestId; } catch (error) { if ( body.settlementMode !== 'acceptance' || @@ -262,7 +289,25 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { settlementMode: 'observed', }; usedObservedFallback = true; - result = await executeSend(activeBody, activeRequestId); + const executed = await executeSend(activeBody, activeRequestId); + result = executed.result; + activeRequestId = executed.requestId; + } + + if ( + !result.ok && + activeBody.settlementMode === 'acceptance' && + isOpenCodeAcceptanceContractMissingError(result.error.message) + ) { + activeRequestId = `${commandRequestId}-observed`; + activeBody = { + ...body, + settlementMode: 'observed', + }; + usedObservedFallback = true; + const executed = await executeSend(activeBody, activeRequestId); + result = executed.result; + activeRequestId = executed.requestId; } if (result.ok) { @@ -487,7 +532,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { type OpenCodeStateChangingTeamCommandName = Extract< OpenCodeBridgeCommandName, - 'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam' + 'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam' | 'opencode.sendMessage' >; function blockedLaunchData( diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index f328f48f..f8846956 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -15,6 +15,7 @@ import type { OpenCodeBridgeResult, OpenCodeBridgeSuccess, OpenCodeLaunchTeamCommandData, + OpenCodeSendMessageCommandData, } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract'; describe('OpenCodeReadinessBridge', () => { @@ -405,12 +406,11 @@ describe('OpenCodeReadinessBridge', () => { }); expect(executor.execute).toHaveBeenCalledTimes(2); - const sendOptions = executor.execute.mock.calls[0]?.[2] as { requestId?: string } | undefined; expect(executor.execute.mock.calls[1]).toEqual([ 'opencode.commandStatus', expect.objectContaining({ originalCommand: 'opencode.sendMessage', - originalRequestId: sendOptions?.requestId, + originalRequestId: 'req-1', deliveryAttemptId: 'ledger-1:1:payload', payloadHash: expect.any(String), }), @@ -560,6 +560,136 @@ describe('OpenCodeReadinessBridge', () => { expect(executor.execute).toHaveBeenCalledTimes(2); }); + it('routes send-message commands through the guarded command service when configured', async () => { + const executor = fakeExecutor( + bridgeFailure('internal_error', 'direct bridge must not run', []) + ); + const stateChangingExecute = vi.fn(); + const stateChangingCommands = { + async execute(input: { + command: OpenCodeBridgeCommandName; + body: TBody; + teamName: string; + laneId?: string | null; + runId: string | null; + }): Promise> { + stateChangingExecute(input); + return bridgeCommandSuccess({ + command: input.command, + requestId: 'guarded-send-req-1', + data: { + accepted: true, + memberName: 'bob', + sessionId: 'session-bob', + runtimePromptMessageId: 'msg_prompt_1', + diagnostics: [], + }, + }) as unknown as OpenCodeBridgeResult; + }, + }; + const bridge = new OpenCodeReadinessBridge(executor, { stateChangingCommands }); + + await expect( + bridge.sendOpenCodeTeamMessage({ + runId: 'run-1', + teamId: 'team-a', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + projectPath: '/repo', + memberName: 'bob', + text: 'hello', + messageId: 'message-1', + deliveryAttemptId: 'ledger-1:1:payload', + settlementMode: 'acceptance', + }) + ).resolves.toMatchObject({ + accepted: true, + memberName: 'bob', + sessionId: 'session-bob', + runtimePromptMessageId: 'msg_prompt_1', + }); + + expect(stateChangingExecute).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'opencode.sendMessage', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + runId: 'run-1', + cwd: '/repo', + body: expect.objectContaining({ + settlementMode: 'acceptance', + payloadHash: expect.any(String), + }), + }) + ); + expect(executor.execute).not.toHaveBeenCalled(); + }); + + it('falls back to observed send mode when guarded acceptance contract validation fails', async () => { + const executor = fakeExecutor( + bridgeCommandSuccess({ + command: 'opencode.sendMessage', + requestId: 'legacy-observed-send', + data: { + accepted: true, + memberName: 'bob', + sessionId: 'session-bob', + diagnostics: [], + }, + }) + ); + const stateChangingExecute = vi + .fn() + .mockResolvedValueOnce( + bridgeCommandFailure({ + command: 'opencode.sendMessage', + requestId: 'guarded-send-acceptance', + kind: 'internal_error', + message: + 'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 1.', + }) + ); + const stateChangingCommands = { + execute: stateChangingExecute, + }; + const bridge = new OpenCodeReadinessBridge(executor, { stateChangingCommands }); + + await expect( + bridge.sendOpenCodeTeamMessage({ + runId: 'run-1', + teamId: 'team-a', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + projectPath: '/repo', + memberName: 'bob', + text: 'hello', + messageId: 'message-1', + deliveryAttemptId: 'ledger-1:1:payload', + settlementMode: 'acceptance', + }) + ).resolves.toMatchObject({ + accepted: true, + diagnostics: [ + expect.objectContaining({ + code: 'opencode_accept_fast_capability_missing', + severity: 'warning', + }), + ], + }); + + expect(stateChangingExecute).toHaveBeenCalledTimes(1); + expect(stateChangingExecute.mock.calls[0]?.[0]?.body).toMatchObject({ + settlementMode: 'acceptance', + }); + expect(executor.execute).toHaveBeenCalledWith( + 'opencode.sendMessage', + expect.objectContaining({ settlementMode: 'observed' }), + expect.objectContaining({ + cwd: '/repo', + }) + ); + }); + it('routes state-changing launch commands through the guarded command service when configured', async () => { const executor = fakeExecutor( bridgeFailure('internal_error', 'direct bridge must not run', []) @@ -690,6 +820,28 @@ function bridgeFailure( }; } +function bridgeCommandFailure(input: { + command: OpenCodeBridgeCommandName; + requestId: string; + kind: OpenCodeBridgeFailureKind; + message: string; +}): OpenCodeBridgeResult { + return { + ok: false, + schemaVersion: 1, + requestId: input.requestId, + command: input.command, + completedAt: '2026-04-21T12:00:01.000Z', + durationMs: 1000, + error: { + kind: input.kind, + message: input.message, + retryable: false, + }, + diagnostics: [], + }; +} + function bridgeCommandSuccess(input: { command: OpenCodeBridgeCommandName; requestId: string;