diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 032e27cc..9d44b02c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3444,6 +3444,47 @@ function getOpenCodeInboxRelayPriority( return 0; } +function getLeadInboxRelayPriority(message: Pick): number { + if (message.messageKind === 'member_work_sync_nudge') { + return 30; + } + return 0; +} + +function compareInboxRelayMessages( + a: Pick & { messageId: string }, + b: Pick & { messageId: string }, + getPriority: (message: Pick) => number +): number { + const priorityDelta = getPriority(b) - getPriority(a); + if (priorityDelta !== 0) return priorityDelta; + const aTime = Date.parse(a.timestamp); + const bTime = Date.parse(b.timestamp); + if (Number.isFinite(aTime) && Number.isFinite(bTime)) { + const timeDelta = aTime - bTime; + if (timeDelta !== 0) return timeDelta; + } else if (Number.isFinite(aTime)) { + return -1; + } else if (Number.isFinite(bTime)) { + return 1; + } + return a.messageId.localeCompare(b.messageId); +} + +function compareOpenCodeInboxRelayMessagesByPriority( + a: Pick & { messageId: string }, + b: Pick & { messageId: string } +): number { + return compareInboxRelayMessages(a, b, getOpenCodeInboxRelayPriority); +} + +function compareLeadInboxRelayMessagesByPriority( + a: Pick & { messageId: string }, + b: Pick & { messageId: string } +): number { + return compareInboxRelayMessages(a, b, getLeadInboxRelayPriority); +} + export class TeamProvisioningService { private readonly runtimeLaneCoordinator = createTeamRuntimeLaneCoordinator(); private readonly providerConnectionService = ProviderConnectionService.getInstance(); @@ -23567,13 +23608,7 @@ export class TeamProvisioningService { if (typeof message.text !== 'string' || message.text.trim().length === 0) return false; return this.hasStableMessageId(message); }) - .sort((a, b) => { - const priorityDelta = getOpenCodeInboxRelayPriority(a) - getOpenCodeInboxRelayPriority(b); - if (priorityDelta !== 0) return priorityDelta; - const timeDelta = Date.parse(a.timestamp) - Date.parse(b.timestamp); - if (timeDelta !== 0) return timeDelta; - return a.messageId.localeCompare(b.messageId); - }) + .sort(compareOpenCodeInboxRelayMessagesByPriority) .slice(0, 10); let taskRefInferenceTasks: Promise | null = null; @@ -24491,13 +24526,26 @@ export class TeamProvisioningService { if (actionableUnread.length === 0) return 0; const MAX_RELAY = 10; - const userOriginatedUnread = actionableUnread.filter((message) => + const prioritizedActionableUnread = [...actionableUnread].sort( + compareLeadInboxRelayMessagesByPriority + ); + const priorityUnread = prioritizedActionableUnread.filter( + (message) => getLeadInboxRelayPriority(message) > 0 + ); + const userOriginatedUnread = prioritizedActionableUnread.filter((message) => this.isUserOriginatedLeadRelayMessage(message) ); - const replyVisibility: 'user' | 'internal_activity' = - userOriginatedUnread.length > 0 ? 'user' : 'internal_activity'; - const batchSource = userOriginatedUnread.length > 0 ? userOriginatedUnread : actionableUnread; + const batchSource = + priorityUnread.length > 0 + ? priorityUnread + : userOriginatedUnread.length > 0 + ? userOriginatedUnread + : prioritizedActionableUnread; const batch = batchSource.slice(0, MAX_RELAY); + const replyVisibility: 'user' | 'internal_activity' = + priorityUnread.length === 0 && userOriginatedUnread.length > 0 + ? 'user' + : 'internal_activity'; const batchIds = new Set(batch.map((message) => message.messageId)); const hasPendingFollowUpRelay = unread.some( (message) => !batchIds.has(message.messageId) && !readOnlyIgnoredIds.has(message.messageId) @@ -24540,7 +24588,7 @@ export class TeamProvisioningService { const message = [ `You have new inbox messages addressed to you (team lead "${leadName}").`, - `Process them in order (oldest first).`, + `Process them in the listed order. High-priority work-sync control messages may appear before older routine rows.`, `If action is required, delegate via task creation or SendMessage, and keep responses minimal.`, ...replyVisibilityInstruction, `If there is no action to take, produce ZERO text output. Do NOT write "No action needed.", status echoes, or any other no-op summary.`, diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 051dd8b7..86609500 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -220,6 +220,38 @@ type LeadWorkSyncTestInboxMessage = { messageKind?: string; taskRefs?: LeadWorkSyncTestTaskRef[]; }; +type LeadRelayPriorityTestInboxMessage = LeadWorkSyncTestInboxMessage & { + source?: string; + workSyncIntent?: string; +}; +type LeadRelayPriorityTestRun = ReturnType & { + leadRelayCapture?: { resolveOnce(text: string): void } | null; +}; +type LeadRelayPriorityServiceHarness = { + runs: Map; + aliveRunByTeam: Map; + configReader: { + getConfig(teamName: string): Promise>; + }; + inboxReader: { + getMessagesFor( + teamName: string, + inboxName: string + ): Promise; + }; + confirmSameTeamNativeMatches(input: unknown): Promise<{ + nativeMatchedMessageIds: Set; + persisted: boolean; + }>; + markInboxMessagesRead( + teamName: string, + inboxName: string, + messages: LeadRelayPriorityTestInboxMessage[] + ): Promise; + resolveControlApiBaseUrl(): Promise; + scheduleLeadInboxFollowUpRelay(teamName: string): void; + sendMessageToRun(run: LeadRelayPriorityTestRun, message: string): Promise; +}; type LeadWorkSyncReadCommitTestHarness = { hasAcceptedLeadWorkSyncReport(input: { teamName: string; leadName: string }): Promise; getLeadRelayReadCommitBatch(input: { @@ -11798,6 +11830,88 @@ describe('TeamProvisioningService', () => { } }); + it('prioritizes OpenCode work-sync nudges over older ordinary inbox rows', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + runtimePromptMessageId: `runtime-${String(input.messageId)}`, + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'responded_non_visible_tool' as const, + deliveredUserMessageId: `oc-user-${String(input.messageId)}`, + assistantMessageId: `oc-assistant-${String(input.messageId)}`, + toolCallNames: + input.messageKind === 'member_work_sync_nudge' + ? ['member_work_sync_status', 'member_work_sync_report'] + : ['task_get'], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: null, + }, + diagnostics: [], + })); + await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); + svc.setMemberWorkSyncAcceptedReportChecker(async () => true); + + 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: 'Older ordinary follow-up.', + timestamp: '2026-04-25T09:00:00.000Z', + read: false, + messageId: 'msg-ordinary-old', + }, + { + from: 'system', + to: 'bob', + text: 'Work sync check for #task-1.', + timestamp: '2026-04-25T10:00:00.000Z', + read: false, + messageId: 'msg-work-sync-priority', + source: 'system_notification', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'agenda_sync', + taskRefs: [ + { + taskId: 'task-1', + displayId: 'task-1', + teamName: 'team-a', + }, + ], + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + await expect(svc.relayOpenCodeMemberInboxMessages('team-a', 'bob')).resolves.toMatchObject({ + attempted: 1, + delivered: 1, + failed: 0, + relayed: 1, + }); + expect(sendMessageToMember).toHaveBeenCalledTimes(1); + expect(sendMessageToMember).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: 'msg-work-sync-priority', + messageKind: 'member_work_sync_nudge', + }) + ); + }); + it('retries OpenCode direct asks after non-visible tool activity with an explicit retry header', async () => { const svc = new TeamProvisioningService(); const sendMessageToMember = vi.fn(async (input: Record) => ({ @@ -12536,7 +12650,7 @@ describe('TeamProvisioningService', () => { responsePending: true, responseState: 'prompt_delivered_no_assistant_message', ledgerStatus: 'retry_scheduled', - reason: 'prompt_delivered_no_assistant_message', + reason: 'member_work_sync_report_required', }); }); @@ -24353,6 +24467,114 @@ describe('TeamProvisioningService', () => { expect(readCommitBatch).toEqual([normalMessage]); }); + it('prioritizes lead work-sync nudges without mixing them into user-visible batches', async () => { + const teamName = 'lead-work-sync-priority-team'; + const svc = new TeamProvisioningService(); + const harness = svc as unknown as LeadRelayPriorityServiceHarness; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + }) as LeadRelayPriorityTestRun; + run.child = { pid: 123 }; + run.processKilled = false; + run.cancelRequested = false; + run.provisioningComplete = true; + + const oldUserMessages = Array.from({ length: 10 }, (_, index) => { + const suffix = String(index + 1).padStart(2, '0'); + return { + from: 'user', + to: 'team-lead', + text: `Older user request ${suffix}.`, + timestamp: `2026-04-25T09:${suffix}:00.000Z`, + messageId: `msg-user-${suffix}`, + source: 'user_sent', + read: false, + }; + }); + const workSyncMessage = { + from: 'system', + to: 'team-lead', + text: 'Work sync required for task-1.', + timestamp: '2026-04-25T10:00:00.000Z', + messageId: 'msg-work-sync-priority', + source: 'system_notification', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'agenda_sync', + taskRefs: [{ taskId: 'task-1', displayId: 'task-1', teamName }], + read: false, + }; + const ordinarySystemMessage = { + from: 'system', + to: 'team-lead', + text: 'Routine system notification.', + timestamp: '2026-04-25T09:59:00.000Z', + messageId: 'msg-system-routine', + source: 'system_notification', + read: false, + }; + const inboxMessages: LeadRelayPriorityTestInboxMessage[] = [ + ...oldUserMessages, + ordinarySystemMessage, + workSyncMessage, + ]; + let deliveredPrompt = ''; + const recoveryScheduler = vi.fn(async () => ({ + scheduled: true, + reason: 'scheduled', + })); + const sendMessageToRun = vi.fn( + async (targetRun: LeadRelayPriorityTestRun, message: string) => { + deliveredPrompt = message; + targetRun.leadRelayCapture?.resolveOnce(''); + } + ); + + harness.runs.set(run.runId, run); + harness.aliveRunByTeam.set(teamName, run.runId); + harness.configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', agentType: 'team-lead', role: 'Team Lead' }, + { name: 'alice', role: 'Developer' }, + ], + })), + }; + vi.spyOn(harness.inboxReader, 'getMessagesFor').mockResolvedValue(inboxMessages); + harness.confirmSameTeamNativeMatches = vi.fn(async () => ({ + nativeMatchedMessageIds: new Set(), + persisted: true, + })); + harness.markInboxMessagesRead = vi.fn(async () => undefined); + harness.resolveControlApiBaseUrl = vi.fn(async () => null); + harness.scheduleLeadInboxFollowUpRelay = vi.fn(); + harness.sendMessageToRun = sendMessageToRun; + svc.setMemberWorkSyncProofMissingRecoveryScheduler(recoveryScheduler); + + await expect(svc.relayLeadInboxMessages(teamName)).resolves.toBe(1); + + expect(sendMessageToRun).toHaveBeenCalledTimes(1); + const messagesSection = deliveredPrompt.slice(deliveredPrompt.indexOf('Messages:')); + expect(messagesSection).toContain('1) From: system'); + expect(messagesSection).toContain('Message kind: member_work_sync_nudge'); + expect(messagesSection).toContain('Work sync required for task-1.'); + expect(messagesSection).not.toContain('Older user request 01.'); + expect(messagesSection).not.toContain('Older user request 10.'); + expect(messagesSection).not.toContain('Routine system notification.'); + expect(deliveredPrompt).toContain( + 'Plain text reply visibility for this batch: internal lead activity only.' + ); + expect(recoveryScheduler).toHaveBeenCalledWith({ + teamName, + memberName: 'team-lead', + originalMessageId: 'msg-work-sync-priority', + taskRefs: [{ taskId: 'task-1', displayId: 'task-1', teamName }], + reason: 'lead_member_work_sync_report_required', + }); + expect(harness.scheduleLeadInboxFollowUpRelay).toHaveBeenCalledWith(teamName); + }); + it('read-commits lead work-sync inbox rows after accepted report proof', async () => { const svc = new TeamProvisioningService(); const harness = leadWorkSyncReadCommitHarness(svc); diff --git a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts index 1948bb0e..7d38a75b 100644 --- a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts +++ b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts @@ -228,11 +228,15 @@ describe('useRuntimeProviderManagement', () => { const root = createRoot(host); await act(async () => { root.render(React.createElement(ConfigurableHarness, { enabled: true })); + await Promise.resolve(); }); - await vi.waitFor(() => { - expect(state?.error ?? '').toContain('wrong runtime binary'); + await act(async () => { + await vi.waitFor(() => { + expect(state?.error ?? '').toContain('wrong runtime binary'); + }); }); + expect(state?.errorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode'); await act(async () => {