diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index 1e60b846..032b6d47 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -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 diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 607b000f..34ff3d3a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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({ diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index d201367d..a10ce3d2 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -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; } diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts index caabdc8a..2951c43c 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts @@ -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', diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index 8a439f52..e1c97f8f 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -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( + 'opencode.sendMessage', + nextBody, + { + cwd: nextBody.projectPath, + timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS, + requestId, + } + ); + + let result: OpenCodeBridgeResult; + 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( command: OpenCodeBridgeCommandName, runId: string, diff --git a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts index 9c1ae0e3..9cd0eab9 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts @@ -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 { 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); } diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts index 03b87095..3fb7549c 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts @@ -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 { @@ -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; } diff --git a/src/main/services/team/taskLogs/stream/TaskLogOpenCodeSessionEvidenceSource.ts b/src/main/services/team/taskLogs/stream/TaskLogOpenCodeSessionEvidenceSource.ts index 4e734563..b617138b 100644 --- a/src/main/services/team/taskLogs/stream/TaskLogOpenCodeSessionEvidenceSource.ts +++ b/src/main/services/team/taskLogs/stream/TaskLogOpenCodeSessionEvidenceSource.ts @@ -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; diff --git a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts index f4a331a3..92e258e0 100644 --- a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts @@ -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 = { + ...rest, + acceptedCommands, + }; + return { + ...withoutHash, + identityHash: createOpenCodeBridgeHandshakeIdentityHash(withoutHash), + }; +} diff --git a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts index 2d5e8bc6..f7f19172 100644 --- a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts +++ b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts @@ -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[]; + }; + 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({ diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index 62915399..f328f48f 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -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, + }; + 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', []), diff --git a/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts b/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts index aa7adb78..5004584f 100644 --- a/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts +++ b/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts @@ -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[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 { @@ -313,6 +379,29 @@ function buildHandshake(input: { }; } +function buildHandshakeWithAcceptedCommands( + input: { + client: OpenCodeBridgePeerIdentity; + server: OpenCodeBridgePeerIdentity; + }, + acceptedCommands: OpenCodeBridgeHandshake['acceptedCommands'] +): OpenCodeBridgeHandshake { + const withoutHash: Omit = { + 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; diff --git a/test/main/services/team/OpenCodeTaskLogStreamSource.fixture-e2e.test.ts b/test/main/services/team/OpenCodeTaskLogStreamSource.fixture-e2e.test.ts index 7c7cd846..8226054d 100644 --- a/test/main/services/team/OpenCodeTaskLogStreamSource.fixture-e2e.test.ts +++ b/test/main/services/team/OpenCodeTaskLogStreamSource.fixture-e2e.test.ts @@ -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, } ); diff --git a/test/main/services/team/TaskLogOpenCodeSessionEvidenceSource.test.ts b/test/main/services/team/TaskLogOpenCodeSessionEvidenceSource.test.ts index 2ff69418..c0b55aae 100644 --- a/test/main/services/team/TaskLogOpenCodeSessionEvidenceSource.test.ts +++ b/test/main/services/team/TaskLogOpenCodeSessionEvidenceSource.test.ts @@ -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', + }), + ]); + }); }); diff --git a/test/main/services/team/TeamInboxReader.test.ts b/test/main/services/team/TeamInboxReader.test.ts index 61622e54..063fa4c4 100644 --- a/test/main/services/team/TeamInboxReader.test.ts +++ b/test/main/services/team/TeamInboxReader.test.ts @@ -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', diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 713ed9cb..66faa4be 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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) => ({ + 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) => ({ @@ -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(