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' ||
|
||||||
row.messageKind === 'slash_command_result' ||
|
row.messageKind === 'slash_command_result' ||
|
||||||
row.messageKind === 'task_comment_notification' ||
|
row.messageKind === 'task_comment_notification' ||
|
||||||
|
row.messageKind === 'task_stall_remediation' ||
|
||||||
row.messageKind === 'member_work_sync_nudge' ||
|
row.messageKind === 'member_work_sync_nudge' ||
|
||||||
row.messageKind === 'agent_error'
|
row.messageKind === 'agent_error'
|
||||||
? row.messageKind
|
? row.messageKind
|
||||||
|
|
|
||||||
|
|
@ -8363,7 +8363,10 @@ export class TeamProvisioningService {
|
||||||
taskRefs: input.taskRefs,
|
taskRefs: input.taskRefs,
|
||||||
prePromptCursor: ledgerRecord.prePromptCursor,
|
prePromptCursor: ledgerRecord.prePromptCursor,
|
||||||
sessionId: ledgerRecord.runtimeSessionId ?? undefined,
|
sessionId: ledgerRecord.runtimeSessionId ?? undefined,
|
||||||
runtimePromptMessageId: ledgerRecord.runtimePromptMessageId ?? undefined,
|
runtimePromptMessageId:
|
||||||
|
ledgerRecord.lastRuntimePromptMessageId ??
|
||||||
|
ledgerRecord.runtimePromptMessageId ??
|
||||||
|
undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = `opencode_direct_user_delivery_inline_observe_failed: ${getErrorMessage(
|
const reason = `opencode_direct_user_delivery_inline_observe_failed: ${getErrorMessage(
|
||||||
|
|
@ -8416,6 +8419,8 @@ export class TeamProvisioningService {
|
||||||
latestAssistantPreview: null,
|
latestAssistantPreview: null,
|
||||||
reason: observed.diagnostics[0] ?? null,
|
reason: observed.diagnostics[0] ?? null,
|
||||||
},
|
},
|
||||||
|
sessionId: observed.sessionId,
|
||||||
|
runtimePromptMessageId: observed.runtimePromptMessageId,
|
||||||
diagnostics: [
|
diagnostics: [
|
||||||
`opencode_direct_user_delivery_inline_observe_attempt_${inlineObserveAttempt}`,
|
`opencode_direct_user_delivery_inline_observe_attempt_${inlineObserveAttempt}`,
|
||||||
...(hadMessageSendToolError ? ['opencode_message_send_tool_error_inline_observe'] : []),
|
...(hadMessageSendToolError ? ['opencode_message_send_tool_error_inline_observe'] : []),
|
||||||
|
|
@ -9815,7 +9820,10 @@ export class TeamProvisioningService {
|
||||||
taskRefs: input.taskRefs,
|
taskRefs: input.taskRefs,
|
||||||
prePromptCursor: ledgerRecord.prePromptCursor,
|
prePromptCursor: ledgerRecord.prePromptCursor,
|
||||||
sessionId: ledgerRecord.runtimeSessionId ?? undefined,
|
sessionId: ledgerRecord.runtimeSessionId ?? undefined,
|
||||||
runtimePromptMessageId: ledgerRecord.runtimePromptMessageId ?? undefined,
|
runtimePromptMessageId:
|
||||||
|
ledgerRecord.lastRuntimePromptMessageId ??
|
||||||
|
ledgerRecord.runtimePromptMessageId ??
|
||||||
|
undefined,
|
||||||
});
|
});
|
||||||
await this.rememberOpenCodeRuntimePidFromBridge({
|
await this.rememberOpenCodeRuntimePidFromBridge({
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -9842,6 +9850,8 @@ export class TeamProvisioningService {
|
||||||
latestAssistantPreview: null,
|
latestAssistantPreview: null,
|
||||||
reason: observed.diagnostics[0] ?? null,
|
reason: observed.diagnostics[0] ?? null,
|
||||||
},
|
},
|
||||||
|
sessionId: observed.sessionId,
|
||||||
|
runtimePromptMessageId: observed.runtimePromptMessageId,
|
||||||
diagnostics: observed.diagnostics,
|
diagnostics: observed.diagnostics,
|
||||||
observedAt: nowIso(),
|
observedAt: nowIso(),
|
||||||
});
|
});
|
||||||
|
|
@ -9982,8 +9992,17 @@ export class TeamProvisioningService {
|
||||||
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
|
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
|
||||||
result.responseObservation
|
result.responseObservation
|
||||||
);
|
);
|
||||||
const promptAccepted =
|
const promptAcceptedByRuntimeIdentity = Boolean(
|
||||||
result.ok || this.isOpenCodePromptAcceptedByObservation(responseObservation);
|
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) {
|
if (ledgerRecord && ledger) {
|
||||||
ledgerRecord = await ledger.applyDeliveryResult({
|
ledgerRecord = await ledger.applyDeliveryResult({
|
||||||
id: ledgerRecord.id,
|
id: ledgerRecord.id,
|
||||||
|
|
@ -9992,9 +10011,10 @@ export class TeamProvisioningService {
|
||||||
responseObservation,
|
responseObservation,
|
||||||
sessionId: result.sessionId,
|
sessionId: result.sessionId,
|
||||||
runtimePromptMessageId: result.runtimePromptMessageId,
|
runtimePromptMessageId: result.runtimePromptMessageId,
|
||||||
|
deliveryAttemptId,
|
||||||
prePromptCursor: result.prePromptCursor,
|
prePromptCursor: result.prePromptCursor,
|
||||||
diagnostics: result.diagnostics,
|
diagnostics: deliveryDiagnostics,
|
||||||
reason: promptAccepted ? responseObservation?.reason : result.diagnostics[0],
|
reason: promptAccepted ? responseObservation?.reason : deliveryDiagnostics[0],
|
||||||
now: nowIso(),
|
now: nowIso(),
|
||||||
});
|
});
|
||||||
this.emitOpenCodePromptDeliveryTaskLogChange(
|
this.emitOpenCodePromptDeliveryTaskLogChange(
|
||||||
|
|
@ -10049,7 +10069,7 @@ export class TeamProvisioningService {
|
||||||
ledgerRecord,
|
ledgerRecord,
|
||||||
{
|
{
|
||||||
accepted: promptAccepted,
|
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) {
|
if (ledgerRecord && !promptAccepted) {
|
||||||
const reason = this.isOpenCodePromptAcceptanceUnknownFailure(result.diagnostics)
|
const reason = promptAcceptanceMissingRuntimePromptId
|
||||||
? 'opencode_prompt_acceptance_unknown_after_bridge_timeout'
|
? 'opencode_prompt_acceptance_unknown_missing_runtime_prompt_id'
|
||||||
: (result.diagnostics[0] ?? 'opencode_message_delivery_failed');
|
: this.isOpenCodePromptAcceptanceUnknownFailure(deliveryDiagnostics)
|
||||||
if (reason === 'opencode_prompt_acceptance_unknown_after_bridge_timeout') {
|
? '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;
|
const delayMs = OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS;
|
||||||
ledgerRecord = await ledger!.markAcceptanceUnknown({
|
ledgerRecord = await ledger!.markAcceptanceUnknown({
|
||||||
id: ledgerRecord.id,
|
id: ledgerRecord.id,
|
||||||
reason,
|
reason,
|
||||||
nextAttemptAt: new Date(Date.now() + delayMs).toISOString(),
|
nextAttemptAt: new Date(Date.now() + delayMs).toISOString(),
|
||||||
diagnostics: result.diagnostics,
|
diagnostics: deliveryDiagnostics,
|
||||||
markedAt: nowIso(),
|
markedAt: nowIso(),
|
||||||
});
|
});
|
||||||
this.scheduleOpenCodePromptDeliveryWatchdog({
|
this.scheduleOpenCodePromptDeliveryWatchdog({
|
||||||
|
|
|
||||||
|
|
@ -438,6 +438,7 @@ export interface OpenCodeBridgePeerIdentity {
|
||||||
supportedCommands: OpenCodeBridgeCommandName[];
|
supportedCommands: OpenCodeBridgeCommandName[];
|
||||||
opencodeTaskLedgerEvidenceContractVersion?: number;
|
opencodeTaskLedgerEvidenceContractVersion?: number;
|
||||||
opencodeAppManagedBootstrapContractVersion?: number;
|
opencodeAppManagedBootstrapContractVersion?: number;
|
||||||
|
opencodeDeliveryAcceptanceContractVersion?: number;
|
||||||
};
|
};
|
||||||
runtime: {
|
runtime: {
|
||||||
providerId: 'opencode';
|
providerId: 'opencode';
|
||||||
|
|
@ -618,6 +619,7 @@ export function validateOpenCodeBridgeHandshake(input: {
|
||||||
expectedCapabilitySnapshotId: string | null;
|
expectedCapabilitySnapshotId: string | null;
|
||||||
expectedManifestHighWatermark: number | null;
|
expectedManifestHighWatermark: number | null;
|
||||||
expectedRunId: string | null;
|
expectedRunId: string | null;
|
||||||
|
requiresDeliveryAcceptanceContract?: boolean;
|
||||||
}): { ok: true } | { ok: false; reason: string } {
|
}): { ok: true } | { ok: false; reason: string } {
|
||||||
const shape = validateOpenCodeBridgeHandshakeShape(input.handshake);
|
const shape = validateOpenCodeBridgeHandshakeShape(input.handshake);
|
||||||
if (!shape.ok) {
|
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 (
|
if (
|
||||||
input.expectedCapabilitySnapshotId &&
|
input.expectedCapabilitySnapshotId &&
|
||||||
input.handshake.server.runtime.capabilitySnapshotId !== 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.opencodeTaskLedgerEvidenceContractVersion as number) < 1)) ||
|
||||||
(bridgeProtocol.opencodeAppManagedBootstrapContractVersion !== undefined &&
|
(bridgeProtocol.opencodeAppManagedBootstrapContractVersion !== undefined &&
|
||||||
(!Number.isInteger(bridgeProtocol.opencodeAppManagedBootstrapContractVersion) ||
|
(!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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||||
|
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
|
||||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||||
} from './OpenCodeBridgeCommandContract';
|
} from './OpenCodeBridgeCommandContract';
|
||||||
|
|
||||||
|
|
@ -103,6 +104,7 @@ export function createOpenCodeBridgeClientIdentity(input: {
|
||||||
],
|
],
|
||||||
opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||||
opencodeAppManagedBootstrapContractVersion: OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
opencodeAppManagedBootstrapContractVersion: OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||||
|
opencodeDeliveryAcceptanceContractVersion: OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
|
||||||
|
|
@ -232,29 +232,56 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
||||||
...input,
|
...input,
|
||||||
payloadHash: input.payloadHash ?? buildSendPayloadHash(input),
|
payloadHash: input.payloadHash ?? buildSendPayloadHash(input),
|
||||||
};
|
};
|
||||||
const result = await this.bridge.execute<
|
let activeRequestId = commandRequestId;
|
||||||
OpenCodeSendMessageCommandBody,
|
let activeBody = body;
|
||||||
OpenCodeSendMessageCommandData
|
let usedObservedFallback = false;
|
||||||
>('opencode.sendMessage', body, {
|
const executeSend = (nextBody: OpenCodeSendMessageCommandBody, requestId: string) =>
|
||||||
cwd: body.projectPath,
|
this.bridge.execute<OpenCodeSendMessageCommandBody, OpenCodeSendMessageCommandData>(
|
||||||
timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS,
|
'opencode.sendMessage',
|
||||||
requestId: commandRequestId,
|
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) {
|
if (result.ok) {
|
||||||
return result.data;
|
return usedObservedFallback
|
||||||
|
? withOpenCodeObservedFallbackDiagnostic(result.data)
|
||||||
|
: result.data;
|
||||||
}
|
}
|
||||||
if (result.error.kind === 'timeout') {
|
if (result.error.kind === 'timeout') {
|
||||||
const recovered = await this.recoverTimedOutSendMessage({
|
const recovered = await this.recoverTimedOutSendMessage({
|
||||||
originalRequestId: commandRequestId,
|
originalRequestId: activeRequestId,
|
||||||
body,
|
body: activeBody,
|
||||||
});
|
});
|
||||||
if (recovered) {
|
if (recovered) {
|
||||||
return recovered;
|
return usedObservedFallback ? withOpenCodeObservedFallbackDiagnostic(recovered) : recovered;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
accepted: false,
|
accepted: false,
|
||||||
memberName: body.memberName,
|
memberName: activeBody.memberName,
|
||||||
diagnostics: [
|
diagnostics: [
|
||||||
{
|
{
|
||||||
code: result.error.kind,
|
code: result.error.kind,
|
||||||
|
|
@ -543,6 +570,28 @@ function formatDiagnosticEvent(event: OpenCodeBridgeDiagnosticEvent): string {
|
||||||
return `${event.type}: ${event.message}`;
|
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>(
|
function thrownBridgeFailure<TData>(
|
||||||
command: OpenCodeBridgeCommandName,
|
command: OpenCodeBridgeCommandName,
|
||||||
runId: string,
|
runId: string,
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,10 @@ export class OpenCodeStateChangingBridgeCommandService {
|
||||||
expectedCapabilitySnapshotId: input.capabilitySnapshotId,
|
expectedCapabilitySnapshotId: input.capabilitySnapshotId,
|
||||||
expectedManifestHighWatermark: manifest.highWatermark,
|
expectedManifestHighWatermark: manifest.highWatermark,
|
||||||
expectedRunId: input.runId,
|
expectedRunId: input.runId,
|
||||||
|
requiresDeliveryAcceptanceContract: requiresOpenCodeDeliveryAcceptanceContract(
|
||||||
|
input.command,
|
||||||
|
input.body
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!handshakeValidation.ok) {
|
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);
|
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 {
|
function stringifyError(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ export interface OpenCodePromptDeliveryLedgerRecord {
|
||||||
runId: string | null;
|
runId: string | null;
|
||||||
runtimeSessionId: string | null;
|
runtimeSessionId: string | null;
|
||||||
runtimePromptMessageId?: string | null;
|
runtimePromptMessageId?: string | null;
|
||||||
|
runtimePromptMessageIds?: string[];
|
||||||
|
lastRuntimePromptMessageId?: string | null;
|
||||||
|
lastDeliveryAttemptIdWithAcceptedPrompt?: string | null;
|
||||||
inboxMessageId: string;
|
inboxMessageId: string;
|
||||||
inboxTimestamp: string;
|
inboxTimestamp: string;
|
||||||
source: 'watcher' | 'ui-send' | 'manual' | 'watchdog' | 'member-work-sync-review-pickup';
|
source: 'watcher' | 'ui-send' | 'manual' | 'watchdog' | 'member-work-sync-review-pickup';
|
||||||
|
|
@ -138,6 +141,7 @@ export interface ApplyOpenCodePromptDeliveryResultInput {
|
||||||
responseObservation?: OpenCodeDeliveryResponseObservation;
|
responseObservation?: OpenCodeDeliveryResponseObservation;
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
runtimePromptMessageId?: string | null;
|
runtimePromptMessageId?: string | null;
|
||||||
|
deliveryAttemptId?: string | null;
|
||||||
runtimePid?: number;
|
runtimePid?: number;
|
||||||
prePromptCursor?: string | null;
|
prePromptCursor?: string | null;
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
|
|
@ -213,6 +217,9 @@ export class OpenCodePromptDeliveryLedgerStore {
|
||||||
runId: input.runId ?? null,
|
runId: input.runId ?? null,
|
||||||
runtimeSessionId: null,
|
runtimeSessionId: null,
|
||||||
runtimePromptMessageId: null,
|
runtimePromptMessageId: null,
|
||||||
|
runtimePromptMessageIds: [],
|
||||||
|
lastRuntimePromptMessageId: null,
|
||||||
|
lastDeliveryAttemptIdWithAcceptedPrompt: null,
|
||||||
inboxMessageId: input.inboxMessageId,
|
inboxMessageId: input.inboxMessageId,
|
||||||
inboxTimestamp: input.inboxTimestamp,
|
inboxTimestamp: input.inboxTimestamp,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
|
|
@ -305,6 +312,37 @@ export class OpenCodePromptDeliveryLedgerStore {
|
||||||
observation?.state ?? (input.accepted ? record.responseState : 'not_observed');
|
observation?.state ?? (input.accepted ? record.responseState : 'not_observed');
|
||||||
const responded = isOpenCodePromptResponseStateResponded(responseState);
|
const responded = isOpenCodePromptResponseStateResponded(responseState);
|
||||||
const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(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 {
|
return {
|
||||||
...record,
|
...record,
|
||||||
status: input.accepted
|
status: input.accepted
|
||||||
|
|
@ -315,11 +353,15 @@ export class OpenCodePromptDeliveryLedgerStore {
|
||||||
: 'accepted'
|
: 'accepted'
|
||||||
: 'failed_retryable',
|
: 'failed_retryable',
|
||||||
responseState,
|
responseState,
|
||||||
attempts:
|
attempts: shouldIncrementAttempts ? record.attempts + 1 : record.attempts,
|
||||||
input.accepted || input.attempted === true ? record.attempts + 1 : record.attempts,
|
|
||||||
runtimeSessionId: input.sessionId ?? record.runtimeSessionId,
|
runtimeSessionId: input.sessionId ?? record.runtimeSessionId,
|
||||||
runtimePromptMessageId:
|
runtimePromptMessageId: lastRuntimePromptMessageId,
|
||||||
input.runtimePromptMessageId ?? record.runtimePromptMessageId ?? null,
|
runtimePromptMessageIds,
|
||||||
|
lastRuntimePromptMessageId,
|
||||||
|
lastDeliveryAttemptIdWithAcceptedPrompt:
|
||||||
|
input.accepted && acceptedDeliveryAttemptId
|
||||||
|
? acceptedDeliveryAttemptId
|
||||||
|
: (record.lastDeliveryAttemptIdWithAcceptedPrompt ?? null),
|
||||||
acceptanceUnknown: input.accepted ? false : record.acceptanceUnknown,
|
acceptanceUnknown: input.accepted ? false : record.acceptanceUnknown,
|
||||||
lastAttemptAt: input.now,
|
lastAttemptAt: input.now,
|
||||||
lastObservedAt: observation ? input.now : record.lastObservedAt,
|
lastObservedAt: observation ? input.now : record.lastObservedAt,
|
||||||
|
|
@ -348,6 +390,8 @@ export class OpenCodePromptDeliveryLedgerStore {
|
||||||
async applyObservation(input: {
|
async applyObservation(input: {
|
||||||
id: string;
|
id: string;
|
||||||
responseObservation: OpenCodeDeliveryResponseObservation;
|
responseObservation: OpenCodeDeliveryResponseObservation;
|
||||||
|
sessionId?: string | null;
|
||||||
|
runtimePromptMessageId?: string | null;
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
observedAt: string;
|
observedAt: string;
|
||||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||||
|
|
@ -356,17 +400,48 @@ export class OpenCodePromptDeliveryLedgerStore {
|
||||||
const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(
|
const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(
|
||||||
input.responseObservation.state
|
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 {
|
return {
|
||||||
...record,
|
...record,
|
||||||
status: responded
|
status: responded
|
||||||
? 'responded'
|
? 'responded'
|
||||||
: unanswered
|
: unanswered
|
||||||
? 'unanswered'
|
? 'unanswered'
|
||||||
: record.status === 'pending'
|
: record.status === 'pending' || promptAcceptedByObservation
|
||||||
? 'accepted'
|
? 'accepted'
|
||||||
: record.status,
|
: record.status,
|
||||||
responseState: input.responseObservation.state,
|
responseState: input.responseObservation.state,
|
||||||
|
runtimeSessionId: input.sessionId ?? record.runtimeSessionId,
|
||||||
|
runtimePromptMessageId: lastRuntimePromptMessageId,
|
||||||
|
runtimePromptMessageIds,
|
||||||
|
lastRuntimePromptMessageId,
|
||||||
|
acceptanceUnknown: promptAcceptedByObservation ? false : record.acceptanceUnknown,
|
||||||
lastObservedAt: input.observedAt,
|
lastObservedAt: input.observedAt,
|
||||||
|
acceptedAt: promptAcceptedByObservation
|
||||||
|
? (record.acceptedAt ?? input.observedAt)
|
||||||
|
: record.acceptedAt,
|
||||||
respondedAt: responded ? (record.respondedAt ?? input.observedAt) : record.respondedAt,
|
respondedAt: responded ? (record.respondedAt ?? input.observedAt) : record.respondedAt,
|
||||||
deliveredUserMessageId:
|
deliveredUserMessageId:
|
||||||
input.responseObservation.deliveredUserMessageId ?? record.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(
|
export function isOpenCodePromptResponseStateResponded(
|
||||||
state: OpenCodeDeliveryResponseState
|
state: OpenCodeDeliveryResponseState
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|
@ -720,6 +830,9 @@ function isOpenCodePromptDeliveryLedgerRecord(
|
||||||
isOptionalNullableString(record.runId) &&
|
isOptionalNullableString(record.runId) &&
|
||||||
isOptionalNullableString(record.runtimeSessionId) &&
|
isOptionalNullableString(record.runtimeSessionId) &&
|
||||||
isOptionalNullableString(record.runtimePromptMessageId) &&
|
isOptionalNullableString(record.runtimePromptMessageId) &&
|
||||||
|
isOptionalStringArray(record.runtimePromptMessageIds) &&
|
||||||
|
isOptionalNullableString(record.lastRuntimePromptMessageId) &&
|
||||||
|
isOptionalNullableString(record.lastDeliveryAttemptIdWithAcceptedPrompt) &&
|
||||||
typeof record.inboxMessageId === 'string' &&
|
typeof record.inboxMessageId === 'string' &&
|
||||||
typeof record.inboxTimestamp === 'string' &&
|
typeof record.inboxTimestamp === 'string' &&
|
||||||
isOpenCodePromptDeliverySource(record.source) &&
|
isOpenCodePromptDeliverySource(record.source) &&
|
||||||
|
|
@ -812,6 +925,7 @@ function isOptionalNullableInboxMessageKind(
|
||||||
value === 'slash_command' ||
|
value === 'slash_command' ||
|
||||||
value === 'slash_command_result' ||
|
value === 'slash_command_result' ||
|
||||||
value === 'task_comment_notification' ||
|
value === 'task_comment_notification' ||
|
||||||
|
value === 'task_stall_remediation' ||
|
||||||
value === 'member_work_sync_nudge' ||
|
value === 'member_work_sync_nudge' ||
|
||||||
value === 'agent_error'
|
value === 'agent_error'
|
||||||
);
|
);
|
||||||
|
|
@ -825,6 +939,10 @@ function isStringArray(value: unknown): value is string[] {
|
||||||
return Array.isArray(value) && value.every((item) => typeof item === '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 {
|
function isNonNegativeInteger(value: unknown): value is number {
|
||||||
return Number.isInteger(value) && (value as number) >= 0;
|
return Number.isInteger(value) && (value as number) >= 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import * as path from 'node:path';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createOpenCodePromptDeliveryLedgerStore,
|
createOpenCodePromptDeliveryLedgerStore,
|
||||||
|
getLatestOpenCodeRuntimePromptMessageId,
|
||||||
type OpenCodePromptDeliveryLedgerRecord,
|
type OpenCodePromptDeliveryLedgerRecord,
|
||||||
} from '../../opencode/delivery/OpenCodePromptDeliveryLedger';
|
} from '../../opencode/delivery/OpenCodePromptDeliveryLedger';
|
||||||
import {
|
import {
|
||||||
|
|
@ -158,7 +159,10 @@ function toAttributionRecord(
|
||||||
])
|
])
|
||||||
: undefined;
|
: undefined;
|
||||||
const until = addMsToIso(terminalUntil ?? fallbackUntil, TERMINAL_EVIDENCE_GRACE_MS);
|
const until = addMsToIso(terminalUntil ?? fallbackUntil, TERMINAL_EVIDENCE_GRACE_MS);
|
||||||
const startMessageUuid = record.deliveredUserMessageId?.trim() || undefined;
|
const startMessageUuid =
|
||||||
|
record.deliveredUserMessageId?.trim() ||
|
||||||
|
getLatestOpenCodeRuntimePromptMessageId(record) ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
|
|
@ -241,7 +245,9 @@ export class TaskLogOpenCodeSessionEvidenceSource implements OpenCodeTaskLogSess
|
||||||
record.memberName.trim().toLowerCase(),
|
record.memberName.trim().toLowerCase(),
|
||||||
record.laneId.trim(),
|
record.laneId.trim(),
|
||||||
sessionId,
|
sessionId,
|
||||||
record.deliveredUserMessageId ?? record.inboxMessageId,
|
record.deliveredUserMessageId ??
|
||||||
|
getLatestOpenCodeRuntimePromptMessageId(record) ??
|
||||||
|
record.inboxMessageId,
|
||||||
].join('::');
|
].join('::');
|
||||||
if (seen.has(key)) {
|
if (seen.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
createOpenCodeBridgeIdempotencyKey,
|
createOpenCodeBridgeIdempotencyKey,
|
||||||
isOpenCodeBridgeCommandName,
|
isOpenCodeBridgeCommandName,
|
||||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||||
|
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
|
||||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||||
parseSingleBridgeJsonResult,
|
parseSingleBridgeJsonResult,
|
||||||
stableHash,
|
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', () => {
|
it('creates deterministic idempotency keys for equivalent JSON bodies', () => {
|
||||||
const first = createOpenCodeBridgeIdempotencyKey({
|
const first = createOpenCodeBridgeIdempotencyKey({
|
||||||
command: 'opencode.launchTeam',
|
command: 'opencode.launchTeam',
|
||||||
|
|
@ -352,3 +413,18 @@ function buildHandshake(input: {
|
||||||
identityHash: createOpenCodeBridgeHandshakeIdentityHash(withoutHash),
|
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('Понял');
|
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 () => {
|
it('keeps plain-text responses active until their visible inbox reply is materialized', async () => {
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
const record = await store.ensurePending({
|
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 () => {
|
it('recovers accepted OpenCode sendMessage after bridge timeout through commandStatus by default', async () => {
|
||||||
const executor = fakeSequenceExecutor([
|
const executor = fakeSequenceExecutor([
|
||||||
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
|
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||||
|
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
|
||||||
createOpenCodeBridgeHandshakeIdentityHash,
|
createOpenCodeBridgeHandshakeIdentityHash,
|
||||||
type OpenCodeBridgeCommandName,
|
type OpenCodeBridgeCommandName,
|
||||||
type OpenCodeBridgeHandshake,
|
type OpenCodeBridgeHandshake,
|
||||||
|
|
@ -85,6 +86,45 @@ describe('OpenCodeStateChangingBridgeCommandService', () => {
|
||||||
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
|
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 () => {
|
it('adds preconditions, commits ledger, and releases lease on success', async () => {
|
||||||
bridge.resultFactory = ({ body, options }) =>
|
bridge.resultFactory = ({ body, options }) =>
|
||||||
bridgeSuccess({
|
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(
|
function bridgeSuccess(
|
||||||
overrides: Partial<OpenCodeBridgeSuccess<unknown>> = {}
|
overrides: Partial<OpenCodeBridgeSuccess<unknown>> = {}
|
||||||
): 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 {
|
class FakeBridgeExecutor implements OpenCodeBridgeCommandExecutor {
|
||||||
calls: Array<{
|
calls: Array<{
|
||||||
command: OpenCodeBridgeCommandName;
|
command: OpenCodeBridgeCommandName;
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,7 @@ describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => {
|
||||||
expect(bridge.getOpenCodeTranscript).toHaveBeenCalledWith('/tmp/agent_teams_orchestrator', {
|
expect(bridge.getOpenCodeTranscript).toHaveBeenCalledWith('/tmp/agent_teams_orchestrator', {
|
||||||
teamId: 'relay-works-10',
|
teamId: 'relay-works-10',
|
||||||
memberName: 'jack',
|
memberName: 'jack',
|
||||||
|
sessionId: 'ses_23edf9243ffeSNYPWObDloBJyQ',
|
||||||
limit: 500,
|
limit: 500,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -361,6 +362,7 @@ describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => {
|
||||||
{
|
{
|
||||||
teamId: 'relay-works-10',
|
teamId: 'relay-works-10',
|
||||||
memberName: 'jack',
|
memberName: 'jack',
|
||||||
|
sessionId: 'stale-session-id',
|
||||||
limit: 500,
|
limit: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ function createLedgerRecord(
|
||||||
laneId: 'lane-a',
|
laneId: 'lane-a',
|
||||||
runId: 'run-a',
|
runId: 'run-a',
|
||||||
runtimeSessionId: 'session-a',
|
runtimeSessionId: 'session-a',
|
||||||
|
runtimePromptMessageIds: [],
|
||||||
|
lastRuntimePromptMessageId: null,
|
||||||
|
lastDeliveryAttemptIdWithAcceptedPrompt: null,
|
||||||
inboxMessageId: 'inbox-a',
|
inboxMessageId: 'inbox-a',
|
||||||
inboxTimestamp: '2026-04-21T10:00:00.000Z',
|
inboxTimestamp: '2026-04-21T10:00:00.000Z',
|
||||||
source: 'watcher',
|
source: 'watcher',
|
||||||
|
|
@ -203,4 +206,33 @@ describe('TaskLogOpenCodeSessionEvidenceSource', () => {
|
||||||
|
|
||||||
await expect(source.readTaskRecords('team-a', createTask())).resolves.toEqual([]);
|
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 () => {
|
it('preserves agent error semantic kind from the team lead inbox', async () => {
|
||||||
hoisted.files.set(
|
hoisted.files.set(
|
||||||
'/mock/teams/my-team/inboxes/team-lead.json',
|
'/mock/teams/my-team/inboxes/team-lead.json',
|
||||||
|
|
|
||||||
|
|
@ -5426,6 +5426,7 @@ describe('TeamProvisioningService', () => {
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
memberName: String(input.memberName),
|
memberName: String(input.memberName),
|
||||||
sessionId: 'oc-session-bob',
|
sessionId: 'oc-session-bob',
|
||||||
|
runtimePromptMessageId: 'msg_prompt_direct_lane',
|
||||||
runtimePid: 456,
|
runtimePid: 456,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
}));
|
}));
|
||||||
|
|
@ -5507,6 +5508,7 @@ describe('TeamProvisioningService', () => {
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
memberName: String(input.memberName),
|
memberName: String(input.memberName),
|
||||||
sessionId: 'oc-session-bob',
|
sessionId: 'oc-session-bob',
|
||||||
|
runtimePromptMessageId: 'msg_prompt_verified_pid',
|
||||||
runtimePid: 456,
|
runtimePid: 456,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
}));
|
}));
|
||||||
|
|
@ -5632,6 +5634,7 @@ describe('TeamProvisioningService', () => {
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
memberName: String(input.memberName),
|
memberName: String(input.memberName),
|
||||||
sessionId: 'oc-session-bob',
|
sessionId: 'oc-session-bob',
|
||||||
|
runtimePromptMessageId: 'msg_prompt_unverified_pid',
|
||||||
runtimePid: 456,
|
runtimePid: 456,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
}));
|
}));
|
||||||
|
|
@ -5734,6 +5737,7 @@ describe('TeamProvisioningService', () => {
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
memberName: String(input.memberName),
|
memberName: String(input.memberName),
|
||||||
sessionId: 'oc-session-bob',
|
sessionId: 'oc-session-bob',
|
||||||
|
runtimePromptMessageId: 'msg_prompt_snapshot_config',
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
}));
|
}));
|
||||||
svc.setRuntimeAdapterRegistry(
|
svc.setRuntimeAdapterRegistry(
|
||||||
|
|
@ -5824,6 +5828,7 @@ describe('TeamProvisioningService', () => {
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
memberName: String(input.memberName),
|
memberName: String(input.memberName),
|
||||||
sessionId: 'oc-session-bob',
|
sessionId: 'oc-session-bob',
|
||||||
|
runtimePromptMessageId: 'msg_prompt_worktree_cwd',
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
}));
|
}));
|
||||||
svc.setRuntimeAdapterRegistry(
|
svc.setRuntimeAdapterRegistry(
|
||||||
|
|
@ -7071,6 +7076,109 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(ledgerEnvelope.data[0].nextAttemptAt).toBeTruthy();
|
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 () => {
|
it('marks OpenCode payload hash mismatch terminal without sending a duplicate prompt', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
|
@ -8923,6 +9031,7 @@ describe('TeamProvisioningService', () => {
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
memberName: String(input.memberName),
|
memberName: String(input.memberName),
|
||||||
sessionId: 'oc-session-bob',
|
sessionId: 'oc-session-bob',
|
||||||
|
runtimePromptMessageId: 'msg_prompt_after_restart',
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
}));
|
}));
|
||||||
const registry = new TeamRuntimeAdapterRegistry([
|
const registry = new TeamRuntimeAdapterRegistry([
|
||||||
|
|
@ -9015,6 +9124,7 @@ describe('TeamProvisioningService', () => {
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
memberName: String(input.memberName),
|
memberName: String(input.memberName),
|
||||||
sessionId: 'oc-session-bob',
|
sessionId: 'oc-session-bob',
|
||||||
|
runtimePromptMessageId: 'msg_prompt_live_lane',
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
}));
|
}));
|
||||||
svc.setRuntimeAdapterRegistry(
|
svc.setRuntimeAdapterRegistry(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue