fix(team): guard opencode acceptance sends
This commit is contained in:
parent
55e1c8f3c4
commit
9dc7858e22
3 changed files with 214 additions and 13 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue