From 85b767e2479ce6ed7472956205dcb5809ee16845 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 19 May 2026 01:27:34 +0300 Subject: [PATCH] fix: harden opencode runtime recovery --- .github/workflows/release.yml | 37 +-- .../services/team/TeamProvisioningService.ts | 47 +++- .../OpenCodeRuntimeDeliveryProofReader.ts | 130 +++++++++-- ...OpenCodeRuntimeDeliveryTaskRefInference.ts | 119 ++++++++++ .../runtime/OpenCodeTeamRuntimeAdapter.ts | 76 +++++- ...OpenCodeRuntimeDeliveryProofReader.test.ts | 202 ++++++++++++++++ ...odeRuntimeDeliveryTaskRefInference.test.ts | 86 +++++++ .../team/OpenCodeTeamRuntimeAdapter.test.ts | 220 ++++++++++++++++++ .../TeamMemberRuntimeAdvisoryService.test.ts | 148 ++++++++++++ .../team/TeamProvisioningServiceRelay.test.ts | 50 ++++ 10 files changed, 1075 insertions(+), 40 deletions(-) create mode 100644 src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryTaskRefInference.ts create mode 100644 test/main/services/team/OpenCodeRuntimeDeliveryProofReader.test.ts create mode 100644 test/main/services/team/OpenCodeRuntimeDeliveryTaskRefInference.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19e2418f..aaa7f639 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -670,7 +670,7 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }} steps: - - name: Upload stable-named assets for /latest/download links + - name: Upload compatibility aliases for older updater clients env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -681,7 +681,7 @@ jobs: TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT - declare -A FILES=( + declare -A COMPATIBILITY_ALIASES=( ["Claude-Agent-Teams-UI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg" ["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg" ["Claude-Agent-Teams-UI-Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe" @@ -691,17 +691,16 @@ jobs: ["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman" ) - # Download versioned files and re-upload with stable names - for STABLE_NAME in "${!FILES[@]}"; do - VERSIONED_NAME="${FILES[$STABLE_NAME]}" - echo "Downloading ${VERSIONED_NAME} -> ${STABLE_NAME}" + for ALIAS_NAME in "${!COMPATIBILITY_ALIASES[@]}"; do + VERSIONED_NAME="${COMPATIBILITY_ALIASES[$ALIAS_NAME]}" + echo "Uploading compatibility alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}" gh release download "${TAG}" \ --repo "$REPO" \ --pattern "${VERSIONED_NAME}" \ --dir "$TMP_DIR" \ --clobber - cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${STABLE_NAME}" - gh release upload "${TAG}" "${TMP_DIR}/${STABLE_NAME}" --repo "$REPO" --clobber + cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${ALIAS_NAME}" + gh release upload "${TAG}" "${TMP_DIR}/${ALIAS_NAME}" --repo "$REPO" --clobber done - name: Publish canonical updater metadata @@ -734,31 +733,33 @@ jobs: } # Canonical Windows feed - download_asset "Claude-Agent-Teams-UI-Setup.exe" - WIN_SHA="$(sha512_base64 "Claude-Agent-Teams-UI-Setup.exe")" - WIN_SIZE="$(file_size "Claude-Agent-Teams-UI-Setup.exe")" + WIN_ASSET="Agent.Teams.AI.Setup.${VERSION}.exe" + download_asset "${WIN_ASSET}" + WIN_SHA="$(sha512_base64 "${WIN_ASSET}")" + WIN_SIZE="$(file_size "${WIN_ASSET}")" cat > latest.yml < latest-linux.yml < Promise + ): Promise { + if (Array.isArray(message.taskRefs) && message.taskRefs.length > 0) { + return message.taskRefs; + } + + const tasks = await (readTasks?.() ?? new TeamTaskReader().getTasks(teamName).catch(() => [])); + if (tasks.length === 0) { + return []; + } + + return inferOpenCodeTaskRefsFromInboxMessage({ + teamName, + message, + tasks, + }); + } + async relayOpenCodeMemberInboxMessages( teamName: string, memberName: string, @@ -23936,6 +23958,12 @@ export class TeamProvisioningService { }) .slice(0, 10); + let taskRefInferenceTasks: Promise | null = null; + const readTaskRefInferenceTasks = (): Promise => { + taskRefInferenceTasks ??= new TeamTaskReader().getTasks(teamName).catch(() => []); + return taskRefInferenceTasks; + }; + for (const message of unread) { let existingRecord = await promptLedger .getByInboxMessage({ @@ -24069,8 +24097,23 @@ export class TeamProvisioningService { options.deliveryMetadata?.actionMode ?? message.actionMode ?? null; + const existingTaskRefs = existingRecord?.taskRefs?.length + ? existingRecord.taskRefs + : undefined; + const metadataTaskRefs = options.deliveryMetadata?.taskRefs?.length + ? options.deliveryMetadata.taskRefs + : undefined; + const messageTaskRefs = message.taskRefs?.length ? message.taskRefs : undefined; + const inferredTaskRefs = + existingTaskRefs || metadataTaskRefs || messageTaskRefs + ? [] + : await this.inferOpenCodeInboxMessageTaskRefs( + teamName, + message, + readTaskRefInferenceTasks + ); const effectiveTaskRefs = - existingRecord?.taskRefs ?? options.deliveryMetadata?.taskRefs ?? message.taskRefs ?? []; + existingTaskRefs ?? metadataTaskRefs ?? messageTaskRefs ?? inferredTaskRefs; const effectiveSource = existingRecord?.source ?? options.source ?? 'watcher'; result.attempted += 1; const attachmentPayloads = await this.resolveOpenCodeInboxAttachmentPayloads({ diff --git a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryProofReader.ts b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryProofReader.ts index 6a80919e..80f0be51 100644 --- a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryProofReader.ts +++ b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryProofReader.ts @@ -7,6 +7,7 @@ import { TeamTaskReader } from '../../TeamTaskReader'; import { getOpenCodeRuntimeDeliveryPromptTimeMs, getOpenCodeRuntimeDeliveryRecordTimeMs, + isPotentialOpenCodeRuntimeDeliveryError, isTerminalSuccessfulOpenCodeDeliveryRecord, type OpenCodeRuntimeDeliveryProofSnapshot, } from './OpenCodeRuntimeDeliveryAdvisoryPolicy'; @@ -17,9 +18,10 @@ import { normalizeOpenCodeRuntimeDeliveryToken, openCodeTaskRefsIncludeAll, } from './OpenCodeRuntimeDeliveryProofMatching'; +import { inferOpenCodeTaskRefsFromInboxMessage } from './OpenCodeRuntimeDeliveryTaskRefInference'; import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger'; -import type { InboxMessage, TeamConfig, TeamTask } from '@shared/types'; +import type { InboxMessage, TaskRef, TeamConfig, TeamTask } from '@shared/types'; const PROOF_READ_CONCURRENCY = 4; @@ -100,7 +102,8 @@ class MaterializedOpenCodeRuntimeDeliveryProofIndex implements OpenCodeRuntimeDe constructor( private readonly latestSuccessTimesByMember: ReadonlyMap, private readonly visibleRepliesByMember: ReadonlyMap, - private readonly taskProgressTimes: ReadonlyMap + private readonly taskProgressTimes: ReadonlyMap, + private readonly inferredTaskRefsByRecordId: ReadonlyMap ) {} getSnapshot( @@ -149,13 +152,25 @@ class MaterializedOpenCodeRuntimeDeliveryProofIndex implements OpenCodeRuntimeDe }) ?? null; } - if (!visibleReply && record.taskRefs.length > 0) { + const taskRefs = + record.taskRefs.length > 0 + ? record.taskRefs + : (this.inferredTaskRefsByRecordId.get(record.id) ?? []); + + if (!visibleReply && taskRefs.length > 0) { + const recordWithTaskRefs = + taskRefs === record.taskRefs + ? record + : { + ...record, + taskRefs: [...taskRefs], + }; visibleReply = visibleReplies .filter((candidate) => isOpenCodeRecoveredVisibleReplyCandidate({ message: candidate.message, - ledgerRecord: record, + ledgerRecord: recordWithTaskRefs, from: memberName, requireTaskRefs: true, }) @@ -164,7 +179,7 @@ class MaterializedOpenCodeRuntimeDeliveryProofIndex implements OpenCodeRuntimeDe } let taskProgressAt = 0; - for (const taskRef of record.taskRefs) { + for (const taskRef of taskRefs) { const taskId = taskRef.taskId?.trim(); if (!taskId) { continue; @@ -197,20 +212,17 @@ export class OpenCodeRuntimeDeliveryProofReader { async readProofIndex( input: OpenCodeRuntimeDeliveryProofReaderInput ): Promise { - const [configuredLeadName, visibleRepliesByMember, taskProgressTimes] = await Promise.all([ + const [configuredLeadName, visibleRepliesByMember, taskProgressProof] = await Promise.all([ this.readConfiguredLeadName(input.teamName), this.readVisibleRepliesByMember(input), - this.readTaskProgressProofTimes( - input.teamName, - input.activeMemberKeys, - input.recordsByMember - ), + this.readTaskProgressProof(input.teamName, input.activeMemberKeys, input.recordsByMember), ]); return new MaterializedOpenCodeRuntimeDeliveryProofIndex( this.readLatestSuccessTimesByMember(input.activeMemberKeys, input.recordsByMember), await visibleRepliesByMember(configuredLeadName), - taskProgressTimes + taskProgressProof.taskProgressTimes, + taskProgressProof.inferredTaskRefsByRecordId ); } @@ -307,17 +319,24 @@ export class OpenCodeRuntimeDeliveryProofReader { return result; } - private async readTaskProgressProofTimes( + private async readTaskProgressProof( teamName: string, activeMemberKeys: ReadonlySet, recordsByMember: ReadonlyMap - ): Promise> { + ): Promise<{ + taskProgressTimes: Map; + inferredTaskRefsByRecordId: Map; + }> { const taskIdsByMember = new Map>(); + const recordsMissingTaskRefs: OpenCodePromptDeliveryLedgerRecord[] = []; for (const [memberKey, records] of recordsByMember) { if (!activeMemberKeys.has(memberKey)) { continue; } for (const record of records) { + if (record.taskRefs.length === 0 && isPotentialOpenCodeRuntimeDeliveryError(record)) { + recordsMissingTaskRefs.push(record); + } for (const taskRef of record.taskRefs) { const taskId = taskRef.taskId?.trim(); if (!taskId) { @@ -329,16 +348,42 @@ export class OpenCodeRuntimeDeliveryProofReader { } } } - if (taskIdsByMember.size === 0) { - return new Map(); + + if (taskIdsByMember.size === 0 && recordsMissingTaskRefs.length === 0) { + return { taskProgressTimes: new Map(), inferredTaskRefsByRecordId: new Map() }; } const tasks = await this.taskReader.getTasks(teamName).catch(() => []); if (tasks.length === 0) { - return new Map(); + return { taskProgressTimes: new Map(), inferredTaskRefsByRecordId: new Map() }; } - const result = new Map(); + const inferredTaskRefsByRecordId = await this.inferTaskRefsForRecordsMissingTaskRefs({ + teamName, + records: recordsMissingTaskRefs, + tasks, + }); + for (const record of recordsMissingTaskRefs) { + const memberKey = normalizeOpenCodeRuntimeDeliveryToken(record.memberName); + if (!activeMemberKeys.has(memberKey)) { + continue; + } + for (const taskRef of inferredTaskRefsByRecordId.get(record.id) ?? []) { + const taskId = taskRef.taskId?.trim(); + if (!taskId) { + continue; + } + const taskIds = taskIdsByMember.get(memberKey) ?? new Set(); + taskIds.add(taskId); + taskIdsByMember.set(memberKey, taskIds); + } + } + + if (taskIdsByMember.size === 0) { + return { taskProgressTimes: new Map(), inferredTaskRefsByRecordId }; + } + + const taskProgressTimes = new Map(); for (const task of tasks) { const taskId = task.id?.trim(); if (!taskId) { @@ -353,9 +398,56 @@ export class OpenCodeRuntimeDeliveryProofReader { continue; } const key = getOpenCodeTaskProgressProofKey(memberKey, taskId); - result.set(key, Math.max(result.get(key) ?? 0, proofAt)); + taskProgressTimes.set(key, Math.max(taskProgressTimes.get(key) ?? 0, proofAt)); } } + return { taskProgressTimes, inferredTaskRefsByRecordId }; + } + + private async inferTaskRefsForRecordsMissingTaskRefs(input: { + teamName: string; + records: readonly OpenCodePromptDeliveryLedgerRecord[]; + tasks: readonly TeamTask[]; + }): Promise> { + const result = new Map(); + if (input.records.length === 0) { + return result; + } + + const inboxMessagesByMember = new Map>(); + const getInboxMessages = (memberName: string): Promise => { + const memberKey = normalizeOpenCodeRuntimeDeliveryToken(memberName); + const existing = inboxMessagesByMember.get(memberKey); + if (existing) { + return existing; + } + const request = this.inboxReader + .getMessagesFor(input.teamName, memberName) + .catch(() => [] as InboxMessage[]); + inboxMessagesByMember.set(memberKey, request); + return request; + }; + + await mapLimit(input.records, PROOF_READ_CONCURRENCY, async (record) => { + const inboxMessageId = record.inboxMessageId?.trim(); + if (!inboxMessageId) { + return; + } + const messages = await getInboxMessages(record.memberName); + const message = messages.find((candidate) => candidate.messageId?.trim() === inboxMessageId); + if (!message) { + return; + } + const inferred = inferOpenCodeTaskRefsFromInboxMessage({ + teamName: input.teamName, + message, + tasks: input.tasks, + }); + if (inferred.length > 0) { + result.set(record.id, inferred); + } + }); + return result; } } diff --git a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryTaskRefInference.ts b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryTaskRefInference.ts new file mode 100644 index 00000000..1001d33b --- /dev/null +++ b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryTaskRefInference.ts @@ -0,0 +1,119 @@ +import { getTaskDisplayId } from '@shared/utils/taskIdentity'; + +import type { InboxMessage, TaskRef, TeamTask } from '@shared/types'; + +function normalizeTaskRefs(taskRefs: readonly TaskRef[] | undefined): TaskRef[] { + if (!Array.isArray(taskRefs) || taskRefs.length === 0) { + return []; + } + const normalized: TaskRef[] = []; + for (const rawTaskRef of taskRefs as readonly unknown[]) { + if (!rawTaskRef || typeof rawTaskRef !== 'object') { + continue; + } + const taskRef = rawTaskRef as Record; + const teamName = typeof taskRef.teamName === 'string' ? taskRef.teamName.trim() : ''; + const taskId = typeof taskRef.taskId === 'string' ? taskRef.taskId.trim() : ''; + const displayId = typeof taskRef.displayId === 'string' ? taskRef.displayId.trim() : ''; + if (teamName && taskId && displayId) { + normalized.push({ teamName, taskId, displayId }); + } + } + return normalized; +} + +function extractTaskReferenceTokens(text: string): Set { + const tokens = new Set(); + for (const match of text.matchAll(/#([A-Za-z0-9][A-Za-z0-9_-]*)/g)) { + const token = match[1]?.trim().toLowerCase(); + if (token) { + tokens.add(token); + } + } + for (const match of text.matchAll( + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi + )) { + const token = match[0]?.trim().toLowerCase(); + if (token) { + tokens.add(token); + } + } + return tokens; +} + +function taskRefForTask(teamName: string, task: Pick): TaskRef { + return { + teamName, + taskId: task.id.trim(), + displayId: getTaskDisplayId(task), + }; +} + +function findUniqueTaskRefInText(input: { + teamName: string; + text: string; + tasks: readonly Pick[]; +}): TaskRef[] { + const tokens = extractTaskReferenceTokens(input.text); + if (tokens.size === 0) { + return []; + } + + const matches = new Map(); + for (const task of input.tasks) { + const taskId = task.id?.trim(); + if (!taskId) { + continue; + } + const displayId = getTaskDisplayId(task); + if (tokens.has(taskId.toLowerCase()) || tokens.has(displayId.toLowerCase())) { + matches.set(taskId, taskRefForTask(input.teamName, task)); + } + } + + return matches.size === 1 ? Array.from(matches.values()) : []; +} + +function getCommentHeadingText(text: string): string { + const firstLine = text + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + if (!firstLine) { + return ''; + } + return /^\**Comment on(?: task)? #/i.test(firstLine) ? firstLine : ''; +} + +export function inferOpenCodeTaskRefsFromInboxMessage(input: { + teamName: string; + message: Pick; + tasks: readonly Pick[]; +}): TaskRef[] { + const structured = normalizeTaskRefs(input.message.taskRefs); + if (structured.length > 0 || input.tasks.length === 0) { + return structured; + } + + const summary = input.message.summary?.trim() ?? ''; + const heading = getCommentHeadingText(input.message.text ?? ''); + const messageId = input.message.messageId?.trim() ?? ''; + const commentId = input.message.commentId?.trim() ?? ''; + const text = input.message.text?.trim() ?? ''; + + for (const candidate of [summary, heading, messageId, commentId, text]) { + if (!candidate) { + continue; + } + const inferred = findUniqueTaskRefInText({ + teamName: input.teamName, + text: candidate, + tasks: input.tasks, + }); + if (inferred.length > 0) { + return inferred; + } + } + + return []; +} diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 51563623..52d21fa0 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -107,6 +107,66 @@ const OPEN_CODE_CAPABILITY_SNAPSHOT_PRELAUNCH_MISMATCH_MARKERS = [ 'Bridge server capability snapshot mismatch', 'OpenCode bridge capability snapshot precondition mismatch', ]; +const OPEN_CODE_READINESS_RETRY_DELAYS_MS = [750, 2_000] as const; + +type OpenCodeTeamLaunchReadinessInput = Parameters< + OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness'] +>[0]; + +function getOpenCodeReadinessDiagnosticText(readiness: OpenCodeTeamLaunchReadiness): string { + return [...readiness.diagnostics, ...readiness.missing].join('\n'); +} + +function isTransientOpenCodeReadinessTransportFailure( + readiness: OpenCodeTeamLaunchReadiness +): boolean { + if (readiness.launchAllowed) { + return false; + } + if (readiness.state !== 'mcp_unavailable' && readiness.state !== 'unknown_error') { + return false; + } + + const diagnosticText = getOpenCodeReadinessDiagnosticText(readiness).toLowerCase(); + if (!diagnosticText) { + return false; + } + + const hasHardFailureMarker = + /\b(?:401|403)\b/.test(diagnosticText) || + diagnosticText.includes('unauthorized') || + diagnosticText.includes('forbidden') || + diagnosticText.includes('missing canonical app mcp tool id') || + diagnosticText.includes('observed alias') || + diagnosticText.includes('app mcp tool missing') || + diagnosticText.includes('tool is absent') || + diagnosticText.includes('missing required field') || + diagnosticText.includes('runtime store') || + diagnosticText.includes('capability snapshot') || + diagnosticText.includes('contract') || + diagnosticText.includes('schema') || + diagnosticText.includes('invalid input') || + /\b(?:404|405)\b/.test(diagnosticText) || + diagnosticText.includes('not found'); + if (hasHardFailureMarker) { + return false; + } + + return ( + diagnosticText.includes('unable to connect') || + diagnosticText.includes('socket connection was closed') || + diagnosticText.includes('fetch failed') || + diagnosticText.includes('econnreset') || + diagnosticText.includes('econnrefused') || + diagnosticText.includes('socket hang up') || + diagnosticText.includes('networkerror') || + diagnosticText.includes('/experimental/tool/ids unavailable') + ); +} + +function sleepOpenCodeReadinessRetry(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} function resolveOpenCodeRuntimeSettlementMode( input: Pick @@ -123,7 +183,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { async prepare(input: TeamRuntimeLaunchInput): Promise { const runtimeOnly = input.runtimeOnly === true; - const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({ + const readiness = await this.checkOpenCodeReadinessWithTransientRetry({ projectPath: input.cwd, selectedModel: input.model ?? null, requireExecutionProbe: !runtimeOnly, @@ -154,6 +214,20 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { return this.lastReadinessByProjectPath.get(projectPath) ?? null; } + private async checkOpenCodeReadinessWithTransientRetry( + input: OpenCodeTeamLaunchReadinessInput + ): Promise { + let readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness(input); + for (const delayMs of OPEN_CODE_READINESS_RETRY_DELAYS_MS) { + if (!isTransientOpenCodeReadinessTransportFailure(readiness)) { + return readiness; + } + await sleepOpenCodeReadinessRetry(delayMs); + readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness(input); + } + return readiness; + } + async launch(input: TeamRuntimeLaunchInput): Promise { const memberValidationDiagnostics = validateOpenCodeRuntimeMembers( input.expectedMembers, diff --git a/test/main/services/team/OpenCodeRuntimeDeliveryProofReader.test.ts b/test/main/services/team/OpenCodeRuntimeDeliveryProofReader.test.ts new file mode 100644 index 00000000..6466a9b9 --- /dev/null +++ b/test/main/services/team/OpenCodeRuntimeDeliveryProofReader.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { OpenCodeRuntimeDeliveryProofReader } from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryProofReader'; + +import type { OpenCodePromptDeliveryLedgerRecord } from '../../../../src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger'; +import type { InboxMessage, TaskRef, TeamTask } from '../../../../src/shared/types/team'; + +const TEAM_NAME = 'relay-works-69'; +const TARGET_TASK_ID = 'a7fd5f34-ff82-4ead-8089-34064454a623'; +const OTHER_TASK_ID = '8dc34135-1111-4111-8111-8dc341350000'; +const TARGET_TASK_REF: TaskRef = { + teamName: TEAM_NAME, + taskId: TARGET_TASK_ID, + displayId: 'a7fd5f34', +}; +const OTHER_TASK_REF: TaskRef = { + teamName: TEAM_NAME, + taskId: OTHER_TASK_ID, + displayId: '8dc34135', +}; + +function createLedgerRecord(): OpenCodePromptDeliveryLedgerRecord { + return { + id: 'opencode-prompt:dependency-comment', + teamName: TEAM_NAME, + memberName: 'tom', + laneId: 'secondary:opencode:tom', + runId: 'run-1', + runtimeSessionId: 'ses-1', + inboxMessageId: 'dependency-comment-1', + inboxTimestamp: '2026-05-18T21:25:05.428Z', + source: 'watcher', + messageKind: null, + replyRecipient: 'team-lead', + actionMode: null, + taskRefs: [], + payloadHash: 'sha256:test', + status: 'failed_terminal', + responseState: 'session_stale', + attempts: 1, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: '2026-05-18T21:25:27.592Z', + lastObservedAt: '2026-05-18T21:27:58.582Z', + acceptedAt: '2026-05-18T21:25:27.592Z', + respondedAt: null, + failedAt: '2026-05-18T21:27:58.582Z', + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: null, + postPromptCursor: null, + deliveredUserMessageId: 'delivered-1', + observedAssistantMessageId: null, + observedAssistantPreview: null, + observedToolCallNames: [], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: 'opencode_session_stale_observe_loop_after_accepted_prompt', + diagnostics: [ + 'OpenCode API error', + 'OpenCode session stayed stale while observing an accepted prompt after 5 attempt(s).', + ], + createdAt: '2026-05-18T21:25:05.428Z', + updatedAt: '2026-05-18T21:27:58.582Z', + }; +} + +function createDependencyInboxMessage(): InboxMessage { + return { + from: 'team-lead', + to: 'tom', + text: [ + '**Comment on task #a7fd5f34** _Calculator styles_', + '', + '> **Dependency resolved** - task #8dc34135 completed.', + '> All blockers for #a7fd5f34 are resolved - this task is ready to start.', + ].join('\n'), + timestamp: '2026-05-18T21:25:05.428Z', + read: false, + summary: 'Comment on #a7fd5f34', + messageId: 'dependency-comment-1', + source: 'system_notification', + }; +} + +function createRuntimeReply(taskRefs: TaskRef[]): InboxMessage { + return { + from: 'tom', + to: 'team-lead', + text: 'Done and verified.', + timestamp: '2026-05-18T21:25:45.000Z', + read: false, + summary: 'Done', + messageId: `reply-${taskRefs[0]?.displayId ?? 'none'}`, + source: 'runtime_delivery', + taskRefs, + }; +} + +function createProofReader(leadInboxMessages: InboxMessage[]): OpenCodeRuntimeDeliveryProofReader { + const inboxReader = { + getMessagesFor: vi.fn((_teamName: string, inboxName: string) => { + if (inboxName === 'tom') { + return Promise.resolve([createDependencyInboxMessage()]); + } + if (inboxName === 'team-lead') { + return Promise.resolve(leadInboxMessages); + } + return Promise.resolve([]); + }), + }; + const taskReader = { + getTasks: vi.fn(() => + Promise.resolve([ + { id: TARGET_TASK_ID, displayId: 'a7fd5f34' }, + { id: OTHER_TASK_ID, displayId: '8dc34135' }, + ] as TeamTask[]) + ), + }; + const configReader = { + getConfigSnapshot: vi.fn(() => + Promise.resolve({ + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }) + ), + }; + + return new OpenCodeRuntimeDeliveryProofReader( + inboxReader as never, + taskReader as never, + configReader as never + ); +} + +describe('OpenCodeRuntimeDeliveryProofReader', () => { + it('matches visible replies using task refs inferred from the original inbox message', async () => { + const record = createLedgerRecord(); + const proofIndex = await createProofReader([ + createRuntimeReply([TARGET_TASK_REF]), + ]).readProofIndex({ + teamName: TEAM_NAME, + activeMemberKeys: new Set(['tom']), + recordsByMember: new Map([['tom', [record]]]), + }); + + expect(proofIndex.getSnapshot('tom', record).visibleReplyMessageId).toBe('reply-a7fd5f34'); + }); + + it('does not treat a reply for another task as proof for an inferred task ref', async () => { + const record = createLedgerRecord(); + const proofIndex = await createProofReader([ + createRuntimeReply([OTHER_TASK_REF]), + ]).readProofIndex({ + teamName: TEAM_NAME, + activeMemberKeys: new Set(['tom']), + recordsByMember: new Map([['tom', [record]]]), + }); + + expect(proofIndex.getSnapshot('tom', record).visibleReplyMessageId).toBeUndefined(); + }); + + it('does not infer task refs for non-error records', async () => { + const record: OpenCodePromptDeliveryLedgerRecord = { + ...createLedgerRecord(), + status: 'pending', + responseState: 'pending', + failedAt: null, + lastReason: null, + diagnostics: [], + }; + const inboxReader = { + getMessagesFor: vi.fn(() => Promise.resolve([] as InboxMessage[])), + }; + const taskReader = { + getTasks: vi.fn(() => Promise.resolve([] as TeamTask[])), + }; + const configReader = { + getConfigSnapshot: vi.fn(() => + Promise.resolve({ + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }) + ), + }; + + const proofIndex = await new OpenCodeRuntimeDeliveryProofReader( + inboxReader as never, + taskReader as never, + configReader as never + ).readProofIndex({ + teamName: TEAM_NAME, + activeMemberKeys: new Set(['tom']), + recordsByMember: new Map([['tom', [record]]]), + }); + + expect(proofIndex.getSnapshot('tom', record).taskProgressAt).toBeUndefined(); + expect(taskReader.getTasks).not.toHaveBeenCalled(); + expect(inboxReader.getMessagesFor).not.toHaveBeenCalledWith(TEAM_NAME, 'tom'); + }); +}); diff --git a/test/main/services/team/OpenCodeRuntimeDeliveryTaskRefInference.test.ts b/test/main/services/team/OpenCodeRuntimeDeliveryTaskRefInference.test.ts new file mode 100644 index 00000000..19e21098 --- /dev/null +++ b/test/main/services/team/OpenCodeRuntimeDeliveryTaskRefInference.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { inferOpenCodeTaskRefsFromInboxMessage } from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryTaskRefInference'; + +const TEAM_NAME = 'relay-works-69'; +const TASKS = [ + { id: 'a7fd5f34-ff82-4ead-8089-34064454a623', displayId: 'a7fd5f34' }, + { id: '8dc34135-1111-4111-8111-8dc341350000', displayId: '8dc34135' }, + { id: '1', displayId: '1' }, +]; + +describe('inferOpenCodeTaskRefsFromInboxMessage', () => { + it('preserves structured task refs', () => { + const structured = [{ teamName: TEAM_NAME, taskId: 'task-1', displayId: 'abcd1234' }]; + + expect( + inferOpenCodeTaskRefsFromInboxMessage({ + teamName: TEAM_NAME, + message: { + text: 'Ignore text #a7fd5f34.', + taskRefs: structured, + }, + tasks: TASKS, + }) + ).toEqual(structured); + }); + + it('uses the summary before ambiguous full text', () => { + expect( + inferOpenCodeTaskRefsFromInboxMessage({ + teamName: TEAM_NAME, + message: { + text: [ + '**Comment on task #a7fd5f34** _Calculator styles_', + '', + '> **Dependency resolved** - task #8dc34135 completed.', + '> All blockers for #a7fd5f34 are resolved - this task is ready to start.', + ].join('\n'), + summary: 'Comment on #a7fd5f34', + }, + tasks: TASKS, + }) + ).toEqual([{ teamName: TEAM_NAME, taskId: TASKS[0].id, displayId: 'a7fd5f34' }]); + }); + + it('uses a comment heading when the summary is missing', () => { + expect( + inferOpenCodeTaskRefsFromInboxMessage({ + teamName: TEAM_NAME, + message: { + text: [ + '**Comment on #a7fd5f34** _Calculator styles_', + '', + 'Dependency #8dc34135 is resolved.', + ].join('\n'), + }, + tasks: TASKS, + }) + ).toEqual([{ teamName: TEAM_NAME, taskId: TASKS[0].id, displayId: 'a7fd5f34' }]); + }); + + it('does not infer from ambiguous text without a unique candidate field', () => { + expect( + inferOpenCodeTaskRefsFromInboxMessage({ + teamName: TEAM_NAME, + message: { + text: 'Dependency resolved: #8dc34135 unblocks #a7fd5f34.', + }, + tasks: TASKS, + }) + ).toEqual([]); + }); + + it('supports short display ids', () => { + expect( + inferOpenCodeTaskRefsFromInboxMessage({ + teamName: TEAM_NAME, + message: { + summary: 'Comment on #1', + text: 'Ready.', + }, + tasks: TASKS, + }) + ).toEqual([{ teamName: TEAM_NAME, taskId: '1', displayId: '1' }]); + }); +}); diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 93ad5984..c95a6329 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -36,6 +36,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { selectedModel: 'openai/gpt-5.4-mini', requireExecutionProbe: true, }); + expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledTimes(1); }); it('uses runtime-only readiness for model-less preflight checks', async () => { @@ -156,6 +157,225 @@ describe('OpenCodeTeamRuntimeAdapter', () => { ); }); + it('retries transient MCP readiness transport failures before prepare succeeds', async () => { + const firstReadiness = readiness({ + state: 'mcp_unavailable', + launchAllowed: false, + diagnostics: [ + 'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?', + ], + missing: ['runtime_deliver_message'], + }); + const finalReadiness = readiness({ + state: 'ready', + launchAllowed: true, + diagnostics: ['OpenCode readiness recovered'], + }); + const checkReadiness = vi + .fn() + .mockResolvedValueOnce(firstReadiness) + .mockResolvedValueOnce(finalReadiness); + const adapter = new OpenCodeTeamRuntimeAdapter({ + checkOpenCodeTeamLaunchReadiness: checkReadiness, + }); + + vi.useFakeTimers(); + try { + const resultPromise = adapter.prepare(launchInput()); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(750); + + await expect(resultPromise).resolves.toEqual({ + ok: true, + providerId: 'opencode', + modelId: 'openai/gpt-5.4-mini', + diagnostics: ['OpenCode readiness recovered'], + warnings: [], + }); + } finally { + vi.useRealTimers(); + } + + expect(checkReadiness).toHaveBeenCalledTimes(2); + expect(adapter.getLastOpenCodeTeamLaunchReadiness('/repo')).toBe(finalReadiness); + }); + + it('retries unknown readiness failures only when diagnostics show transport failure', async () => { + const checkReadiness = vi + .fn() + .mockResolvedValueOnce( + readiness({ + state: 'unknown_error', + launchAllowed: false, + diagnostics: ['OpenCode readiness bridge failed: fetch failed'], + }) + ) + .mockResolvedValueOnce(readiness({ state: 'ready', launchAllowed: true })); + const adapter = new OpenCodeTeamRuntimeAdapter({ + checkOpenCodeTeamLaunchReadiness: checkReadiness, + }); + + vi.useFakeTimers(); + try { + const resultPromise = adapter.prepare(launchInput()); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(750); + + await expect(resultPromise).resolves.toMatchObject({ + ok: true, + providerId: 'opencode', + }); + } finally { + vi.useRealTimers(); + } + + expect(checkReadiness).toHaveBeenCalledTimes(2); + }); + + it('returns the final readiness failure after transient retries are exhausted', async () => { + const finalReadiness = readiness({ + state: 'mcp_unavailable', + launchAllowed: false, + diagnostics: ['Final OpenCode /experimental/tool/ids unavailable - ECONNRESET'], + missing: ['final transport missing'], + }); + const checkReadiness = vi + .fn() + .mockResolvedValueOnce( + readiness({ + state: 'mcp_unavailable', + launchAllowed: false, + diagnostics: ['First OpenCode /experimental/tool/ids unavailable - Unable to connect'], + }) + ) + .mockResolvedValueOnce( + readiness({ + state: 'unknown_error', + launchAllowed: false, + diagnostics: ['Second OpenCode readiness bridge failed: socket hang up'], + }) + ) + .mockResolvedValueOnce(finalReadiness); + const adapter = new OpenCodeTeamRuntimeAdapter({ + checkOpenCodeTeamLaunchReadiness: checkReadiness, + }); + + vi.useFakeTimers(); + try { + const resultPromise = adapter.prepare(launchInput()); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(750); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(resultPromise).resolves.toEqual({ + ok: false, + providerId: 'opencode', + reason: 'mcp_unavailable', + retryable: true, + diagnostics: [ + 'Final OpenCode /experimental/tool/ids unavailable - ECONNRESET', + 'final transport missing', + ], + warnings: [], + }); + } finally { + vi.useRealTimers(); + } + + expect(checkReadiness).toHaveBeenCalledTimes(3); + expect(adapter.getLastOpenCodeTeamLaunchReadiness('/repo')).toBe(finalReadiness); + }); + + it.each([ + { + state: 'not_authenticated' as const, + diagnostics: ['OpenCode provider returned 401 unauthorized'], + }, + { + state: 'not_installed' as const, + diagnostics: ['OpenCode runtime binary is not installed'], + }, + { + state: 'model_unavailable' as const, + diagnostics: ['Selected model is unavailable'], + }, + { + state: 'mcp_unavailable' as const, + diagnostics: ['OpenCode /experimental/tool/ids unavailable - HTTP 403 forbidden'], + }, + { + state: 'mcp_unavailable' as const, + diagnostics: ['OpenCode /experimental/tool/ids unavailable - HTTP 404 Not Found'], + }, + { + state: 'mcp_unavailable' as const, + diagnostics: [ + 'OpenCode /experimental/tool/ids unavailable - fetch failed', + 'App MCP tool missing: runtime_deliver_message', + ], + }, + { + state: 'unknown_error' as const, + diagnostics: ['OpenCode bridge contract violation: schema mismatch'], + }, + ])('does not retry $state readiness failures', async ({ state, diagnostics }) => { + const checkReadiness = vi + .fn() + .mockResolvedValue(readiness({ state, launchAllowed: false, diagnostics })); + const adapter = new OpenCodeTeamRuntimeAdapter({ + checkOpenCodeTeamLaunchReadiness: checkReadiness, + }); + + await expect(adapter.prepare(launchInput())).resolves.toMatchObject({ + ok: false, + reason: state, + }); + expect(checkReadiness).toHaveBeenCalledTimes(1); + }); + + it('launch retries readiness before bridge launch and uses the fresh runtime snapshot', async () => { + const checkReadiness = vi + .fn() + .mockResolvedValueOnce( + readiness({ + state: 'mcp_unavailable', + launchAllowed: false, + diagnostics: ['OpenCode /experimental/tool/ids unavailable - Unable to connect'], + }) + ) + .mockResolvedValueOnce(readiness({ state: 'ready', launchAllowed: true })); + const getLastOpenCodeRuntimeSnapshot = vi.fn(() => runtimeSnapshot('cap-fresh')); + const launchOpenCodeTeam = vi.fn< + NonNullable + >(() => Promise.resolve(successfulOpenCodeLaunchData())); + const adapter = new OpenCodeTeamRuntimeAdapter({ + checkOpenCodeTeamLaunchReadiness: checkReadiness, + getLastOpenCodeRuntimeSnapshot, + launchOpenCodeTeam, + }); + + vi.useFakeTimers(); + try { + const resultPromise = adapter.launch(launchInput()); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(750); + + await expect(resultPromise).resolves.toMatchObject({ + teamLaunchState: 'clean_success', + }); + } finally { + vi.useRealTimers(); + } + + expect(checkReadiness).toHaveBeenCalledTimes(2); + expect(getLastOpenCodeRuntimeSnapshot).toHaveBeenCalledWith('/repo'); + expect(launchOpenCodeTeam).toHaveBeenCalledWith( + expect.objectContaining({ + expectedCapabilitySnapshotId: 'cap-fresh', + }) + ); + }); + it('uses concrete member diagnostics as failed OpenCode hard failure reasons', async () => { const concreteReason = 'Latest assistant message msg_123 failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits'; diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts index d3e64592..c891e7b4 100644 --- a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -787,6 +787,154 @@ describe('TeamMemberRuntimeAdvisoryService', () => { expect(advisory).toBeNull(); }); + it('suppresses stale OpenCode advisories when task refs can be inferred from the inbox comment', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-18T21:35:00.000Z')); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'relay-works-69'; + const laneId = 'secondary:opencode:tom'; + const taskId = 'a7fd5f34-ff82-4ead-8089-34064454a623'; + const laneDir = path.join( + tmpDir, + 'teams', + teamName, + '.opencode-runtime', + 'lanes', + encodeURIComponent(laneId) + ); + await fs.mkdir(laneDir, { recursive: true }); + await fs.mkdir(path.join(tmpDir, 'teams', teamName, 'inboxes'), { recursive: true }); + await fs.mkdir(path.join(tmpDir, 'tasks', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'), + JSON.stringify({ + version: 1, + updatedAt: '2026-05-18T21:27:58.582Z', + lanes: { + [laneId]: { laneId, state: 'active', updatedAt: '2026-05-18T21:27:58.582Z' }, + }, + }), + 'utf8' + ); + await fs.writeFile( + path.join(laneDir, 'opencode-prompt-delivery-ledger.json'), + JSON.stringify({ + schemaVersion: 1, + updatedAt: '2026-05-18T21:27:58.582Z', + data: [ + { + id: 'opencode-prompt:dependency-comment', + teamName, + memberName: 'tom', + laneId, + runId: 'run-1', + runtimeSessionId: 'ses-1', + inboxMessageId: 'dependency-comment-1', + inboxTimestamp: '2026-05-18T21:25:05.428Z', + source: 'watcher', + messageKind: null, + replyRecipient: 'team-lead', + actionMode: null, + taskRefs: [], + payloadHash: 'sha256:test', + status: 'failed_terminal', + responseState: 'session_stale', + attempts: 1, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: '2026-05-18T21:25:27.592Z', + lastObservedAt: '2026-05-18T21:27:58.582Z', + acceptedAt: '2026-05-18T21:25:27.592Z', + respondedAt: null, + failedAt: '2026-05-18T21:27:58.582Z', + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: null, + postPromptCursor: null, + deliveredUserMessageId: 'delivered-1', + observedAssistantMessageId: null, + observedAssistantPreview: null, + observedToolCallNames: [], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: 'opencode_session_stale_observe_loop_after_accepted_prompt', + diagnostics: [ + 'OpenCode API error', + 'OpenCode session stayed stale while observing an accepted prompt after 5 attempt(s).', + ], + createdAt: '2026-05-18T21:25:05.428Z', + updatedAt: '2026-05-18T21:27:58.582Z', + }, + ], + }), + 'utf8' + ); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'inboxes', 'tom.json'), + JSON.stringify([ + { + from: 'team-lead', + to: 'tom', + text: [ + '**Comment on task #a7fd5f34** _Calculator styles_', + '', + '> **Dependency resolved** - task #8dc34135 completed.', + '> All blockers for #a7fd5f34 are resolved - this task is ready to start.', + ].join('\n'), + timestamp: '2026-05-18T21:25:05.428Z', + read: false, + summary: 'Comment on #a7fd5f34', + messageId: 'dependency-comment-1', + source: 'system_notification', + }, + ]), + 'utf8' + ); + await fs.writeFile( + path.join(tmpDir, 'tasks', teamName, `${taskId}.json`), + JSON.stringify({ + id: taskId, + displayId: 'a7fd5f34', + subject: 'Calculator styles', + owner: 'tom', + status: 'completed', + updatedAt: '2026-05-18T21:25:21.453Z', + comments: [ + { + id: 'result-comment', + author: 'tom', + text: 'Styles completed and verified.', + createdAt: '2026-05-18T21:25:18.441Z', + type: 'regular', + }, + ], + historyEvents: [ + { + id: 'completed-event', + type: 'status_changed', + from: 'in_progress', + to: 'completed', + actor: 'tom', + timestamp: '2026-05-18T21:25:21.453Z', + }, + ], + }), + 'utf8' + ); + + const service = new TeamMemberRuntimeAdvisoryService({ + findMemberLogs: vi.fn(async () => []), + }); + const advisory = await service.getMemberAdvisory(teamName, 'tom'); + + expect(advisory).toBeNull(); + }); + it('ignores expired retry advisories', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); setClaudeBasePathOverride(tmpDir); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 17164503..8028e288 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -2274,6 +2274,56 @@ Messages: expect(rows[0].read).toBe(true); }); + it('uses inferred task refs when relaying legacy OpenCode inbox rows without structured refs', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const taskRefs = [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]; + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/mock/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + seedMemberInbox(teamName, 'jack', [ + { + from: 'team-lead', + to: 'jack', + text: '**Comment on task #abcd1234**\n\nPlease continue.', + timestamp: '2026-02-23T17:00:00.000Z', + read: false, + messageId: 'opencode-relay-infer-1', + summary: 'Comment on #abcd1234', + }, + ]); + const inferSpy = vi + .spyOn(service as any, 'inferOpenCodeInboxMessageTaskRefs') + .mockResolvedValue(taskRefs); + const deliverSpy = vi + .spyOn(service, 'deliverOpenCodeMemberMessage') + .mockResolvedValue({ delivered: true, diagnostics: [] }); + + const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack'); + + expect(relay).toMatchObject({ relayed: 1, attempted: 1, delivered: 1, failed: 0 }); + expect(inferSpy).toHaveBeenCalledWith( + teamName, + expect.objectContaining({ messageId: 'opencode-relay-infer-1' }), + expect.any(Function) + ); + expect(deliverSpy).toHaveBeenCalledWith( + teamName, + expect.objectContaining({ + messageId: 'opencode-relay-infer-1', + taskRefs, + }) + ); + }); + it('keeps OpenCode member inbox rows unread while runtime response is pending', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team';