fix(team): guard opencode acceptance sends

This commit is contained in:
777genius 2026-05-14 06:00:58 +03:00
parent 55e1c8f3c4
commit 9dc7858e22
3 changed files with 214 additions and 13 deletions

View file

@ -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 {

View file

@ -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<OpenCodeSendMessageCommandBody, OpenCodeSendMessageCommandData>(
'opencode.sendMessage',
nextBody,
{
const executeSend = async (
nextBody: OpenCodeSendMessageCommandBody,
requestId: string
): Promise<{
result: OpenCodeBridgeResult<OpenCodeSendMessageCommandData>;
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<OpenCodeSendMessageCommandData>;
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(

View file

@ -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<TBody, TData>(input: {
command: OpenCodeBridgeCommandName;
body: TBody;
teamName: string;
laneId?: string | null;
runId: string | null;
}): Promise<OpenCodeBridgeResult<TData>> {
stateChangingExecute(input);
return bridgeCommandSuccess<OpenCodeSendMessageCommandData>({
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<TData>;
},
};
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<OpenCodeSendMessageCommandData>({
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<unknown> {
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<TData>(input: {
command: OpenCodeBridgeCommandName;
requestId: string;