From 2f5454ab3c487228b99a3f7da274333667e42a33 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 6 Jun 2026 21:04:56 +0300 Subject: [PATCH] fix(team): route opencode lead through runtime --- ...opencode-native-semantic-messaging-plan.md | 17 ++- src/main/ipc/teams.ts | 9 +- .../services/team/TeamLaunchStateEvaluator.ts | 11 +- .../services/team/TeamProvisioningService.ts | 103 +++++++++++++--- .../OpenCodeSemanticMessaging.live.test.ts | 5 +- .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 100 +++++++++++++++- .../team/TeamProvisioningServiceRelay.test.ts | 113 ++++++++++++++++-- 7 files changed, 303 insertions(+), 55 deletions(-) diff --git a/docs/team-management/opencode-native-semantic-messaging-plan.md b/docs/team-management/opencode-native-semantic-messaging-plan.md index 59b4348e..935b1b1f 100644 --- a/docs/team-management/opencode-native-semantic-messaging-plan.md +++ b/docs/team-management/opencode-native-semantic-messaging-plan.md @@ -1827,9 +1827,9 @@ OpenCode lead rule: - Mixed team with native Codex/Claude/Gemini lead: keep the existing `relayLeadInboxMessages()` path. - OpenCode teammate or secondary lane: use `relayOpenCodeMemberInboxMessages()`. -- Pure OpenCode lead inbox in v1: do not mark messages read and do not report delivery success unless a real stored OpenCode `team-lead` session exists. Return a diagnostic like `opencode_lead_runtime_session_missing`. +- Pure OpenCode lead inbox: launch and store a real OpenCode `team-lead` runtime session, then relay through `relayOpenCodeMemberInboxMessages()`. Do not mark messages read unless that delivery is accepted. - Do not fake lead delivery by sending to a random teammate session. That would make messages appear delivered while the actual recipient never saw them. -- A future explicit OpenCode lead lane can reuse this selector by teaching the bridge to create/store a `team-lead` session and by passing `agent: "team-lead"` where the bridge supports it. That is not part of this v1 seam. +- If the stored `team-lead` session is missing, keep the row retryable instead of falling back to another teammate. FileWatcher change: @@ -2967,13 +2967,12 @@ it('routes native lead inbox relay through the legacy stdin path', async () => { ``` ```ts -it('does not silently consume pure OpenCode lead inbox when no lead session exists', async () => { - // Configure a pure OpenCode runtime-adapter team where isTeamAlive() is true via runtimeAdapterRunByTeam. - // Ensure there is no stored OpenCode session record for the canonical lead name. +it('relays pure OpenCode lead inbox through the stored lead session', async () => { + // Configure a pure OpenCode runtime-adapter team with a stored team-lead session. // Seed inboxes/.json with one unread message. // Call relayInboxFileToLiveRecipient(teamName, leadName). - // Assert diagnostics include opencode_lead_runtime_session_missing. - // Assert the inbox row remains unread and no teammate session received the prompt. + // Assert the relay kind is opencode_member and the prompt targets team-lead. + // Assert the inbox row is marked read only after accepted runtime delivery. }); ``` @@ -3464,8 +3463,8 @@ Avoid heavy E2E until targeted tests pass. - UI direct sends to live OpenCode teammates either confirm runtime delivery or show a visible warning; there is no log-only post-send delivery failure. - Persisted inbox messages addressed to OpenCode teammates are live-relayed to their runtime lanes, while native teammates keep file-watch behavior and lead keeps lead relay behavior. - OpenCode inbox relay is direct-to-runtime and does not reuse native `relayMemberInboxMessages()` / `SendMessage` forwarding. -- Pure OpenCode lead inbox delivery is not silently consumed: without a real OpenCode lead session, rows remain unread and diagnostics say `opencode_lead_runtime_session_missing` or equivalent. +- Pure OpenCode lead inbox delivery uses the stored `team-lead` runtime session and does not silently fall back to another teammate. - Renderer send-message actions return `SendMessageResult` on success and reject on real send failure, so pending-reply cleanup is not dependent on dead `.catch()` paths. - `message_send` cannot create `from: "user", to: "user"` rows; user-directed MCP replies require a configured teammate sender. - OpenCode replies appear in Messages UI without frontend fake state. -- Tests cover native default, OpenCode override, assignment protocol, tool alias canonicalization, tool proof, taskRefs persistence, user-directed sender guard, local recipient canonicalization, direct-message runtime delivery result visibility, OpenCode reply feed projection, OpenCode-targeted inbox relay/dedupe, unsupported OpenCode lead diagnostics, launch identity injection, lane-scoped manifest activeRunId recovery, and runtime delivery team-change event shape. +- Tests cover native default, OpenCode override, assignment protocol, tool alias canonicalization, tool proof, taskRefs persistence, user-directed sender guard, local recipient canonicalization, direct-message runtime delivery result visibility, OpenCode reply feed projection, OpenCode-targeted inbox relay/dedupe, pure OpenCode lead relay, launch identity injection, lane-scoped manifest activeRunId recovery, and runtime delivery team-change event shape. diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index e2574003..f4336542 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -3028,9 +3028,10 @@ async function handleSendMessage( : leadName !== null && memberName === leadName; const actionMode = payload.actionMode; - const recipientProviderId = !isLeadRecipient - ? await provisioning.resolveRuntimeRecipientProviderId(tn, memberName) - : undefined; + const recipientProviderId = await provisioning.resolveRuntimeRecipientProviderId( + tn, + memberName + ); const isOpenCodeRecipient = recipientProviderId === 'opencode'; // Attachments are routed through explicit provider transports only. @@ -3051,7 +3052,7 @@ async function handleSendMessage( } // Smart routing: lead + alive → stdin direct, else → inbox - if (isLeadRecipient && isAlive) { + if (isLeadRecipient && isAlive && !isOpenCodeRecipient) { const resolvedLeadName = leadName ?? memberName; const teammateRoster = await getDurableLeadTeammateRoster(tn, resolvedLeadName); const rosterContextBlock = buildLeadRosterContextBlock(tn, resolvedLeadName, teammateRoster); diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index 83711fdc..424a56fd 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -712,24 +712,23 @@ export function createPersistedLaunchSnapshot(params: { teamName: string; expectedMembers: readonly string[]; bootstrapExpectedMembers?: readonly string[]; + includeLeadMembers?: boolean; leadSessionId?: string; launchPhase?: PersistedTeamLaunchPhase; members?: Record; updatedAt?: string; }): PersistedTeamLaunchSnapshot { const updatedAt = params.updatedAt ?? new Date().toISOString(); + const shouldKeepExpectedMemberName = (name: string): boolean => + name.length > 0 && name !== 'user' && (params.includeLeadMembers || !isLeadMember({ name })); const expectedMembers = Array.from( - new Set( - params.expectedMembers - .map(normalizeMemberName) - .filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name })) - ) + new Set(params.expectedMembers.map(normalizeMemberName).filter(shouldKeepExpectedMemberName)) ); const bootstrapExpectedMembers = Array.from( new Set( (params.bootstrapExpectedMembers ?? expectedMembers) .map(normalizeMemberName) - .filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name })) + .filter(shouldKeepExpectedMemberName) ) ); const members = params.members ?? {}; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0402addd..bfef86d4 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3344,12 +3344,7 @@ interface OpenCodeMemberInboxRelayResult { } interface LiveInboxRelayResult { - kind: - | 'ignored' - | 'native_lead' - | 'native_member_noop' - | 'opencode_member' - | 'opencode_lead_unsupported'; + kind: 'ignored' | 'native_lead' | 'native_member_noop' | 'opencode_member'; relayed: number; diagnostics?: string[]; lastDelivery?: OpenCodeMemberInboxDelivery; @@ -14859,7 +14854,13 @@ export class TeamProvisioningService { if (!memberName) continue; const isLead = isLeadMember({ name: memberName, agentType: member.agentType }); - if (isLead) { + const candidateLaunchMember = launchSnapshot?.members[memberName]; + const candidateRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName]; + const leadRuntimeProviderId = + normalizeOptionalTeamProviderId(candidateRuntimeAdapterEvidence?.providerId) ?? + normalizeOptionalTeamProviderId(candidateLaunchMember?.providerId) ?? + normalizeOptionalTeamProviderId(member.providerId); + if (isLead && leadRuntimeProviderId !== 'opencode') { const pid = run?.child?.pid; const usageStats = pid ? this.buildRuntimeProcessLoadStatsSafely(teamName, memberName, { @@ -14929,6 +14930,7 @@ export class TeamProvisioningService { const liveRuntimeMember = getLiveRuntimeMember(memberName); const spawnStatusMember = getSpawnStatusMember(memberName); const launchMember = launchSnapshot?.members[memberName]; + const runtimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName]; const activeRunMember = activeRunMemberByName.get(memberName); const activeRunModel = activeRunMember?.model?.trim(); const activeRunProviderId = @@ -14969,6 +14971,16 @@ export class TeamProvisioningService { member.providerBackendId ); const isOpenCodeMember = memberProviderId === 'opencode'; + const runtimeAdapterSessionId = + typeof runtimeAdapterEvidence?.sessionId === 'string' + ? runtimeAdapterEvidence.sessionId.trim() + : ''; + const runtimeAdapterPid = + typeof runtimeAdapterEvidence?.runtimePid === 'number' && + Number.isFinite(runtimeAdapterEvidence.runtimePid) && + runtimeAdapterEvidence.runtimePid > 0 + ? runtimeAdapterEvidence.runtimePid + : undefined; const configuredCwd = typeof activeRunMember?.cwd === 'string' ? activeRunMember.cwd.trim() @@ -14985,7 +14997,9 @@ export class TeamProvisioningService { metricsPid > 0 && liveRuntimeMember?.pidSource !== 'agent_process_table'; const rssPid = isSharedOpenCodeHost ? metricsPid : (liveRuntimeMember?.pid ?? metricsPid); - const displayPid = isSharedOpenCodeHost ? rssPid : liveRuntimeMember?.pid; + const displayPid = isSharedOpenCodeHost + ? rssPid + : (liveRuntimeMember?.pid ?? runtimeAdapterPid); const restartable = isOpenCodeMember ? !isSharedOpenCodeHost && Boolean(liveRuntimeMember?.pid) : isSharedOpenCodeHost @@ -14994,6 +15008,8 @@ export class TeamProvisioningService { const historicalBootstrapConfirmed = launchMember?.bootstrapConfirmed === true || launchMember?.launchState === 'confirmed_alive' || + runtimeAdapterEvidence?.bootstrapConfirmed === true || + runtimeAdapterEvidence?.launchState === 'confirmed_alive' || spawnStatusMember?.bootstrapConfirmed === true || spawnStatusMember?.launchState === 'confirmed_alive'; const spawnStatusConfirmsBootstrap = @@ -15003,7 +15019,9 @@ export class TeamProvisioningService { isOpenCodeMember && (typeof liveRuntimeMember?.pid === 'number' || typeof liveRuntimeMember?.metricsPid === 'number' || - typeof liveRuntimeMember?.runtimeSessionId === 'string'); + typeof liveRuntimeMember?.runtimeSessionId === 'string' || + typeof runtimeAdapterPid === 'number' || + runtimeAdapterSessionId.length > 0); const confirmedOpenCodeRuntimeAlive = isOpenCodeMember && canUseLiveSpawnStatusRuntimeTruth && @@ -15012,6 +15030,12 @@ export class TeamProvisioningService { spawnStatusMember?.hardFailure !== true && spawnStatusMember?.launchState !== 'failed_to_start' && spawnStatusMember?.launchState !== 'runtime_pending_permission'; + const confirmedOpenCodeRuntimeAdapterAlive = + isOpenCodeMember && + runtimeAdapterEvidence?.bootstrapConfirmed === true && + runtimeAdapterEvidence.runtimeAlive === true && + runtimeAdapterEvidence.hardFailure !== true && + hasOpenCodeRuntimeHandle; const confirmedSpawnRuntimeFallback = !isOpenCodeMember && spawnStatusConfirmsBootstrap && @@ -15026,6 +15050,7 @@ export class TeamProvisioningService { const effectiveAlive = liveRuntimeMember?.alive === true || confirmedOpenCodeRuntimeAlive || + confirmedOpenCodeRuntimeAdapterAlive || confirmedSpawnRuntimeFallback; const effectiveLivenessKind = confirmedOpenCodeRuntimeAlive && @@ -15154,7 +15179,9 @@ export class TeamProvisioningService { ...(liveRuntimeMember?.metricsPid ? { runtimePid: liveRuntimeMember.metricsPid } : {}), ...(liveRuntimeMember?.runtimeSessionId ? { runtimeSessionId: liveRuntimeMember.runtimeSessionId } - : {}), + : runtimeAdapterSessionId + ? { runtimeSessionId: runtimeAdapterSessionId } + : {}), ...(liveRuntimeMember?.runtimeLastSeenAt ? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt } : {}), @@ -19888,6 +19915,29 @@ export class TeamProvisioningService { }; } + private buildOpenCodeRuntimeAdapterLaunchMembers( + request: TeamCreateRequest | TeamLaunchRequest, + members: TeamCreateRequest['members'] + ): TeamCreateRequest['members'] { + if (resolveTeamProviderId(request.providerId) !== 'opencode') { + return members; + } + if (members.some((member) => isLeadMember(member))) { + return members; + } + + return [ + { + name: 'team-lead', + role: 'Team Lead', + providerId: 'opencode', + model: request.model, + effort: request.effort, + }, + ...members, + ]; + } + private async resolveOpenCodeMemberWorkspacesForRuntime(params: { teamName: string; baseCwd: string; @@ -21530,6 +21580,10 @@ export class TeamProvisioningService { leadProviderId: launchRequest.providerId, members: materialized.members, }); + const runtimeLaunchMembers = this.buildOpenCodeRuntimeAdapterLaunchMembers( + launchRequest, + effectiveMembers + ); const teamDir = path.join(getTeamsBasePath(), launchRequest.teamName); const tasksDir = path.join(getTasksBasePath(), launchRequest.teamName); await fs.promises.mkdir(teamDir, { recursive: true }); @@ -21558,7 +21612,7 @@ export class TeamProvisioningService { return this.runOpenCodeTeamRuntimeAdapterLaunch({ request: launchRequest, - members: effectiveMembers, + members: runtimeLaunchMembers, prompt: launchRequest.prompt?.trim() ?? '', sourceWarning: undefined, onProgress, @@ -21594,6 +21648,10 @@ export class TeamProvisioningService { leadProviderId: launchRequest.providerId, members: materialized.members, }); + const runtimeLaunchMembers = this.buildOpenCodeRuntimeAdapterLaunchMembers( + launchRequest, + effectiveMembers + ); await this.updateConfigProjectPath(launchRequest.teamName, launchRequest.cwd); let existingTasks: TeamTask[] = []; @@ -21613,7 +21671,7 @@ export class TeamProvisioningService { return this.runOpenCodeTeamRuntimeAdapterLaunch({ request: launchRequest, - members: effectiveMembers, + members: runtimeLaunchMembers, prompt, sourceWarning: warning, onProgress, @@ -21901,6 +21959,7 @@ export class TeamProvisioningService { teamName: input.teamName, expectedMembers: input.expectedMembers.map((member) => member.name), bootstrapExpectedMembers: input.expectedMembers.map((member) => member.name), + includeLeadMembers: true, leadSessionId: result.leadSessionId, launchPhase: committedResult.launchPhase, members, @@ -23462,13 +23521,21 @@ export class TeamProvisioningService { ); if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) { if (isOpenCodeRecipient) { - const diagnostic = - 'opencode_lead_runtime_session_missing: OpenCode lead inbox relay is unsupported in v1; leaving inbox unread for durable retry/diagnostics.'; - logger.warn(`[${teamName}] ${diagnostic} inbox=${inboxName}`); + const relayOptions: OpenCodeMemberInboxRelayOptions = { + source: options.source ?? 'watcher', + ...(options.onlyMessageId ? { onlyMessageId: options.onlyMessageId } : {}), + ...(options.deliveryMetadata ? { deliveryMetadata: options.deliveryMetadata } : {}), + }; + const relay = await this.relayOpenCodeMemberInboxMessages( + teamName, + inboxName, + relayOptions + ); return { - kind: 'opencode_lead_unsupported', - relayed: 0, - diagnostics: [diagnostic], + kind: 'opencode_member', + relayed: relay.relayed, + diagnostics: relay.diagnostics, + lastDelivery: relay.lastDelivery, }; } return { diff --git a/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts b/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts index 52f04514..740cfd4f 100644 --- a/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts +++ b/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts @@ -251,9 +251,8 @@ liveDescribe('OpenCode semantic messaging live e2e', () => { break; } if ( - lastRelay.kind === 'opencode_lead_unsupported' || - (lastRelay.lastDelivery?.delivered === false && - lastRelay.lastDelivery.responsePending !== true) + lastRelay.lastDelivery?.delivered === false && + lastRelay.lastDelivery.responsePending !== true ) { break; } diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 82f42194..969fc541 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -223,6 +223,11 @@ describe('Team agent launch matrix safe e2e', () => { }); const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot('pure-opencode-safe-e2e'); + expect(runtimeSnapshot.members['team-lead']).toMatchObject({ + alive: true, + providerId: 'opencode', + runtimeModel: 'opencode/big-pickle', + }); expect(runtimeSnapshot.members.alice).toMatchObject({ alive: true, providerId: 'opencode', @@ -240,8 +245,8 @@ describe('Team agent launch matrix safe e2e', () => { }) ) as { expectedMembers: string[]; members: Record; teamLaunchState: string }; expect(launchState.teamLaunchState).toBe('clean_success'); - expect(launchState.expectedMembers).toEqual(['alice', 'bob']); - expect(Object.keys(launchState.members)).toEqual(['alice', 'bob']); + expect(launchState.expectedMembers).toEqual(['team-lead', 'alice', 'bob']); + expect(Object.keys(launchState.members)).toEqual(['team-lead', 'alice', 'bob']); await expect( readCommittedOpenCodeBootstrapSessionEvidence({ teamsBasePath: getTeamsBasePath(), @@ -278,7 +283,7 @@ describe('Team agent launch matrix safe e2e', () => { expect(runId).toBe(adapter.launchInputs[0]?.runId); expect(adapter.bootstrapCheckins).toEqual([ { - memberName: 'team-lead', + memberName: 'alice', runId, state: 'accepted', }, @@ -350,7 +355,6 @@ describe('Team agent launch matrix safe e2e', () => { expect(runId).toBe(adapter.launchInputs[0]?.runId); expect(adapter.launchInputs[0]?.expectedMembers.map((member) => member.name)).toEqual([ - 'team-lead', 'alice', 'bob', ]); @@ -11164,6 +11168,94 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.messageInputs[0]?.runId).not.toBe(first.runId); }); + it('delivers pure OpenCode lead inbox messages through the primary runtime lane end-to-end', async () => { + const teamName = 'pure-opencode-lead-inbox-delivery-safe-e2e'; + const adapter = new VisibleReplyOpenCodeRuntimeAdapter({ + replySource: 'runtime_delivery', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const launch = await svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + () => undefined + ); + const messageId = 'msg-pure-opencode-lead-inbox'; + const leadInboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', 'team-lead.json'); + await fs.mkdir(path.dirname(leadInboxPath), { recursive: true }); + await fs.writeFile( + leadInboxPath, + `${JSON.stringify( + [ + { + from: 'user', + to: 'team-lead', + text: 'coordinate this pure opencode team', + timestamp: '2026-05-08T10:05:00.000Z', + read: false, + messageId, + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + await expect( + svc.relayInboxFileToLiveRecipient(teamName, 'team-lead', { + onlyMessageId: messageId, + source: 'ui-send', + deliveryMetadata: { + replyRecipient: 'user', + actionMode: 'do', + }, + }) + ).resolves.toMatchObject({ + kind: 'opencode_member', + relayed: 1, + lastDelivery: { + delivered: true, + accepted: true, + responsePending: false, + responseState: 'responded_visible_message', + visibleReplyMessageId: `reply-${messageId}`, + }, + }); + + expect(adapter.messageInputs).toHaveLength(1); + expect(adapter.messageInputs[0]).toMatchObject({ + runId: launch.runId, + teamName, + laneId: 'primary', + memberName: 'team-lead', + text: 'coordinate this pure opencode team', + messageId, + replyRecipient: 'user', + actionMode: 'do', + }); + + const leadInbox = await readInboxRows(teamName, 'team-lead'); + expect(leadInbox[0]).toMatchObject({ + messageId, + read: true, + }); + const userInbox = await readInboxRows(teamName, 'user'); + expect(userInbox[0]).toMatchObject({ + from: 'team-lead', + to: 'user', + source: 'runtime_delivery', + messageId: `reply-${messageId}`, + relayOfMessageId: messageId, + }); + }); + it('surfaces pure OpenCode delivery permission blocks as the shared tool approval dialog', async () => { const teamName = 'pure-opencode-delivery-permission-approval-safe-e2e'; const adapter = new PermissionBlockedOpenCodeRuntimeAdapter(); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index bd68f65f..b05eb39a 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -1664,6 +1664,66 @@ Messages: expect(payload).not.toContain('MessageId: m-ordinary-11'); }); + it('keeps native member work-sync rows unread without accepted report proof', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + service.setMemberWorkSyncAcceptedReportChecker(async () => false); + seedMemberInbox(teamName, 'alice', [ + { + from: 'system', + text: 'Call member_work_sync_status, then member_work_sync_report.', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + messageId: 'm-work-sync-unproved', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'agenda_sync', + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + const firstRelayed = await service.relayMemberInboxMessages(teamName, 'alice'); + const rowsAfterFirst = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]' + ) as Array<{ read?: boolean }>; + + expect(firstRelayed).toBe(1); + expect(rowsAfterFirst[0]?.read).toBe(false); + + const secondRelayed = await service.relayMemberInboxMessages(teamName, 'alice'); + + expect(secondRelayed).toBe(1); + expect(writeSpy).toHaveBeenCalledTimes(2); + }); + + it('read-commits native member work-sync rows after accepted report proof', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + service.setMemberWorkSyncAcceptedReportChecker(async () => true); + seedMemberInbox(teamName, 'alice', [ + { + from: 'system', + text: 'Call member_work_sync_status, then member_work_sync_report.', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + messageId: 'm-work-sync-proved', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'agenda_sync', + }, + ]); + + attachAliveRun(service, teamName); + const relayed = await service.relayMemberInboxMessages(teamName, 'alice'); + const rows = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]' + ) as Array<{ read?: boolean }>; + + expect(relayed).toBe(1); + expect(rows[0]?.read).toBe(true); + await expect(service.relayMemberInboxMessages(teamName, 'alice')).resolves.toBe(0); + }); + it('retries a work-sync nudge after member relay times out before stdin write completes', async () => { vi.useFakeTimers(); const service = new TeamProvisioningService(); @@ -4170,7 +4230,7 @@ Messages: expect(rows[0].read).toBe(true); }); - it('leaves OpenCode lead inbox rows unread with an explicit unsupported diagnostic', async () => { + it('routes OpenCode lead inbox rows through OpenCode member relay', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; hoisted.files.set( @@ -4198,19 +4258,50 @@ Messages: messageId: 'opencode-lead-unread-1', }, ]); + const relaySpy = vi.spyOn(service, 'relayOpenCodeMemberInboxMessages').mockResolvedValue({ + relayed: 1, + attempted: 1, + delivered: 1, + failed: 0, + diagnostics: ['fake OpenCode lead relay ready'], + lastDelivery: { + delivered: true, + accepted: true, + responsePending: false, + }, + }); - const relay = await service.relayInboxFileToLiveRecipient(teamName, 'team-lead'); + const relay = await service.relayInboxFileToLiveRecipient(teamName, 'team-lead', { + onlyMessageId: 'opencode-lead-unread-1', + source: 'ui-send', + deliveryMetadata: { + replyRecipient: 'user', + actionMode: 'do', + }, + }); - expect(relay).toMatchObject({ kind: 'opencode_lead_unsupported', relayed: 0 }); - expect(relay.diagnostics?.join('\n')).toContain('opencode_lead_runtime_session_missing'); - expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( - 'opencode_lead_runtime_session_missing' + expect(relay).toMatchObject({ + kind: 'opencode_member', + relayed: 1, + diagnostics: ['fake OpenCode lead relay ready'], + lastDelivery: { + delivered: true, + accepted: true, + responsePending: false, + }, + }); + expect(relaySpy).toHaveBeenCalledWith( + teamName, + 'team-lead', + expect.objectContaining({ + onlyMessageId: 'opencode-lead-unread-1', + source: 'ui-send', + deliveryMetadata: expect.objectContaining({ + replyRecipient: 'user', + actionMode: 'do', + }), + }) ); - vi.mocked(console.warn).mockClear(); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]' - ); - expect(rows[0].read).toBe(false); }); it('keeps failed OpenCode member inbox relay rows unread for retry', async () => {