From bcbcb621c275e5ba5472ea8cf65d08075a3565a2 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 9 May 2026 01:52:29 +0300 Subject: [PATCH] fix(opencode): harden team delivery reconciliation --- .../services/team/TeamProvisioningService.ts | 237 ++++++++++-- .../model-gauntlet-results.json | 26 +- .../model-gauntlet-results.md | 6 +- ...OpenCodeSemanticModelGauntlet.live.test.ts | 72 +++- .../team/TeamProvisioningService.test.ts | 360 ++++++++++++++---- 5 files changed, 579 insertions(+), 122 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4042d675..0c9d0e30 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -5,6 +5,7 @@ import { type CodexNativeImageArgPart, type OpenCodeFilePart, } from '@features/agent-attachments/main'; +import { AgentAttachmentError } from '@features/agent-attachments/core/domain'; import { resolveAnthropicFastMode, resolveAnthropicRuntimeSelection, @@ -322,6 +323,15 @@ import type { } from './runtime'; import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/main'; +type OpenCodeRuntimeMessageAdapter = TeamLaunchRuntimeAdapter & { + sendMessageToMember( + input: OpenCodeTeamRuntimeMessageInput + ): Promise; + observeMessageDelivery?( + input: OpenCodeTeamRuntimeMessageInput & { prePromptCursor?: string | null } + ): Promise; +}; + /** * Kill a team CLI process using SIGKILL (uncatchable). * @@ -6739,28 +6749,12 @@ export class TeamProvisioningService { return this.runtimeAdapterRegistry.get('opencode'); } - private getOpenCodeRuntimeMessageAdapter(): - | (TeamLaunchRuntimeAdapter & { - sendMessageToMember( - input: OpenCodeTeamRuntimeMessageInput - ): Promise; - observeMessageDelivery?( - input: OpenCodeTeamRuntimeMessageInput & { prePromptCursor?: string | null } - ): Promise; - }) - | null { + private getOpenCodeRuntimeMessageAdapter(): OpenCodeRuntimeMessageAdapter | null { const adapter = this.getOpenCodeRuntimeAdapter(); if (!adapter || !('sendMessageToMember' in adapter)) { return null; } - return adapter as TeamLaunchRuntimeAdapter & { - sendMessageToMember( - input: OpenCodeTeamRuntimeMessageInput - ): Promise; - observeMessageDelivery?( - input: OpenCodeTeamRuntimeMessageInput & { prePromptCursor?: string | null } - ): Promise; - }; + return adapter as OpenCodeRuntimeMessageAdapter; } private resolveRuntimeRecipientProviderIdFromSources( @@ -7844,6 +7838,161 @@ export class TeamProvisioningService { } } + private async observeOpenCodeDirectUserDeliveryInlineIfNeeded(input: { + adapter: OpenCodeRuntimeMessageAdapter; + ledger: OpenCodePromptDeliveryLedgerStore; + ledgerRecord: OpenCodePromptDeliveryLedgerRecord; + teamName: string; + memberName: string; + laneId: string; + cwd: string; + text: string; + messageId: string; + runtimeRunId?: string | null; + replyRecipient?: string | null; + actionMode?: AgentActionMode; + messageKind?: OpenCodeTeamRuntimeMessageInput['messageKind']; + taskRefs?: TaskRef[]; + promptAccepted: boolean; + visibleReply?: OpenCodeVisibleReplyProof | null; + }): Promise<{ + ledgerRecord: OpenCodePromptDeliveryLedgerRecord; + visibleReply: OpenCodeVisibleReplyProof | null; + }> { + let ledgerRecord = input.ledgerRecord; + let visibleReply = input.visibleReply ?? null; + const observeMessageDelivery = input.adapter.observeMessageDelivery; + const readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({ + responseState: ledgerRecord.responseState, + actionMode: ledgerRecord.actionMode ?? undefined, + taskRefs: ledgerRecord.taskRefs, + visibleReply, + ledgerRecord, + }); + const shouldObserveInline = + observeMessageDelivery && + input.promptAccepted && + this.isOpenCodeDirectUserPromptDelivery(ledgerRecord) && + (ledgerRecord.source === 'manual' || + (ledgerRecord.responseState === 'tool_error' && + this.hasOpenCodeObservedMessageSendToolCall(ledgerRecord))) && + !readAllowed && + !visibleReply && + !ledgerRecord.visibleReplyMessageId; + + if (!shouldObserveInline || !observeMessageDelivery) { + return { ledgerRecord, visibleReply }; + } + + for (let inlineObserveAttempt = 1; inlineObserveAttempt <= 4; inlineObserveAttempt += 1) { + await sleep(OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS); + let observed: OpenCodeTeamRuntimeMessageResult; + try { + observed = await observeMessageDelivery.call(input.adapter, { + ...(input.runtimeRunId ? { runId: input.runtimeRunId } : {}), + teamName: input.teamName, + laneId: input.laneId, + memberName: input.memberName, + cwd: input.cwd, + text: input.text, + messageId: input.messageId, + replyRecipient: input.replyRecipient ?? undefined, + actionMode: input.actionMode, + messageKind: input.messageKind, + taskRefs: input.taskRefs, + prePromptCursor: ledgerRecord.prePromptCursor, + }); + } catch (error) { + const reason = `opencode_direct_user_delivery_inline_observe_failed: ${getErrorMessage( + error + )}`; + ledgerRecord = await input.ledger.applyObservation({ + id: ledgerRecord.id, + responseObservation: { + state: 'reconcile_failed', + deliveredUserMessageId: null, + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason, + }, + diagnostics: [ + `opencode_direct_user_delivery_inline_observe_attempt_${inlineObserveAttempt}`, + reason, + ], + observedAt: nowIso(), + }); + break; + } + await this.rememberOpenCodeRuntimePidFromBridge({ + teamName: input.teamName, + memberName: input.memberName, + laneId: input.laneId, + runId: input.runtimeRunId, + runtimeSessionId: observed.sessionId, + runtimePid: observed.runtimePid, + reason: 'opencode_delivery_inline_observe_runtime_pid_observed', + }); + const observedResponse = this.normalizeOpenCodeDeliveryResponseObservation( + observed.responseObservation + ); + const hadMessageSendToolError = this.hasOpenCodeObservedMessageSendToolCall(ledgerRecord); + ledgerRecord = await input.ledger.applyObservation({ + id: ledgerRecord.id, + responseObservation: observedResponse ?? { + state: observed.ok ? 'not_observed' : 'reconcile_failed', + deliveredUserMessageId: null, + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: observed.diagnostics[0] ?? null, + }, + diagnostics: [ + `opencode_direct_user_delivery_inline_observe_attempt_${inlineObserveAttempt}`, + ...(hadMessageSendToolError ? ['opencode_message_send_tool_error_inline_observe'] : []), + ...observed.diagnostics, + ], + observedAt: nowIso(), + }); + const proof = await this.applyOpenCodeVisibleDestinationProof({ + ledger: input.ledger, + ledgerRecord, + teamName: input.teamName, + replyRecipient: input.replyRecipient, + memberName: input.memberName, + }); + ledgerRecord = proof.ledgerRecord; + visibleReply = proof.visibleReply; + const materialized = await this.materializeOpenCodePlainTextReplyIfNeeded({ + ledger: input.ledger, + ledgerRecord, + teamName: input.teamName, + memberName: input.memberName, + visibleReply, + }); + ledgerRecord = materialized.ledgerRecord; + visibleReply = materialized.visibleReply; + const observedReadAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({ + responseState: ledgerRecord.responseState, + actionMode: ledgerRecord.actionMode ?? undefined, + taskRefs: ledgerRecord.taskRefs, + visibleReply, + ledgerRecord, + }); + if (observedReadAllowed) { + break; + } + } + + return { ledgerRecord, visibleReply }; + } + private getOpenCodeDeliveryWatchdogKey(input: { teamName: string; memberName: string; @@ -8788,14 +8937,27 @@ export class TeamProvisioningService { } } - const openCodeFileParts: OpenCodeFilePart[] = - input.attachments?.length && laneIdentity.laneOwnerProviderId === 'opencode' - ? buildOpenCodeAttachmentDeliveryParts({ - text: input.text, - model: metaMember?.model ?? configMember?.model ?? '', - attachments: input.attachments, - }).fileParts - : []; + let openCodeFileParts: OpenCodeFilePart[] = []; + if (input.attachments?.length && laneIdentity.laneOwnerProviderId === 'opencode') { + try { + openCodeFileParts = buildOpenCodeAttachmentDeliveryParts({ + text: input.text, + model: metaMember?.model ?? configMember?.model ?? '', + attachments: input.attachments, + }).fileParts; + } catch (error) { + const reason = + error instanceof AgentAttachmentError + ? error.code + : 'opencode_attachment_delivery_prepare_failed'; + const diagnostic = `opencode_attachment_delivery_prepare_failed: ${getErrorMessage(error)}`; + return { + delivered: false, + reason, + diagnostics: [diagnostic], + }; + } + } if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) { const result = await adapter.sendMessageToMember({ @@ -9251,6 +9413,25 @@ export class TeamProvisioningService { visibleReply: proof.visibleReply, }); ledgerRecord = proof.ledgerRecord; + proof = await this.observeOpenCodeDirectUserDeliveryInlineIfNeeded({ + adapter, + ledger, + ledgerRecord, + teamName, + memberName: canonicalMemberName, + laneId: laneIdentity.laneId, + cwd, + text: input.text, + messageId: ledgerRecord.inboxMessageId, + runtimeRunId, + replyRecipient: input.replyRecipient, + actionMode: input.actionMode, + messageKind: input.messageKind, + taskRefs: input.taskRefs, + promptAccepted, + visibleReply: proof.visibleReply, + }); + ledgerRecord = proof.ledgerRecord; this.logOpenCodePromptDeliveryEvent( promptAccepted ? ledgerRecord.status === 'unanswered' @@ -20040,7 +20221,7 @@ export class TeamProvisioningService { private asOpenCodeAttachmentPayload(meta: AttachmentMeta): AttachmentPayload | null { const data = (meta as Partial).data; - return typeof data === 'string' && data.length > 0 + return typeof data === 'string' ? { ...meta, data, @@ -20284,6 +20465,7 @@ export class TeamProvisioningService { const effectiveTaskRefs = existingRecord?.taskRefs ?? options.deliveryMetadata?.taskRefs ?? message.taskRefs ?? []; const effectiveSource = existingRecord?.source ?? options.source ?? 'watcher'; + result.attempted += 1; const attachmentPayloads = await this.resolveOpenCodeInboxAttachmentPayloads({ teamName, message, @@ -20298,7 +20480,6 @@ export class TeamProvisioningService { }; break; } - result.attempted += 1; const delivery = await this.deliverOpenCodeMemberMessage(teamName, { memberName, text: message.text, diff --git a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json index a5cdbc69..dd856243 100644 --- a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json +++ b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-05-08T21:28:15.433Z", + "generatedAt": "2026-05-08T22:48:31.416Z", "runsPerModel": 1, "qualification": { "minimumAverageScore": 80, @@ -25,8 +25,8 @@ "runtimeTransportFailures": 0, "modelBehaviorFailures": 0, "harnessFailures": 0, - "p50DurationMs": 173403, - "p95DurationMs": 173403, + "p50DurationMs": 118757, + "p95DurationMs": 118757, "stagePassRates": { "launchBootstrap": { "passed": 1, @@ -217,17 +217,17 @@ "outcome": "passed", "failureCategory": "none", "primaryFailure": null, - "durationMs": 173403, + "durationMs": 118757, "hardFailure": false, "stageDurationsMs": { - "setup": 191, - "launchBootstrap": 24869, - "materializeTasks": 33, - "directReply": 11982, - "peerRelayAB": 24984, - "peerRelayBC": 24320, - "concurrentReplies": 77988, - "hygiene": 2 + "setup": 225, + "launchBootstrap": 20591, + "materializeTasks": 36, + "directReply": 14820, + "peerRelayAB": 32039, + "peerRelayBC": 27306, + "concurrentReplies": 15426, + "hygiene": 1 }, "stageFailures": {}, "taskRefChecks": { @@ -253,7 +253,7 @@ "latencyStable": true }, "diagnostics": [ - "runId=55b87bf5-abe4-4c21-9b98-db1ed8c22cfb" + "runId=44f5aa40-e169-49ed-9ea3-4c72aaf4a9f1" ] } ] diff --git a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md index e3ec508f..79a6cd17 100644 --- a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md +++ b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md @@ -1,6 +1,6 @@ # OpenCode Model Gauntlet Results -Generated: 2026-05-08T21:28:15.433Z +Generated: 2026-05-08T22:48:31.416Z Runs per model: 1 Recommended threshold: average >= 80, successful runs >= 1, consistency >= 85, hard failures = 0 @@ -13,7 +13,7 @@ Scoring weights: launchBootstrap=15, directReply=10, peerRelayAB=15, peerRelayBC | Model | Verdict | Confidence | Readiness | Consistency | Score Spread | Behavior Avg | Overall Avg | Counted | Pass Runs | Weakest Stage | Weakest TaskRef | Dominant Failure | Blockers | Provider Infra | Runtime Transport | Model Fails | Protocol Runs | p50 | p95 | | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | -| `opencode/big-pickle` | Recommended | low | 100 | 100 | 0 | 100 | 100 | 1/1 | 1/1 | cleanTranscript 1/1 (100%) | concurrentBob 1/1 (100%) | none | - | 0 | 0 | 0 | 0 | 173403ms | 173403ms | +| `opencode/big-pickle` | Recommended | low | 100 | 100 | 0 | 100 | 100 | 1/1 | 1/1 | cleanTranscript 1/1 (100%) | concurrentBob 1/1 (100%) | none | - | 0 | 0 | 0 | 0 | 118757ms | 118757ms | ## opencode/big-pickle @@ -33,5 +33,5 @@ Protocol totals: badMessages=0, duplicateOrMissingTokens=0, affectedRuns=0. | Run | Outcome | Category | Score | Counted | Duration | Failed Stages | Slowest Stage | TaskRefs | Protocol | Diagnostics | | ---: | --- | --- | ---: | --- | ---: | --- | --- | --- | --- | --- | -| 1 | passed | none | 100 | yes | 173403ms | - | concurrentReplies:77988ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:ok, concurrentTom:ok | - | runId=55b87bf5-abe4-4c21-9b98-db1ed8c22cfb | +| 1 | passed | none | 100 | yes | 118757ms | - | peerRelayAB:32039ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:ok, concurrentTom:ok | - | runId=44f5aa40-e169-49ed-9ea3-4c72aaf4a9f1 | diff --git a/test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts b/test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts index 676209c1..7a25d1ff 100644 --- a/test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts +++ b/test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts @@ -376,6 +376,66 @@ describe('OpenCode semantic model gauntlet report helpers', () => { expect(markdown).toContain('Protocol totals: badMessages=1'); }); + it('does not count token prefixes as duplicate user replies', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-gauntlet-hygiene-')); + const tempClaudeRoot = path.join(tempDir, '.claude'); + const teamName = 'prefix-token-team'; + try { + setClaudeBasePathOverride(tempClaudeRoot); + const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes'); + await fs.mkdir(inboxDir, { recursive: true }); + await fs.writeFile( + path.join(inboxDir, 'user.json'), + `${JSON.stringify( + [ + { + from: 'bob', + to: 'user', + text: 'GAUNTLET_DIRECT_BOB_OK_1', + timestamp: '2026-04-26T00:00:01.000Z', + read: false, + }, + { + from: 'bob', + to: 'user', + text: 'GAUNTLET_DIRECT_BOB_OK_11', + timestamp: '2026-04-26T00:00:02.000Z', + read: false, + }, + { + from: 'bob', + to: 'user', + text: 'GAUNTLET_DIRECT_BOB_OK_12', + timestamp: '2026-04-26T00:00:03.000Z', + read: false, + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + await expect( + inspectMessageHygiene({ + teamName, + members: ['bob'], + expectedUserReplyTokens: [ + 'GAUNTLET_DIRECT_BOB_OK_1', + 'GAUNTLET_DIRECT_BOB_OK_11', + 'GAUNTLET_DIRECT_BOB_OK_12', + ], + }) + ).resolves.toMatchObject({ + noDuplicateTokens: true, + duplicateOrMissingTokens: [], + }); + } finally { + setClaudeBasePathOverride(null); + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + } + }); + it('ranks failure impact by lost weighted score instead of raw failure count only', () => { const model = createTestGauntletModel({ runs: [ @@ -1131,6 +1191,15 @@ function hasTaskRef(message: InboxMessage, expected: TaskRef): boolean { ); } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function textContainsExactToken(text: string | undefined, token: string): boolean { + const pattern = new RegExp(`(^|[^A-Za-z0-9_])${escapeRegExp(token)}($|[^A-Za-z0-9_])`); + return pattern.test(text ?? ''); +} + async function inspectMessageHygiene(input: { teamName: string; members: string[]; @@ -1167,7 +1236,8 @@ async function inspectMessageHygiene(input: { diagnostics.push(`badMessages=${JSON.stringify(badMessages.slice(0, 5))}`); } const duplicateTokens = input.expectedUserReplyTokens.filter((token) => { - const count = userMessages.filter((message) => message.text?.includes(token)).length; + const count = userMessages.filter((message) => textContainsExactToken(message.text, token)) + .length; return count !== 1; }); if (duplicateTokens.length > 0) { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 6d1f614f..0e69b86e 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -439,6 +439,7 @@ async function configureOpenCodeBobDeliveryService(input: { svc: TeamProvisioningService; sendMessageToMember: ReturnType; observeMessageDelivery?: ReturnType; + memberModel?: string; }): Promise { const registry = new TeamRuntimeAdapterRegistry([ { @@ -469,7 +470,11 @@ async function configureOpenCodeBobDeliveryService(input: { projectPath: '/repo', members: [ { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, - { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + { + name: 'bob', + providerId: 'opencode', + model: input.memberModel ?? 'minimax-m2.5-free', + }, ], })), }; @@ -484,7 +489,7 @@ async function configureOpenCodeBobDeliveryService(input: { { name: 'bob', providerId: 'opencode', - model: 'opencode/minimax-m2.5-free', + model: input.memberModel ?? 'opencode/minimax-m2.5-free', }, ]), }; @@ -5156,15 +5161,17 @@ describe('TeamProvisioningService', () => { delivered: true, diagnostics: [], }); - expect(sendMessageToMember).toHaveBeenCalledWith({ - runId: 'opencode-run-bob', - teamName: 'team-a', - laneId: 'secondary:opencode:bob', - memberName: 'bob', - cwd: '/repo', - text: 'hello bob', - messageId: 'msg-1', - }); + expect(sendMessageToMember).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'opencode-run-bob', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + text: 'hello bob', + messageId: 'msg-1', + }) + ); }); it('persists verified OpenCode bridge runtime pids so member cards can show memory', async () => { @@ -6082,7 +6089,7 @@ describe('TeamProvisioningService', () => { }); }); - it('observes OpenCode message_send tool errors quickly before retrying duplicate prompts', async () => { + it('waits through delayed OpenCode message_send tool-error fallback inline', async () => { const svc = new TeamProvisioningService(); const taskRef = { taskId: 'task-tool-error-observe-first', @@ -6108,36 +6115,60 @@ describe('TeamProvisioningService', () => { }, diagnostics: ['OpenCode tool failed without output'], })); - const observeMessageDelivery = vi.fn(async (input: Record) => ({ - ok: true, + let observeAttempts = 0; + const opencodeAdapter = { providerId: 'opencode', - memberName: String(input.memberName), - sessionId: 'oc-session-bob', - responseObservation: { - state: 'responded_plain_text', - deliveredUserMessageId: 'oc-user-tool-error-observe', - assistantMessageId: 'oc-assistant-plain-fallback', - toolCallNames: ['agent-teams_message_send'], - visibleMessageToolCallId: 'call-message-send-observe', - visibleReplyMessageId: null, - visibleReplyCorrelation: 'plain_assistant_text', - latestAssistantPreview: 'GAUNTLET_OBSERVE_FIRST_OK_1', - reason: 'assistant_replied_with_plain_text', - }, - diagnostics: ['Observed OpenCode plain-text fallback after message_send tool error'], - })); - svc.setRuntimeAdapterRegistry( - new TeamRuntimeAdapterRegistry([ - { + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + observeMessageDelivery: vi.fn(async function ( + this: unknown, + input: Record + ) { + expect(this).toBe(opencodeAdapter); + observeAttempts += 1; + return { + ok: true, providerId: 'opencode', - prepare: vi.fn(), - launch: vi.fn(), - reconcile: vi.fn(), - stop: vi.fn(), - sendMessageToMember, - observeMessageDelivery, - } as any, - ]) + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + responseObservation: + observeAttempts === 1 + ? { + state: 'pending', + deliveredUserMessageId: 'oc-user-tool-error-observe', + assistantMessageId: null, + toolCallNames: ['agent-teams_message_send'], + visibleMessageToolCallId: 'call-message-send-observe', + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'assistant_reply_not_visible_yet', + } + : { + state: 'responded_plain_text', + deliveredUserMessageId: 'oc-user-tool-error-observe', + assistantMessageId: 'oc-assistant-plain-fallback', + toolCallNames: ['agent-teams_message_send'], + visibleMessageToolCallId: 'call-message-send-observe', + visibleReplyMessageId: null, + visibleReplyCorrelation: 'plain_assistant_text', + latestAssistantPreview: 'GAUNTLET_OBSERVE_FIRST_OK_1', + reason: 'assistant_replied_with_plain_text', + }, + diagnostics: [ + observeAttempts === 1 + ? 'OpenCode assistant reply not visible yet' + : 'Observed OpenCode plain-text fallback after message_send tool error', + ], + }; + }), + } as any; + const observeMessageDelivery = opencodeAdapter.observeMessageDelivery; + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([opencodeAdapter]) ); (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); @@ -6190,45 +6221,14 @@ describe('TeamProvisioningService', () => { ).resolves.toMatchObject({ delivered: true, accepted: true, - responsePending: true, - responseState: 'tool_error', - reason: 'tool_error_without_required_delivery_proof', - }); - - 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<{ nextAttemptAt: string | null }>; - }; - const nextAttemptAt = ledgerEnvelope.data[0]?.nextAttemptAt; - expect(nextAttemptAt).toBeTruthy(); - const delayMs = Date.parse(nextAttemptAt!) - Date.now(); - expect(delayMs).toBeGreaterThanOrEqual(0); - expect(delayMs).toBeLessThanOrEqual(5_000); - - ledgerEnvelope.data[0]!.nextAttemptAt = '2000-01-01T00:00:00.000Z'; - await fsPromises.writeFile(ledgerPath, JSON.stringify(ledgerEnvelope, null, 2), 'utf8'); - - await expect( - svc.deliverOpenCodeMemberMessage('team-a', { - memberName: 'bob', - text: 'Reply to user with GAUNTLET_OBSERVE_FIRST_OK_1.', - messageId: 'msg-tool-error-observe-first', - replyRecipient: 'user', - actionMode: 'ask', - taskRefs: [taskRef], - source: 'watcher', - inboxTimestamp: '2026-04-25T10:00:00.000Z', - }) - ).resolves.toMatchObject({ - delivered: true, responsePending: false, responseState: 'responded_plain_text', visibleReplyCorrelation: 'plain_assistant_text', + diagnostics: expect.arrayContaining([ + 'opencode_message_send_tool_error_inline_observe', + 'opencode_direct_user_delivery_inline_observe_attempt_2', + 'opencode_plain_text_reply_materialized_to_user_inbox', + ]), }); const userInbox = JSON.parse( @@ -6247,13 +6247,219 @@ describe('TeamProvisioningService', () => { taskRefs: [taskRef], }); expect(sendMessageToMember).toHaveBeenCalledTimes(1); - expect(observeMessageDelivery).toHaveBeenCalledTimes(1); + expect(observeMessageDelivery).toHaveBeenCalledTimes(2); expect(observeMessageDelivery).toHaveBeenCalledWith( expect.objectContaining({ messageId: 'msg-tool-error-observe-first', prePromptCursor: 'cursor-before-tool-error', }) ); + }, 15_000); + + it('keeps accepted OpenCode delivery retryable when inline observe throws', async () => { + const svc = new TeamProvisioningService(); + const taskRef = { + taskId: 'task-tool-error-observe-throws', + displayId: 'obsthrow', + teamName: 'team-a', + }; + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before-observe-throws', + responseObservation: { + state: 'tool_error', + deliveredUserMessageId: 'oc-user-observe-throws', + assistantMessageId: 'oc-assistant-observe-throws', + toolCallNames: ['agent-teams_message_send'], + visibleMessageToolCallId: 'call-message-send-observe-throws', + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'message_send_tool_error_without_visible_reply_proof', + }, + diagnostics: ['OpenCode tool failed without output'], + })); + const observeMessageDelivery = vi.fn(async () => { + throw new Error('observe bridge fs write failed'); + }); + await configureOpenCodeBobDeliveryService({ + svc, + sendMessageToMember, + observeMessageDelivery, + }); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Reply to user with GAUNTLET_OBSERVE_THROW_OK_1.', + messageId: 'msg-tool-error-observe-throws', + replyRecipient: 'user', + actionMode: 'ask', + taskRefs: [taskRef], + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: true, + responseState: 'reconcile_failed', + ledgerStatus: 'retry_scheduled', + reason: expect.stringContaining('opencode_direct_user_delivery_inline_observe_failed'), + diagnostics: expect.arrayContaining([ + 'opencode_direct_user_delivery_inline_observe_attempt_1', + expect.stringContaining('observe bridge fs write failed'), + ]), + }); + expect(sendMessageToMember).toHaveBeenCalledTimes(1); + expect(observeMessageDelivery).toHaveBeenCalledTimes(1); + }, 10_000); + + it('resolves stored attachment payloads for OpenCode inbox relay before delivery', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + responseObservation: { + state: 'responded_plain_text', + deliveredUserMessageId: 'oc-user-attachment', + assistantMessageId: 'oc-assistant-attachment', + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: 'I reviewed the attached image and can proceed.', + reason: 'assistant_replied_with_plain_text', + }, + diagnostics: [], + })); + await configureOpenCodeBobDeliveryService({ + svc, + sendMessageToMember, + memberModel: 'openai/gpt-5.4-mini', + }); + await (svc as any).attachmentStore.saveAttachments('team-a', 'msg-image-attachment', [ + { + id: 'att-1', + filename: 'diagram.png', + mimeType: 'image/png', + size: 5, + data: 'aW1nMQ==', + }, + ]); + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'bob.json'), + `${JSON.stringify( + [ + { + from: 'team-lead', + to: 'bob', + text: 'Review this image.', + timestamp: '2026-04-25T10:00:00.000Z', + read: false, + messageId: 'msg-image-attachment', + attachments: [ + { + id: 'att-1', + filename: 'diagram.png', + mimeType: 'image/png', + size: 5, + }, + ], + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + await expect( + svc.relayOpenCodeMemberInboxMessages('team-a', 'bob', { + onlyMessageId: 'msg-image-attachment', + }) + ).resolves.toMatchObject({ + attempted: 1, + delivered: 1, + failed: 0, + relayed: 1, + }); + expect(sendMessageToMember).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: 'msg-image-attachment', + fileParts: [ + { + type: 'file', + mime: 'image/png', + url: 'data:image/png;base64,aW1nMQ==', + filename: 'diagram.png', + }, + ], + }) + ); + }); + + it('keeps OpenCode inbox relay unread when attachment payload data is unavailable', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(); + await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'bob.json'), + `${JSON.stringify( + [ + { + from: 'team-lead', + to: 'bob', + text: 'Review this image.', + timestamp: '2026-04-25T10:00:00.000Z', + read: false, + messageId: 'msg-missing-attachment', + attachments: [ + { + id: 'missing-att', + filename: 'missing.png', + mimeType: 'image/png', + size: 5, + }, + ], + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + await expect( + svc.relayOpenCodeMemberInboxMessages('team-a', 'bob', { + onlyMessageId: 'msg-missing-attachment', + }) + ).resolves.toMatchObject({ + attempted: 1, + delivered: 0, + failed: 1, + relayed: 0, + lastDelivery: { + delivered: false, + reason: 'opencode_inbox_attachment_payload_unavailable: missing-att', + }, + }); + expect(sendMessageToMember).not.toHaveBeenCalled(); + + const inbox = JSON.parse(await fsPromises.readFile(path.join(inboxDir, 'bob.json'), 'utf8')); + expect(inbox[0]).toMatchObject({ + messageId: 'msg-missing-attachment', + read: false, + }); }); it('treats OpenCode send bridge timeouts as acceptance-unknown observe-first records', async () => {