fix(team): harden opencode delivery prompt evidence
This commit is contained in:
parent
f055193b16
commit
55e1c8f3c4
16 changed files with 820 additions and 33 deletions
|
|
@ -142,6 +142,7 @@ export class TeamInboxReader {
|
|||
row.messageKind === 'slash_command' ||
|
||||
row.messageKind === 'slash_command_result' ||
|
||||
row.messageKind === 'task_comment_notification' ||
|
||||
row.messageKind === 'task_stall_remediation' ||
|
||||
row.messageKind === 'member_work_sync_nudge' ||
|
||||
row.messageKind === 'agent_error'
|
||||
? row.messageKind
|
||||
|
|
|
|||
|
|
@ -8363,7 +8363,10 @@ export class TeamProvisioningService {
|
|||
taskRefs: input.taskRefs,
|
||||
prePromptCursor: ledgerRecord.prePromptCursor,
|
||||
sessionId: ledgerRecord.runtimeSessionId ?? undefined,
|
||||
runtimePromptMessageId: ledgerRecord.runtimePromptMessageId ?? undefined,
|
||||
runtimePromptMessageId:
|
||||
ledgerRecord.lastRuntimePromptMessageId ??
|
||||
ledgerRecord.runtimePromptMessageId ??
|
||||
undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = `opencode_direct_user_delivery_inline_observe_failed: ${getErrorMessage(
|
||||
|
|
@ -8416,6 +8419,8 @@ export class TeamProvisioningService {
|
|||
latestAssistantPreview: null,
|
||||
reason: observed.diagnostics[0] ?? null,
|
||||
},
|
||||
sessionId: observed.sessionId,
|
||||
runtimePromptMessageId: observed.runtimePromptMessageId,
|
||||
diagnostics: [
|
||||
`opencode_direct_user_delivery_inline_observe_attempt_${inlineObserveAttempt}`,
|
||||
...(hadMessageSendToolError ? ['opencode_message_send_tool_error_inline_observe'] : []),
|
||||
|
|
@ -9815,7 +9820,10 @@ export class TeamProvisioningService {
|
|||
taskRefs: input.taskRefs,
|
||||
prePromptCursor: ledgerRecord.prePromptCursor,
|
||||
sessionId: ledgerRecord.runtimeSessionId ?? undefined,
|
||||
runtimePromptMessageId: ledgerRecord.runtimePromptMessageId ?? undefined,
|
||||
runtimePromptMessageId:
|
||||
ledgerRecord.lastRuntimePromptMessageId ??
|
||||
ledgerRecord.runtimePromptMessageId ??
|
||||
undefined,
|
||||
});
|
||||
await this.rememberOpenCodeRuntimePidFromBridge({
|
||||
teamName,
|
||||
|
|
@ -9842,6 +9850,8 @@ export class TeamProvisioningService {
|
|||
latestAssistantPreview: null,
|
||||
reason: observed.diagnostics[0] ?? null,
|
||||
},
|
||||
sessionId: observed.sessionId,
|
||||
runtimePromptMessageId: observed.runtimePromptMessageId,
|
||||
diagnostics: observed.diagnostics,
|
||||
observedAt: nowIso(),
|
||||
});
|
||||
|
|
@ -9982,8 +9992,17 @@ export class TeamProvisioningService {
|
|||
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
|
||||
result.responseObservation
|
||||
);
|
||||
const promptAccepted =
|
||||
result.ok || this.isOpenCodePromptAcceptedByObservation(responseObservation);
|
||||
const promptAcceptedByRuntimeIdentity = Boolean(
|
||||
result.ok && result.runtimePromptMessageId?.trim()
|
||||
);
|
||||
const promptAcceptedByObservation =
|
||||
this.isOpenCodePromptAcceptedByObservation(responseObservation);
|
||||
const promptAccepted = promptAcceptedByRuntimeIdentity || promptAcceptedByObservation;
|
||||
const promptAcceptanceMissingRuntimePromptId =
|
||||
result.ok && !promptAcceptedByRuntimeIdentity && !promptAcceptedByObservation;
|
||||
const deliveryDiagnostics = promptAcceptanceMissingRuntimePromptId
|
||||
? [...result.diagnostics, 'opencode_prompt_acceptance_missing_runtime_prompt_id']
|
||||
: result.diagnostics;
|
||||
if (ledgerRecord && ledger) {
|
||||
ledgerRecord = await ledger.applyDeliveryResult({
|
||||
id: ledgerRecord.id,
|
||||
|
|
@ -9992,9 +10011,10 @@ export class TeamProvisioningService {
|
|||
responseObservation,
|
||||
sessionId: result.sessionId,
|
||||
runtimePromptMessageId: result.runtimePromptMessageId,
|
||||
deliveryAttemptId,
|
||||
prePromptCursor: result.prePromptCursor,
|
||||
diagnostics: result.diagnostics,
|
||||
reason: promptAccepted ? responseObservation?.reason : result.diagnostics[0],
|
||||
diagnostics: deliveryDiagnostics,
|
||||
reason: promptAccepted ? responseObservation?.reason : deliveryDiagnostics[0],
|
||||
now: nowIso(),
|
||||
});
|
||||
this.emitOpenCodePromptDeliveryTaskLogChange(
|
||||
|
|
@ -10049,7 +10069,7 @@ export class TeamProvisioningService {
|
|||
ledgerRecord,
|
||||
{
|
||||
accepted: promptAccepted,
|
||||
reason: ledgerRecord.lastReason ?? result.diagnostics[0] ?? null,
|
||||
reason: ledgerRecord.lastReason ?? deliveryDiagnostics[0] ?? null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -10112,16 +10132,21 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
if (ledgerRecord && !promptAccepted) {
|
||||
const reason = this.isOpenCodePromptAcceptanceUnknownFailure(result.diagnostics)
|
||||
? 'opencode_prompt_acceptance_unknown_after_bridge_timeout'
|
||||
: (result.diagnostics[0] ?? 'opencode_message_delivery_failed');
|
||||
if (reason === 'opencode_prompt_acceptance_unknown_after_bridge_timeout') {
|
||||
const reason = promptAcceptanceMissingRuntimePromptId
|
||||
? 'opencode_prompt_acceptance_unknown_missing_runtime_prompt_id'
|
||||
: this.isOpenCodePromptAcceptanceUnknownFailure(deliveryDiagnostics)
|
||||
? 'opencode_prompt_acceptance_unknown_after_bridge_timeout'
|
||||
: (deliveryDiagnostics[0] ?? 'opencode_message_delivery_failed');
|
||||
if (
|
||||
reason === 'opencode_prompt_acceptance_unknown_after_bridge_timeout' ||
|
||||
reason === 'opencode_prompt_acceptance_unknown_missing_runtime_prompt_id'
|
||||
) {
|
||||
const delayMs = OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS;
|
||||
ledgerRecord = await ledger!.markAcceptanceUnknown({
|
||||
id: ledgerRecord.id,
|
||||
reason,
|
||||
nextAttemptAt: new Date(Date.now() + delayMs).toISOString(),
|
||||
diagnostics: result.diagnostics,
|
||||
diagnostics: deliveryDiagnostics,
|
||||
markedAt: nowIso(),
|
||||
});
|
||||
this.scheduleOpenCodePromptDeliveryWatchdog({
|
||||
|
|
|
|||
|
|
@ -438,6 +438,7 @@ export interface OpenCodeBridgePeerIdentity {
|
|||
supportedCommands: OpenCodeBridgeCommandName[];
|
||||
opencodeTaskLedgerEvidenceContractVersion?: number;
|
||||
opencodeAppManagedBootstrapContractVersion?: number;
|
||||
opencodeDeliveryAcceptanceContractVersion?: number;
|
||||
};
|
||||
runtime: {
|
||||
providerId: 'opencode';
|
||||
|
|
@ -618,6 +619,7 @@ export function validateOpenCodeBridgeHandshake(input: {
|
|||
expectedCapabilitySnapshotId: string | null;
|
||||
expectedManifestHighWatermark: number | null;
|
||||
expectedRunId: string | null;
|
||||
requiresDeliveryAcceptanceContract?: boolean;
|
||||
}): { ok: true } | { ok: false; reason: string } {
|
||||
const shape = validateOpenCodeBridgeHandshakeShape(input.handshake);
|
||||
if (!shape.ok) {
|
||||
|
|
@ -676,6 +678,19 @@ export function validateOpenCodeBridgeHandshake(input: {
|
|||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.requiredCommand === 'opencode.sendMessage' &&
|
||||
input.requiresDeliveryAcceptanceContract === true &&
|
||||
input.handshake.server.bridgeProtocol.opencodeDeliveryAcceptanceContractVersion !==
|
||||
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 1. Falling back to observed delivery mode is required.',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
input.expectedCapabilitySnapshotId &&
|
||||
input.handshake.server.runtime.capabilitySnapshotId !== input.expectedCapabilitySnapshotId
|
||||
|
|
@ -948,7 +963,10 @@ function isPeerIdentity(value: unknown): value is OpenCodeBridgePeerIdentity {
|
|||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion as number) < 1)) ||
|
||||
(bridgeProtocol.opencodeAppManagedBootstrapContractVersion !== undefined &&
|
||||
(!Number.isInteger(bridgeProtocol.opencodeAppManagedBootstrapContractVersion) ||
|
||||
(bridgeProtocol.opencodeAppManagedBootstrapContractVersion as number) < 1))
|
||||
(bridgeProtocol.opencodeAppManagedBootstrapContractVersion as number) < 1)) ||
|
||||
(bridgeProtocol.opencodeDeliveryAcceptanceContractVersion !== undefined &&
|
||||
(!Number.isInteger(bridgeProtocol.opencodeDeliveryAcceptanceContractVersion) ||
|
||||
(bridgeProtocol.opencodeDeliveryAcceptanceContractVersion as number) < 1))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
|
||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
} from './OpenCodeBridgeCommandContract';
|
||||
|
||||
|
|
@ -103,6 +104,7 @@ export function createOpenCodeBridgeClientIdentity(input: {
|
|||
],
|
||||
opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
opencodeAppManagedBootstrapContractVersion: OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
opencodeDeliveryAcceptanceContractVersion: OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
|
||||
},
|
||||
runtime: {
|
||||
providerId: 'opencode',
|
||||
|
|
|
|||
|
|
@ -232,29 +232,56 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
...input,
|
||||
payloadHash: input.payloadHash ?? buildSendPayloadHash(input),
|
||||
};
|
||||
const result = await this.bridge.execute<
|
||||
OpenCodeSendMessageCommandBody,
|
||||
OpenCodeSendMessageCommandData
|
||||
>('opencode.sendMessage', body, {
|
||||
cwd: body.projectPath,
|
||||
timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS,
|
||||
requestId: commandRequestId,
|
||||
});
|
||||
let activeRequestId = commandRequestId;
|
||||
let activeBody = body;
|
||||
let usedObservedFallback = false;
|
||||
const executeSend = (nextBody: OpenCodeSendMessageCommandBody, requestId: string) =>
|
||||
this.bridge.execute<OpenCodeSendMessageCommandBody, OpenCodeSendMessageCommandData>(
|
||||
'opencode.sendMessage',
|
||||
nextBody,
|
||||
{
|
||||
cwd: nextBody.projectPath,
|
||||
timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS,
|
||||
requestId,
|
||||
}
|
||||
);
|
||||
|
||||
let result: OpenCodeBridgeResult<OpenCodeSendMessageCommandData>;
|
||||
try {
|
||||
result = await executeSend(activeBody, activeRequestId);
|
||||
} catch (error) {
|
||||
if (
|
||||
body.settlementMode !== 'acceptance' ||
|
||||
!isOpenCodeAcceptanceContractMissingError(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
activeRequestId = `${commandRequestId}-observed`;
|
||||
activeBody = {
|
||||
...body,
|
||||
settlementMode: 'observed',
|
||||
};
|
||||
usedObservedFallback = true;
|
||||
result = await executeSend(activeBody, activeRequestId);
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
return result.data;
|
||||
return usedObservedFallback
|
||||
? withOpenCodeObservedFallbackDiagnostic(result.data)
|
||||
: result.data;
|
||||
}
|
||||
if (result.error.kind === 'timeout') {
|
||||
const recovered = await this.recoverTimedOutSendMessage({
|
||||
originalRequestId: commandRequestId,
|
||||
body,
|
||||
originalRequestId: activeRequestId,
|
||||
body: activeBody,
|
||||
});
|
||||
if (recovered) {
|
||||
return recovered;
|
||||
return usedObservedFallback ? withOpenCodeObservedFallbackDiagnostic(recovered) : recovered;
|
||||
}
|
||||
}
|
||||
return {
|
||||
accepted: false,
|
||||
memberName: body.memberName,
|
||||
memberName: activeBody.memberName,
|
||||
diagnostics: [
|
||||
{
|
||||
code: result.error.kind,
|
||||
|
|
@ -543,6 +570,28 @@ function formatDiagnosticEvent(event: OpenCodeBridgeDiagnosticEvent): string {
|
|||
return `${event.type}: ${event.message}`;
|
||||
}
|
||||
|
||||
function isOpenCodeAcceptanceContractMissingError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes('OpenCode delivery acceptance mode is required');
|
||||
}
|
||||
|
||||
function withOpenCodeObservedFallbackDiagnostic(
|
||||
data: OpenCodeSendMessageCommandData
|
||||
): OpenCodeSendMessageCommandData {
|
||||
return {
|
||||
...data,
|
||||
diagnostics: [
|
||||
{
|
||||
code: 'opencode_accept_fast_capability_missing',
|
||||
severity: 'warning',
|
||||
message:
|
||||
'OpenCode delivery acceptance capability was not advertised by the orchestrator; used observed delivery mode.',
|
||||
},
|
||||
...data.diagnostics,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function thrownBridgeFailure<TData>(
|
||||
command: OpenCodeBridgeCommandName,
|
||||
runId: string,
|
||||
|
|
|
|||
|
|
@ -118,6 +118,10 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
expectedCapabilitySnapshotId: input.capabilitySnapshotId,
|
||||
expectedManifestHighWatermark: manifest.highWatermark,
|
||||
expectedRunId: input.runId,
|
||||
requiresDeliveryAcceptanceContract: requiresOpenCodeDeliveryAcceptanceContract(
|
||||
input.command,
|
||||
input.body
|
||||
),
|
||||
});
|
||||
|
||||
if (!handshakeValidation.ok) {
|
||||
|
|
@ -299,6 +303,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requiresOpenCodeDeliveryAcceptanceContract(
|
||||
command: OpenCodeBridgeCommandName,
|
||||
body: unknown
|
||||
): boolean {
|
||||
if (command !== 'opencode.sendMessage' || !isRecord(body)) {
|
||||
return false;
|
||||
}
|
||||
return body.settlementMode === 'acceptance';
|
||||
}
|
||||
|
||||
function stringifyError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ export interface OpenCodePromptDeliveryLedgerRecord {
|
|||
runId: string | null;
|
||||
runtimeSessionId: string | null;
|
||||
runtimePromptMessageId?: string | null;
|
||||
runtimePromptMessageIds?: string[];
|
||||
lastRuntimePromptMessageId?: string | null;
|
||||
lastDeliveryAttemptIdWithAcceptedPrompt?: string | null;
|
||||
inboxMessageId: string;
|
||||
inboxTimestamp: string;
|
||||
source: 'watcher' | 'ui-send' | 'manual' | 'watchdog' | 'member-work-sync-review-pickup';
|
||||
|
|
@ -138,6 +141,7 @@ export interface ApplyOpenCodePromptDeliveryResultInput {
|
|||
responseObservation?: OpenCodeDeliveryResponseObservation;
|
||||
sessionId?: string | null;
|
||||
runtimePromptMessageId?: string | null;
|
||||
deliveryAttemptId?: string | null;
|
||||
runtimePid?: number;
|
||||
prePromptCursor?: string | null;
|
||||
diagnostics?: string[];
|
||||
|
|
@ -213,6 +217,9 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
runId: input.runId ?? null,
|
||||
runtimeSessionId: null,
|
||||
runtimePromptMessageId: null,
|
||||
runtimePromptMessageIds: [],
|
||||
lastRuntimePromptMessageId: null,
|
||||
lastDeliveryAttemptIdWithAcceptedPrompt: null,
|
||||
inboxMessageId: input.inboxMessageId,
|
||||
inboxTimestamp: input.inboxTimestamp,
|
||||
source: input.source,
|
||||
|
|
@ -305,6 +312,37 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
observation?.state ?? (input.accepted ? record.responseState : 'not_observed');
|
||||
const responded = isOpenCodePromptResponseStateResponded(responseState);
|
||||
const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(responseState);
|
||||
const acceptedRuntimePromptMessageId =
|
||||
input.accepted && input.runtimePromptMessageId?.trim()
|
||||
? input.runtimePromptMessageId.trim()
|
||||
: null;
|
||||
const previousRuntimePromptMessageIds = getOpenCodeRuntimePromptMessageIds(record);
|
||||
const runtimePromptMessageIds =
|
||||
acceptedRuntimePromptMessageId &&
|
||||
!previousRuntimePromptMessageIds.includes(acceptedRuntimePromptMessageId)
|
||||
? [...previousRuntimePromptMessageIds, acceptedRuntimePromptMessageId]
|
||||
: previousRuntimePromptMessageIds;
|
||||
const acceptedDeliveryAttemptId = input.deliveryAttemptId?.trim() || null;
|
||||
const acceptedAttemptAlreadyRecorded = Boolean(
|
||||
input.accepted &&
|
||||
acceptedDeliveryAttemptId &&
|
||||
record.lastDeliveryAttemptIdWithAcceptedPrompt === acceptedDeliveryAttemptId
|
||||
);
|
||||
const acceptedPromptAlreadyRecorded = Boolean(
|
||||
input.accepted &&
|
||||
acceptedRuntimePromptMessageId &&
|
||||
previousRuntimePromptMessageIds.includes(acceptedRuntimePromptMessageId)
|
||||
);
|
||||
const shouldIncrementAttempts =
|
||||
(input.accepted || input.attempted === true) &&
|
||||
!acceptedAttemptAlreadyRecorded &&
|
||||
!acceptedPromptAlreadyRecorded;
|
||||
const lastRuntimePromptMessageId =
|
||||
acceptedRuntimePromptMessageId ??
|
||||
record.lastRuntimePromptMessageId ??
|
||||
record.runtimePromptMessageId ??
|
||||
runtimePromptMessageIds[runtimePromptMessageIds.length - 1] ??
|
||||
null;
|
||||
return {
|
||||
...record,
|
||||
status: input.accepted
|
||||
|
|
@ -315,11 +353,15 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
: 'accepted'
|
||||
: 'failed_retryable',
|
||||
responseState,
|
||||
attempts:
|
||||
input.accepted || input.attempted === true ? record.attempts + 1 : record.attempts,
|
||||
attempts: shouldIncrementAttempts ? record.attempts + 1 : record.attempts,
|
||||
runtimeSessionId: input.sessionId ?? record.runtimeSessionId,
|
||||
runtimePromptMessageId:
|
||||
input.runtimePromptMessageId ?? record.runtimePromptMessageId ?? null,
|
||||
runtimePromptMessageId: lastRuntimePromptMessageId,
|
||||
runtimePromptMessageIds,
|
||||
lastRuntimePromptMessageId,
|
||||
lastDeliveryAttemptIdWithAcceptedPrompt:
|
||||
input.accepted && acceptedDeliveryAttemptId
|
||||
? acceptedDeliveryAttemptId
|
||||
: (record.lastDeliveryAttemptIdWithAcceptedPrompt ?? null),
|
||||
acceptanceUnknown: input.accepted ? false : record.acceptanceUnknown,
|
||||
lastAttemptAt: input.now,
|
||||
lastObservedAt: observation ? input.now : record.lastObservedAt,
|
||||
|
|
@ -348,6 +390,8 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
async applyObservation(input: {
|
||||
id: string;
|
||||
responseObservation: OpenCodeDeliveryResponseObservation;
|
||||
sessionId?: string | null;
|
||||
runtimePromptMessageId?: string | null;
|
||||
diagnostics?: string[];
|
||||
observedAt: string;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
|
|
@ -356,17 +400,48 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(
|
||||
input.responseObservation.state
|
||||
);
|
||||
const previousRuntimePromptMessageIds = getOpenCodeRuntimePromptMessageIds(record);
|
||||
const deliveredRuntimePromptMessageId =
|
||||
input.responseObservation.deliveredUserMessageId?.trim() || null;
|
||||
const requestedRuntimePromptMessageId = input.runtimePromptMessageId?.trim() || null;
|
||||
const requestedRuntimePromptMessageIdIsKnown = Boolean(
|
||||
requestedRuntimePromptMessageId &&
|
||||
previousRuntimePromptMessageIds.includes(requestedRuntimePromptMessageId)
|
||||
);
|
||||
const observedRuntimePromptMessageId =
|
||||
deliveredRuntimePromptMessageId ||
|
||||
(requestedRuntimePromptMessageIdIsKnown ? requestedRuntimePromptMessageId : null);
|
||||
const runtimePromptMessageIds =
|
||||
observedRuntimePromptMessageId &&
|
||||
!previousRuntimePromptMessageIds.includes(observedRuntimePromptMessageId)
|
||||
? [...previousRuntimePromptMessageIds, observedRuntimePromptMessageId]
|
||||
: previousRuntimePromptMessageIds;
|
||||
const promptAcceptedByObservation = Boolean(deliveredRuntimePromptMessageId);
|
||||
const lastRuntimePromptMessageId =
|
||||
observedRuntimePromptMessageId ??
|
||||
record.lastRuntimePromptMessageId ??
|
||||
record.runtimePromptMessageId ??
|
||||
runtimePromptMessageIds[runtimePromptMessageIds.length - 1] ??
|
||||
null;
|
||||
return {
|
||||
...record,
|
||||
status: responded
|
||||
? 'responded'
|
||||
: unanswered
|
||||
? 'unanswered'
|
||||
: record.status === 'pending'
|
||||
: record.status === 'pending' || promptAcceptedByObservation
|
||||
? 'accepted'
|
||||
: record.status,
|
||||
responseState: input.responseObservation.state,
|
||||
runtimeSessionId: input.sessionId ?? record.runtimeSessionId,
|
||||
runtimePromptMessageId: lastRuntimePromptMessageId,
|
||||
runtimePromptMessageIds,
|
||||
lastRuntimePromptMessageId,
|
||||
acceptanceUnknown: promptAcceptedByObservation ? false : record.acceptanceUnknown,
|
||||
lastObservedAt: input.observedAt,
|
||||
acceptedAt: promptAcceptedByObservation
|
||||
? (record.acceptedAt ?? input.observedAt)
|
||||
: record.acceptedAt,
|
||||
respondedAt: responded ? (record.respondedAt ?? input.observedAt) : record.respondedAt,
|
||||
deliveredUserMessageId:
|
||||
input.responseObservation.deliveredUserMessageId ?? record.deliveredUserMessageId,
|
||||
|
|
@ -660,6 +735,41 @@ export function hashOpenCodePromptDeliveryPayload(input: {
|
|||
})}`;
|
||||
}
|
||||
|
||||
export function getOpenCodeRuntimePromptMessageIds(
|
||||
record: Pick<
|
||||
OpenCodePromptDeliveryLedgerRecord,
|
||||
'runtimePromptMessageId' | 'runtimePromptMessageIds' | 'lastRuntimePromptMessageId'
|
||||
>
|
||||
): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const value of [
|
||||
...(Array.isArray(record.runtimePromptMessageIds) ? record.runtimePromptMessageIds : []),
|
||||
record.runtimePromptMessageId,
|
||||
record.lastRuntimePromptMessageId,
|
||||
]) {
|
||||
const id = typeof value === 'string' ? value.trim() : '';
|
||||
if (id && !ids.includes(id)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function getLatestOpenCodeRuntimePromptMessageId(
|
||||
record: Pick<
|
||||
OpenCodePromptDeliveryLedgerRecord,
|
||||
'runtimePromptMessageId' | 'runtimePromptMessageIds' | 'lastRuntimePromptMessageId'
|
||||
>
|
||||
): string | null {
|
||||
const explicit =
|
||||
record.lastRuntimePromptMessageId?.trim() || record.runtimePromptMessageId?.trim();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
const ids = getOpenCodeRuntimePromptMessageIds(record);
|
||||
return ids[ids.length - 1] ?? null;
|
||||
}
|
||||
|
||||
export function isOpenCodePromptResponseStateResponded(
|
||||
state: OpenCodeDeliveryResponseState
|
||||
): boolean {
|
||||
|
|
@ -720,6 +830,9 @@ function isOpenCodePromptDeliveryLedgerRecord(
|
|||
isOptionalNullableString(record.runId) &&
|
||||
isOptionalNullableString(record.runtimeSessionId) &&
|
||||
isOptionalNullableString(record.runtimePromptMessageId) &&
|
||||
isOptionalStringArray(record.runtimePromptMessageIds) &&
|
||||
isOptionalNullableString(record.lastRuntimePromptMessageId) &&
|
||||
isOptionalNullableString(record.lastDeliveryAttemptIdWithAcceptedPrompt) &&
|
||||
typeof record.inboxMessageId === 'string' &&
|
||||
typeof record.inboxTimestamp === 'string' &&
|
||||
isOpenCodePromptDeliverySource(record.source) &&
|
||||
|
|
@ -812,6 +925,7 @@ function isOptionalNullableInboxMessageKind(
|
|||
value === 'slash_command' ||
|
||||
value === 'slash_command_result' ||
|
||||
value === 'task_comment_notification' ||
|
||||
value === 'task_stall_remediation' ||
|
||||
value === 'member_work_sync_nudge' ||
|
||||
value === 'agent_error'
|
||||
);
|
||||
|
|
@ -825,6 +939,10 @@ function isStringArray(value: unknown): value is string[] {
|
|||
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
||||
}
|
||||
|
||||
function isOptionalStringArray(value: unknown): value is string[] | undefined {
|
||||
return value === undefined || isStringArray(value);
|
||||
}
|
||||
|
||||
function isNonNegativeInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import * as path from 'node:path';
|
|||
|
||||
import {
|
||||
createOpenCodePromptDeliveryLedgerStore,
|
||||
getLatestOpenCodeRuntimePromptMessageId,
|
||||
type OpenCodePromptDeliveryLedgerRecord,
|
||||
} from '../../opencode/delivery/OpenCodePromptDeliveryLedger';
|
||||
import {
|
||||
|
|
@ -158,7 +159,10 @@ function toAttributionRecord(
|
|||
])
|
||||
: undefined;
|
||||
const until = addMsToIso(terminalUntil ?? fallbackUntil, TERMINAL_EVIDENCE_GRACE_MS);
|
||||
const startMessageUuid = record.deliveredUserMessageId?.trim() || undefined;
|
||||
const startMessageUuid =
|
||||
record.deliveredUserMessageId?.trim() ||
|
||||
getLatestOpenCodeRuntimePromptMessageId(record) ||
|
||||
undefined;
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
|
|
@ -241,7 +245,9 @@ export class TaskLogOpenCodeSessionEvidenceSource implements OpenCodeTaskLogSess
|
|||
record.memberName.trim().toLowerCase(),
|
||||
record.laneId.trim(),
|
||||
sessionId,
|
||||
record.deliveredUserMessageId ?? record.inboxMessageId,
|
||||
record.deliveredUserMessageId ??
|
||||
getLatestOpenCodeRuntimePromptMessageId(record) ??
|
||||
record.inboxMessageId,
|
||||
].join('::');
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
createOpenCodeBridgeIdempotencyKey,
|
||||
isOpenCodeBridgeCommandName,
|
||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
|
||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
parseSingleBridgeJsonResult,
|
||||
stableHash,
|
||||
|
|
@ -240,6 +241,66 @@ describe('OpenCodeBridgeCommandContract', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('requires the delivery acceptance contract only for acceptance-mode sendMessage', () => {
|
||||
const client = peerIdentity('claude_team');
|
||||
const server = peerIdentity('agent_teams_orchestrator');
|
||||
client.bridgeProtocol.supportedCommands.push('opencode.sendMessage');
|
||||
server.bridgeProtocol.supportedCommands.push('opencode.sendMessage');
|
||||
let handshake = withAcceptedCommands(buildHandshake({ client, server }), [
|
||||
'opencode.launchTeam',
|
||||
'opencode.stopTeam',
|
||||
'opencode.sendMessage',
|
||||
]);
|
||||
|
||||
expect(
|
||||
validateOpenCodeBridgeHandshake({
|
||||
handshake,
|
||||
expectedClient: client,
|
||||
requiredCommand: 'opencode.sendMessage',
|
||||
expectedCapabilitySnapshotId: null,
|
||||
expectedManifestHighWatermark: 10,
|
||||
expectedRunId: 'run-1',
|
||||
requiresDeliveryAcceptanceContract: false,
|
||||
})
|
||||
).toEqual({ ok: true });
|
||||
|
||||
expect(
|
||||
validateOpenCodeBridgeHandshake({
|
||||
handshake,
|
||||
expectedClient: client,
|
||||
requiredCommand: 'opencode.sendMessage',
|
||||
expectedCapabilitySnapshotId: null,
|
||||
expectedManifestHighWatermark: 10,
|
||||
expectedRunId: 'run-1',
|
||||
requiresDeliveryAcceptanceContract: true,
|
||||
})
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason:
|
||||
'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 1. Falling back to observed delivery mode is required.',
|
||||
});
|
||||
|
||||
server.bridgeProtocol.opencodeDeliveryAcceptanceContractVersion =
|
||||
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION;
|
||||
handshake = withAcceptedCommands(buildHandshake({ client, server }), [
|
||||
'opencode.launchTeam',
|
||||
'opencode.stopTeam',
|
||||
'opencode.sendMessage',
|
||||
]);
|
||||
|
||||
expect(
|
||||
validateOpenCodeBridgeHandshake({
|
||||
handshake,
|
||||
expectedClient: client,
|
||||
requiredCommand: 'opencode.sendMessage',
|
||||
expectedCapabilitySnapshotId: null,
|
||||
expectedManifestHighWatermark: 10,
|
||||
expectedRunId: 'run-1',
|
||||
requiresDeliveryAcceptanceContract: true,
|
||||
})
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('creates deterministic idempotency keys for equivalent JSON bodies', () => {
|
||||
const first = createOpenCodeBridgeIdempotencyKey({
|
||||
command: 'opencode.launchTeam',
|
||||
|
|
@ -352,3 +413,18 @@ function buildHandshake(input: {
|
|||
identityHash: createOpenCodeBridgeHandshakeIdentityHash(withoutHash),
|
||||
};
|
||||
}
|
||||
|
||||
function withAcceptedCommands(
|
||||
handshake: OpenCodeBridgeHandshake,
|
||||
acceptedCommands: OpenCodeBridgeHandshake['acceptedCommands']
|
||||
): OpenCodeBridgeHandshake {
|
||||
const { identityHash: _identityHash, ...rest } = handshake;
|
||||
const withoutHash: Omit<OpenCodeBridgeHandshake, 'identityHash'> = {
|
||||
...rest,
|
||||
acceptedCommands,
|
||||
};
|
||||
return {
|
||||
...withoutHash,
|
||||
identityHash: createOpenCodeBridgeHandshakeIdentityHash(withoutHash),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -400,6 +400,169 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
|||
expect(observed.observedAssistantPreview).toBe('Понял');
|
||||
});
|
||||
|
||||
it('tracks accepted runtime prompt ids without double-counting recovered command status', async () => {
|
||||
const store = createStore();
|
||||
const record = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-accepted',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:accepted',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
|
||||
const firstAccepted = await store.applyDeliveryResult({
|
||||
id: record.id,
|
||||
accepted: true,
|
||||
attempted: true,
|
||||
sessionId: 'oc-session-1',
|
||||
runtimePromptMessageId: 'msg_prompt_1',
|
||||
deliveryAttemptId: 'attempt-1',
|
||||
now: '2026-04-25T10:00:05.000Z',
|
||||
});
|
||||
expect(firstAccepted).toMatchObject({
|
||||
status: 'accepted',
|
||||
attempts: 1,
|
||||
runtimePromptMessageId: 'msg_prompt_1',
|
||||
lastRuntimePromptMessageId: 'msg_prompt_1',
|
||||
lastDeliveryAttemptIdWithAcceptedPrompt: 'attempt-1',
|
||||
});
|
||||
expect(firstAccepted.runtimePromptMessageIds).toEqual(['msg_prompt_1']);
|
||||
|
||||
const recoveredSamePrompt = await store.applyDeliveryResult({
|
||||
id: record.id,
|
||||
accepted: true,
|
||||
attempted: true,
|
||||
sessionId: 'oc-session-1',
|
||||
runtimePromptMessageId: 'msg_prompt_1',
|
||||
deliveryAttemptId: 'attempt-1',
|
||||
now: '2026-04-25T10:00:06.000Z',
|
||||
});
|
||||
expect(recoveredSamePrompt.attempts).toBe(1);
|
||||
expect(recoveredSamePrompt.runtimePromptMessageIds).toEqual(['msg_prompt_1']);
|
||||
|
||||
const retryAccepted = await store.applyDeliveryResult({
|
||||
id: record.id,
|
||||
accepted: true,
|
||||
attempted: true,
|
||||
sessionId: 'oc-session-2',
|
||||
runtimePromptMessageId: 'msg_prompt_2',
|
||||
deliveryAttemptId: 'attempt-2',
|
||||
now: '2026-04-25T10:01:00.000Z',
|
||||
});
|
||||
expect(retryAccepted.attempts).toBe(2);
|
||||
expect(retryAccepted.runtimePromptMessageIds).toEqual(['msg_prompt_1', 'msg_prompt_2']);
|
||||
expect(retryAccepted.lastRuntimePromptMessageId).toBe('msg_prompt_2');
|
||||
});
|
||||
|
||||
it('keeps schema-1 legacy prompt-id fields compatible and normalizes when touched', async () => {
|
||||
const store = createStore();
|
||||
const legacy = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-legacy-runtime-prompt',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:legacy-runtime-prompt',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
|
||||
const envelope = JSON.parse(await fs.readFile(ledgerPath(), 'utf8')) as {
|
||||
data: Record<string, unknown>[];
|
||||
};
|
||||
delete envelope.data[0].runtimePromptMessageIds;
|
||||
delete envelope.data[0].lastRuntimePromptMessageId;
|
||||
delete envelope.data[0].lastDeliveryAttemptIdWithAcceptedPrompt;
|
||||
await fs.writeFile(ledgerPath(), `${JSON.stringify(envelope, null, 2)}\n`, 'utf8');
|
||||
|
||||
await expect(store.list()).resolves.toHaveLength(1);
|
||||
|
||||
const touched = await store.applyDeliveryResult({
|
||||
id: legacy.id,
|
||||
accepted: true,
|
||||
attempted: true,
|
||||
runtimePromptMessageId: 'msg_prompt_legacy_touch',
|
||||
deliveryAttemptId: 'attempt-legacy-touch',
|
||||
now: '2026-04-25T10:00:05.000Z',
|
||||
});
|
||||
expect(touched.runtimePromptMessageIds).toEqual(['msg_prompt_legacy_touch']);
|
||||
expect(touched.lastRuntimePromptMessageId).toBe('msg_prompt_legacy_touch');
|
||||
expect(touched.lastDeliveryAttemptIdWithAcceptedPrompt).toBe('attempt-legacy-touch');
|
||||
});
|
||||
|
||||
it('accepts task stall remediation message kind across ledger validation', async () => {
|
||||
const store = createStore();
|
||||
const record = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'task-stall:team-a:jack:task-a',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watchdog',
|
||||
messageKind: 'task_stall_remediation',
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: 'do',
|
||||
payloadHash: 'sha256:task-stall',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(record.messageKind).toBe('task_stall_remediation');
|
||||
await expect(store.list()).resolves.toMatchObject([
|
||||
{ messageKind: 'task_stall_remediation' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('upgrades acceptance-unknown records when exact observation finds the prompt', async () => {
|
||||
const store = createStore();
|
||||
const record = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-observed-later',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:observed-later',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
const unknown = await store.markAcceptanceUnknown({
|
||||
id: record.id,
|
||||
reason: 'opencode_prompt_acceptance_unknown_after_bridge_timeout',
|
||||
nextAttemptAt: '2026-04-25T10:01:00.000Z',
|
||||
markedAt: '2026-04-25T10:00:45.000Z',
|
||||
});
|
||||
expect(unknown.acceptanceUnknown).toBe(true);
|
||||
|
||||
const observed = await store.applyObservation({
|
||||
id: record.id,
|
||||
sessionId: 'oc-session-recovered',
|
||||
runtimePromptMessageId: 'msg_prompt_recovered',
|
||||
responseObservation: {
|
||||
state: 'pending',
|
||||
deliveredUserMessageId: 'msg_prompt_recovered',
|
||||
assistantMessageId: null,
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: 'assistant_response_pending',
|
||||
},
|
||||
observedAt: '2026-04-25T10:00:50.000Z',
|
||||
});
|
||||
|
||||
expect(observed.status).toBe('accepted');
|
||||
expect(observed.acceptanceUnknown).toBe(false);
|
||||
expect(observed.acceptedAt).toBe('2026-04-25T10:00:50.000Z');
|
||||
expect(observed.runtimeSessionId).toBe('oc-session-recovered');
|
||||
expect(observed.runtimePromptMessageIds).toEqual(['msg_prompt_recovered']);
|
||||
});
|
||||
|
||||
it('keeps plain-text responses active until their visible inbox reply is materialized', async () => {
|
||||
const store = createStore();
|
||||
const record = await store.ensurePending({
|
||||
|
|
|
|||
|
|
@ -308,6 +308,63 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('falls back to observed sendMessage when acceptance capability is missing', async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new Error(
|
||||
'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 1.'
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
bridgeCommandSuccess({
|
||||
command: 'opencode.sendMessage',
|
||||
requestId: 'send-req-observed',
|
||||
data: {
|
||||
accepted: true,
|
||||
memberName: 'bob',
|
||||
sessionId: 'session-bob',
|
||||
diagnostics: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
const executor = {
|
||||
execute: execute as unknown as OpenCodeReadinessBridgeCommandExecutor['execute'] &
|
||||
ReturnType<typeof vi.fn>,
|
||||
};
|
||||
const bridge = new OpenCodeReadinessBridge(executor);
|
||||
|
||||
await expect(
|
||||
bridge.sendOpenCodeTeamMessage({
|
||||
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,
|
||||
sessionId: 'session-bob',
|
||||
diagnostics: [
|
||||
expect.objectContaining({
|
||||
code: 'opencode_accept_fast_capability_missing',
|
||||
severity: 'warning',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(execute.mock.calls[0]?.[1]).toMatchObject({ settlementMode: 'acceptance' });
|
||||
expect(execute.mock.calls[1]?.[1]).toMatchObject({ settlementMode: 'observed' });
|
||||
expect(execute.mock.calls[1]?.[2]).toMatchObject({
|
||||
requestId: expect.stringMatching(/-observed$/),
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers accepted OpenCode sendMessage after bridge timeout through commandStatus by default', async () => {
|
||||
const executor = fakeSequenceExecutor([
|
||||
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import {
|
||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
|
||||
createOpenCodeBridgeHandshakeIdentityHash,
|
||||
type OpenCodeBridgeCommandName,
|
||||
type OpenCodeBridgeHandshake,
|
||||
|
|
@ -85,6 +86,45 @@ describe('OpenCodeStateChangingBridgeCommandService', () => {
|
|||
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('requires delivery acceptance contract only for acceptance-mode sendMessage', async () => {
|
||||
clientIdentity.bridgeProtocol.supportedCommands.push('opencode.sendMessage');
|
||||
const server = peerIdentity('agent_teams_orchestrator');
|
||||
server.bridgeProtocol.supportedCommands.push('opencode.sendMessage');
|
||||
handshakePort.nextHandshake = buildHandshakeWithAcceptedCommands(
|
||||
{ client: clientIdentity, server },
|
||||
['opencode.launchTeam', 'opencode.stopTeam', 'opencode.sendMessage']
|
||||
);
|
||||
const service = createService();
|
||||
|
||||
await expect(service.execute(buildSendInput('acceptance'))).rejects.toThrow(
|
||||
'OpenCode delivery acceptance mode is required'
|
||||
);
|
||||
expect(bridge.calls).toHaveLength(0);
|
||||
await expect(ledger.list()).resolves.toEqual([]);
|
||||
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
|
||||
|
||||
server.bridgeProtocol.opencodeDeliveryAcceptanceContractVersion =
|
||||
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION;
|
||||
handshakePort.nextHandshake = buildHandshakeWithAcceptedCommands(
|
||||
{ client: clientIdentity, server },
|
||||
['opencode.launchTeam', 'opencode.stopTeam', 'opencode.sendMessage']
|
||||
);
|
||||
bridge.resultFactory = ({ body, command, options }) =>
|
||||
bridgeSuccess({
|
||||
requestId: options.requestId,
|
||||
command,
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
idempotencyKey: body.preconditions.idempotencyKey,
|
||||
runtimeStoreManifestHighWatermark: 10,
|
||||
},
|
||||
});
|
||||
await expect(service.execute(buildSendInput('acceptance'))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(bridge.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('adds preconditions, commits ledger, and releases lease on success', async () => {
|
||||
bridge.resultFactory = ({ body, options }) =>
|
||||
bridgeSuccess({
|
||||
|
|
@ -227,6 +267,32 @@ function buildLaunchInput(): Parameters<OpenCodeStateChangingBridgeCommandServic
|
|||
};
|
||||
}
|
||||
|
||||
function buildSendInput(
|
||||
settlementMode: 'observed' | 'acceptance'
|
||||
): Parameters<OpenCodeStateChangingBridgeCommandService['execute']>[0] {
|
||||
return {
|
||||
command: 'opencode.sendMessage',
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
runId: 'run-1',
|
||||
capabilitySnapshotId: null,
|
||||
behaviorFingerprint: null,
|
||||
body: {
|
||||
runId: 'run-1',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
teamId: 'team-a',
|
||||
teamName: 'team-a',
|
||||
projectPath: '/tmp/project',
|
||||
memberName: 'bob',
|
||||
text: 'hello',
|
||||
messageId: 'msg-1',
|
||||
settlementMode,
|
||||
},
|
||||
cwd: '/tmp/project',
|
||||
timeoutMs: 10_000,
|
||||
};
|
||||
}
|
||||
|
||||
function bridgeSuccess(
|
||||
overrides: Partial<OpenCodeBridgeSuccess<unknown>> = {}
|
||||
): OpenCodeBridgeSuccess<unknown> {
|
||||
|
|
@ -313,6 +379,29 @@ function buildHandshake(input: {
|
|||
};
|
||||
}
|
||||
|
||||
function buildHandshakeWithAcceptedCommands(
|
||||
input: {
|
||||
client: OpenCodeBridgePeerIdentity;
|
||||
server: OpenCodeBridgePeerIdentity;
|
||||
},
|
||||
acceptedCommands: OpenCodeBridgeHandshake['acceptedCommands']
|
||||
): OpenCodeBridgeHandshake {
|
||||
const withoutHash: Omit<OpenCodeBridgeHandshake, 'identityHash'> = {
|
||||
schemaVersion: 1,
|
||||
requestId: 'handshake-1',
|
||||
client: input.client,
|
||||
server: input.server,
|
||||
agreedProtocolVersion: 1,
|
||||
acceptedCommands,
|
||||
serverTime: '2026-04-21T12:00:00.000Z',
|
||||
};
|
||||
|
||||
return {
|
||||
...withoutHash,
|
||||
identityHash: createOpenCodeBridgeHandshakeIdentityHash(withoutHash),
|
||||
};
|
||||
}
|
||||
|
||||
class FakeBridgeExecutor implements OpenCodeBridgeCommandExecutor {
|
||||
calls: Array<{
|
||||
command: OpenCodeBridgeCommandName;
|
||||
|
|
|
|||
|
|
@ -291,6 +291,7 @@ describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => {
|
|||
expect(bridge.getOpenCodeTranscript).toHaveBeenCalledWith('/tmp/agent_teams_orchestrator', {
|
||||
teamId: 'relay-works-10',
|
||||
memberName: 'jack',
|
||||
sessionId: 'ses_23edf9243ffeSNYPWObDloBJyQ',
|
||||
limit: 500,
|
||||
});
|
||||
});
|
||||
|
|
@ -361,6 +362,7 @@ describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => {
|
|||
{
|
||||
teamId: 'relay-works-10',
|
||||
memberName: 'jack',
|
||||
sessionId: 'stale-session-id',
|
||||
limit: 500,
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ function createLedgerRecord(
|
|||
laneId: 'lane-a',
|
||||
runId: 'run-a',
|
||||
runtimeSessionId: 'session-a',
|
||||
runtimePromptMessageIds: [],
|
||||
lastRuntimePromptMessageId: null,
|
||||
lastDeliveryAttemptIdWithAcceptedPrompt: null,
|
||||
inboxMessageId: 'inbox-a',
|
||||
inboxTimestamp: '2026-04-21T10:00:00.000Z',
|
||||
source: 'watcher',
|
||||
|
|
@ -203,4 +206,33 @@ describe('TaskLogOpenCodeSessionEvidenceSource', () => {
|
|||
|
||||
await expect(source.readTaskRecords('team-a', createTask())).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('uses accepted runtime prompt id as task-log start anchor before observation catches up', async () => {
|
||||
const teamsBasePath = await createTempTeamsBasePath();
|
||||
await writeLedger({
|
||||
teamsBasePath,
|
||||
teamName: 'team-a',
|
||||
laneId: 'lane-a',
|
||||
records: [
|
||||
createLedgerRecord({
|
||||
id: 'record-accepted-only',
|
||||
runtimeSessionId: 'session-accepted-only',
|
||||
runtimePromptMessageId: 'msg_prompt_current',
|
||||
runtimePromptMessageIds: ['msg_prompt_previous', 'msg_prompt_current'],
|
||||
lastRuntimePromptMessageId: 'msg_prompt_current',
|
||||
deliveredUserMessageId: null,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const source = new TaskLogOpenCodeSessionEvidenceSource({ teamsBasePath });
|
||||
const records = await source.readTaskRecords('team-a', createTask());
|
||||
|
||||
expect(records).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: 'session-accepted-only',
|
||||
startMessageUuid: 'msg_prompt_current',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -206,6 +206,31 @@ describe('TeamInboxReader', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('preserves task-stall remediation semantic kind', async () => {
|
||||
hoisted.files.set(
|
||||
'/mock/teams/my-team/inboxes/alice.json',
|
||||
JSON.stringify([
|
||||
{
|
||||
from: 'system',
|
||||
to: 'alice',
|
||||
text: 'Please continue the stalled task or report a blocker.',
|
||||
timestamp: '2026-01-01T02:45:00.000Z',
|
||||
read: false,
|
||||
messageId: 'task-stall:my-team:alice:task-a',
|
||||
source: 'system_notification',
|
||||
messageKind: 'task_stall_remediation',
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const messages = await reader.getMessagesFor('my-team', 'alice');
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
messageId: 'task-stall:my-team:alice:task-a',
|
||||
messageKind: 'task_stall_remediation',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves agent error semantic kind from the team lead inbox', async () => {
|
||||
hoisted.files.set(
|
||||
'/mock/teams/my-team/inboxes/team-lead.json',
|
||||
|
|
|
|||
|
|
@ -5426,6 +5426,7 @@ describe('TeamProvisioningService', () => {
|
|||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
runtimePromptMessageId: 'msg_prompt_direct_lane',
|
||||
runtimePid: 456,
|
||||
diagnostics: [],
|
||||
}));
|
||||
|
|
@ -5507,6 +5508,7 @@ describe('TeamProvisioningService', () => {
|
|||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
runtimePromptMessageId: 'msg_prompt_verified_pid',
|
||||
runtimePid: 456,
|
||||
diagnostics: [],
|
||||
}));
|
||||
|
|
@ -5632,6 +5634,7 @@ describe('TeamProvisioningService', () => {
|
|||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
runtimePromptMessageId: 'msg_prompt_unverified_pid',
|
||||
runtimePid: 456,
|
||||
diagnostics: [],
|
||||
}));
|
||||
|
|
@ -5734,6 +5737,7 @@ describe('TeamProvisioningService', () => {
|
|||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
runtimePromptMessageId: 'msg_prompt_snapshot_config',
|
||||
diagnostics: [],
|
||||
}));
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
|
|
@ -5824,6 +5828,7 @@ describe('TeamProvisioningService', () => {
|
|||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
runtimePromptMessageId: 'msg_prompt_worktree_cwd',
|
||||
diagnostics: [],
|
||||
}));
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
|
|
@ -7071,6 +7076,109 @@ describe('TeamProvisioningService', () => {
|
|||
expect(ledgerEnvelope.data[0].nextAttemptAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps accepted OpenCode responses without exact prompt identity acceptance-unknown', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
prePromptCursor: 'cursor-before',
|
||||
diagnostics: [],
|
||||
}));
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
sendMessageToMember,
|
||||
observeMessageDelivery: vi.fn(),
|
||||
} as any,
|
||||
]);
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
|
||||
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
|
||||
(svc as any).provisioningRunByTeam.set('team-a', 'run-1');
|
||||
(svc as any).setSecondaryRuntimeRun({
|
||||
teamName: 'team-a',
|
||||
runId: 'opencode-run-bob',
|
||||
providerId: 'opencode',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
members: [
|
||||
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
],
|
||||
})),
|
||||
};
|
||||
(svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({
|
||||
launchIdentity: { providerId: 'codex' },
|
||||
providerId: 'codex',
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'Please handle this.',
|
||||
messageId: 'msg-accepted-missing-prompt-id',
|
||||
replyRecipient: 'user',
|
||||
actionMode: 'ask',
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
accepted: false,
|
||||
responsePending: true,
|
||||
acceptanceUnknown: true,
|
||||
reason: 'opencode_prompt_acceptance_unknown_missing_runtime_prompt_id',
|
||||
});
|
||||
|
||||
const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({
|
||||
teamsBasePath: tempTeamsBase,
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
fileName: 'opencode-prompt-delivery-ledger.json',
|
||||
});
|
||||
const ledgerEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as {
|
||||
data: Array<{
|
||||
acceptanceUnknown: boolean;
|
||||
status: string;
|
||||
runtimePromptMessageId: string | null;
|
||||
lastReason: string | null;
|
||||
diagnostics: string[];
|
||||
}>;
|
||||
};
|
||||
expect(ledgerEnvelope.data[0]).toMatchObject({
|
||||
acceptanceUnknown: true,
|
||||
status: 'failed_retryable',
|
||||
runtimePromptMessageId: null,
|
||||
lastReason: 'opencode_prompt_acceptance_unknown_missing_runtime_prompt_id',
|
||||
});
|
||||
expect(ledgerEnvelope.data[0].diagnostics).toContain(
|
||||
'opencode_prompt_acceptance_missing_runtime_prompt_id'
|
||||
);
|
||||
});
|
||||
|
||||
it('marks OpenCode payload hash mismatch terminal without sending a duplicate prompt', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
|
|
@ -8923,6 +9031,7 @@ describe('TeamProvisioningService', () => {
|
|||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
runtimePromptMessageId: 'msg_prompt_after_restart',
|
||||
diagnostics: [],
|
||||
}));
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
|
|
@ -9015,6 +9124,7 @@ describe('TeamProvisioningService', () => {
|
|||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
runtimePromptMessageId: 'msg_prompt_live_lane',
|
||||
diagnostics: [],
|
||||
}));
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
|
|
|
|||
Loading…
Reference in a new issue