diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f69d11dd..5982b831 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4765,33 +4765,62 @@ export class TeamProvisioningService { replyRecipient?: string | null; from: string; relayOfMessageId: string; + expectedMessageId?: string | null; + allowUserFallbackForLeadRecipient?: boolean; }): Promise { const relayOfMessageId = input.relayOfMessageId.trim(); if (!relayOfMessageId) { return null; } + const expectedMessageId = input.expectedMessageId?.trim() || null; const candidates = await this.getOpenCodeVisibleReplyInboxCandidates({ teamName: input.teamName, replyRecipient: input.replyRecipient, + includeUserFallbackForLeadRecipient: Boolean( + expectedMessageId || input.allowUserFallbackForLeadRecipient + ), }); + const explicitRecipient = input.replyRecipient?.trim() || 'user'; const expectedFrom = input.from.trim().toLowerCase(); for (const inboxName of candidates) { const messages = await this.inboxReader .getMessagesFor(input.teamName, inboxName) .catch(() => []); + const isUserFallbackForNonUserRecipient = + inboxName.trim().toLowerCase() === 'user' && + explicitRecipient.trim().toLowerCase() !== 'user'; const matches = messages.filter( - (message): message is InboxMessage & { messageId: string } => - typeof message.messageId === 'string' && - message.messageId.trim().length > 0 && - message.relayOfMessageId === relayOfMessageId && - message.from.trim().toLowerCase() === expectedFrom + (message): message is InboxMessage & { messageId: string } => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + const messageRelayOf = + typeof message.relayOfMessageId === 'string' ? message.relayOfMessageId.trim() : ''; + return ( + messageId.length > 0 && + (!expectedMessageId || messageId === expectedMessageId) && + messageRelayOf === relayOfMessageId && + message.from.trim().toLowerCase() === expectedFrom + ); + } + ); + const runtimeDeliveryMatches = matches.filter( + (message) => message.source === 'runtime_delivery' ); const match = - matches.find((message) => message.source === 'runtime_delivery') ?? matches[0] ?? null; + isUserFallbackForNonUserRecipient && !expectedMessageId + ? runtimeDeliveryMatches.length === 1 + ? runtimeDeliveryMatches[0] + : matches.length === 1 + ? matches[0] + : null + : (runtimeDeliveryMatches[0] ?? matches[0] ?? null); if (match) { + const matchMessageId = typeof match.messageId === 'string' ? match.messageId.trim() : ''; + if (!matchMessageId) { + continue; + } return { inboxName, - message: { ...match, messageId: match.messageId! }, + message: { ...match, messageId: matchMessageId }, missingRuntimeDeliverySource: match.source !== 'runtime_delivery', }; } @@ -4802,21 +4831,29 @@ export class TeamProvisioningService { private async getOpenCodeVisibleReplyInboxCandidates(input: { teamName: string; replyRecipient?: string | null; + includeUserFallbackForLeadRecipient?: boolean; }): Promise { const explicitRecipient = input.replyRecipient?.trim() || 'user'; const candidates = [explicitRecipient]; - if (this.isOpenCodeLeadReplyRecipientAlias(explicitRecipient)) { - const configuredLeadName = await this.configReader - .getConfig(input.teamName) - .then( - (config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null - ) - .catch(() => null); + const configuredLeadName = await this.configReader + .getConfig(input.teamName) + .then( + (config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null + ) + .catch(() => null); + const isConfiguredLeadRecipient = + Boolean(configuredLeadName) && + configuredLeadName?.toLowerCase() === explicitRecipient.toLowerCase(); + + if (this.isOpenCodeLeadReplyRecipientAlias(explicitRecipient) || isConfiguredLeadRecipient) { if (configuredLeadName) { candidates.push(configuredLeadName); } candidates.push('lead'); candidates.push('team-lead'); + if (input.includeUserFallbackForLeadRecipient) { + candidates.push('user'); + } } return candidates .filter((value): value is string => Boolean(value && value.trim())) @@ -4854,6 +4891,12 @@ export class TeamProvisioningService { replyRecipient: input.replyRecipient ?? input.ledgerRecord.replyRecipient, from: input.memberName, relayOfMessageId: input.ledgerRecord.inboxMessageId, + expectedMessageId: + input.ledgerRecord.visibleReplyCorrelation === 'relayOfMessageId' + ? input.ledgerRecord.visibleReplyMessageId + : null, + allowUserFallbackForLeadRecipient: + input.ledgerRecord.visibleReplyCorrelation === 'relayOfMessageId', }); if (!visibleReply) { return { ledgerRecord: input.ledgerRecord, visibleReply: null }; @@ -5693,6 +5736,12 @@ export class TeamProvisioningService { replyRecipient: input.replyRecipient ?? ledgerRecord.replyRecipient, from: canonicalMemberName, relayOfMessageId: ledgerRecord.inboxMessageId, + expectedMessageId: + ledgerRecord.visibleReplyCorrelation === 'relayOfMessageId' + ? ledgerRecord.visibleReplyMessageId + : null, + allowUserFallbackForLeadRecipient: + ledgerRecord.visibleReplyCorrelation === 'relayOfMessageId', }) : null; const readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({ diff --git a/src/renderer/components/team/members/memberActivityEntries.ts b/src/renderer/components/team/members/memberActivityEntries.ts index d3e1edf1..640e9bef 100644 --- a/src/renderer/components/team/members/memberActivityEntries.ts +++ b/src/renderer/components/team/members/memberActivityEntries.ts @@ -18,13 +18,14 @@ export function buildMemberActivityEntries({ tasks: TeamTaskWithKanban[]; messages: InboxMessage[]; }): InlineActivityEntry[] { + const leadName = members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`; const filteredMessages = filterTeamMessages(messages, { + leadNames: [leadName], timeWindow: null, filter: { from: new Set(), to: new Set(), showNoise: true }, searchQuery: '', }); const leadId = `lead:${teamName}`; - const leadName = members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`; const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`; const ownerNodeIds = new Set([leadId, ownerNodeId]); const entriesByOwner = buildInlineActivityEntries({ diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 23025ebb..feb2db82 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -21,6 +21,7 @@ import { selectTeamMessages } from '@renderer/store/slices/teamSlice'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { CheckCheck, ChevronsDownUp, @@ -408,22 +409,29 @@ export const MessagesPanel = memo(function MessagesPanel({ }; }, [position, mountPoint]); + const leadNames = useMemo( + () => members.filter((member) => isLeadMember(member)).map((member) => member.name), + [members] + ); + const filteredMessages = useMemo(() => { return filterTeamMessages(effectiveMessages, { + leadNames, timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); - }, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]); + }, [effectiveMessages, leadNames, messagesFilter, messagesSearchQuery, timeWindow]); const activityTimelineMessages = useMemo(() => { return filterTeamMessages(effectiveMessages, { includePassiveIdlePeerSummariesWhenNoiseHidden: true, + leadNames, timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); - }, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]); + }, [effectiveMessages, leadNames, messagesFilter, messagesSearchQuery, timeWindow]); const replyCandidateMessages = useMemo( () => diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index abd69aef..1888fe6f 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -13,10 +13,88 @@ export interface TeamMessagesFilter { showNoise: boolean; } +function normalizeMessageText(value: string | undefined): string { + return (value ?? '') + .trim() + .replace(/\r\n/g, '\n') + .replace(/[ \t]+/g, ' '); +} + +function normalizeParticipant(value: string | undefined): string { + return (value ?? '').trim().toLowerCase(); +} + +function normalizeLeadNames(values: Iterable | undefined): Set { + const normalized = new Set(); + for (const value of values ?? []) { + const name = normalizeParticipant(value); + if (name) { + normalized.add(name); + } + } + return normalized; +} + +function isLeadAlias(value: string | undefined): boolean { + const normalized = normalizeParticipant(value).replace(/[\s_]+/g, '-'); + return ( + normalized === 'lead' || + normalized === 'team-lead' || + normalized === 'teamlead' || + normalized === 'team-leader' + ); +} + +function isLeadParticipant(value: string | undefined, leadNames: Set): boolean { + const normalized = normalizeParticipant(value); + return isLeadAlias(value) || (normalized.length > 0 && leadNames.has(normalized)); +} + +function isRelayDuplicateOfVisibleMessage( + message: InboxMessage, + original: InboxMessage | undefined, + leadNames: Set +): boolean { + if (!original) { + return false; + } + + if (isInboxNoiseMessage(message.text)) { + return true; + } + + const isInternalLeadRelayDelivery = + (message.source === 'runtime_delivery' || message.source === 'lead_process') && + original.source === 'user_sent' && + normalizeParticipant(original.from) === 'user' && + isLeadParticipant(original.to, leadNames) && + isLeadParticipant(message.from, leadNames) && + normalizeParticipant(message.to) !== 'user'; + + if (isInternalLeadRelayDelivery) { + return true; + } + + const sameDirection = + normalizeParticipant(message.from) === normalizeParticipant(original.from) && + normalizeParticipant(message.to) === normalizeParticipant(original.to); + + if (!sameDirection) { + return false; + } + + if (message.source === 'lead_process' || message.source === 'runtime_delivery') { + return true; + } + + return normalizeMessageText(message.text) === normalizeMessageText(original.text); +} + export function filterTeamMessages( messages: InboxMessage[], options: { includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean; + leadNames?: Iterable; timeWindow?: { start: number; end: number } | null; filter: TeamMessagesFilter; searchQuery: string; @@ -24,10 +102,12 @@ export function filterTeamMessages( ): InboxMessage[] { const { includePassiveIdlePeerSummariesWhenNoiseHidden = false, + leadNames: rawLeadNames, timeWindow, filter, searchQuery, } = options; + const leadNames = normalizeLeadNames(rawLeadNames); let list = messages.filter((m) => m.messageKind !== 'task_comment_notification'); if (timeWindow) { @@ -74,10 +154,13 @@ export function filterTeamMessages( }); } - const visibleMessageIds = new Set( + const visibleMessagesById = new Map( list - .map((m) => (typeof m.messageId === 'string' ? m.messageId.trim() : '')) - .filter((id) => id.length > 0) + .map((m) => { + const id = typeof m.messageId === 'string' ? m.messageId.trim() : ''; + return id ? ([id, m] as const) : null; + }) + .filter((entry): entry is readonly [string, InboxMessage] => entry !== null) ); return list.filter((m) => { @@ -90,6 +173,10 @@ export function filterTeamMessages( if (relayOfMessageId === ownMessageId) { return true; } - return !visibleMessageIds.has(relayOfMessageId); + return !isRelayDuplicateOfVisibleMessage( + m, + visibleMessagesById.get(relayOfMessageId), + leadNames + ); }); } diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 65be4d7a..c4b1187c 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -3668,6 +3668,459 @@ describe('TeamProvisioningService', () => { expect(sendMessageToMember).not.toHaveBeenCalled(); }); + it('accepts observed visible OpenCode user replies for lead-delegated inbox messages', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'responded_visible_message', + deliveredUserMessageId: 'oc-user-1', + assistantMessageId: 'oc-assistant-1', + toolCallNames: ['message_send'], + visibleMessageToolCallId: 'call-1', + visibleReplyMessageId: 'reply-user-1', + visibleReplyCorrelation: 'relayOfMessageId', + latestAssistantPreview: null, + reason: 'visible_message_sent', + }, + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'user.json'), + `${JSON.stringify( + [ + { + from: 'bob', + to: 'user', + text: 'Here is the concrete answer for the user.', + timestamp: '2026-04-25T10:00:03.000Z', + read: false, + messageId: 'reply-user-1', + relayOfMessageId: 'msg-lead-delegated', + source: 'runtime_delivery', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Please answer the user.', + messageId: 'msg-lead-delegated', + replyRecipient: 'team-lead', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: false, + responseState: 'responded_visible_message', + visibleReplyMessageId: 'reply-user-1', + visibleReplyCorrelation: 'relayOfMessageId', + diagnostics: [], + }); + expect(sendMessageToMember).toHaveBeenCalledTimes(1); + expect(sendMessageToMember).toHaveBeenCalledWith( + expect.objectContaining({ + replyRecipient: 'team-lead', + messageId: 'msg-lead-delegated', + }) + ); + }); + + it('accepts exact observed OpenCode user replies for custom configured lead recipients', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'captain', providerId: 'codex', agentType: 'team-lead', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'user.json'), + `${JSON.stringify( + [ + { + from: 'bob', + to: 'user', + text: 'Old reply with the same relay id must not be accepted.', + timestamp: '2026-04-25T10:00:02.000Z', + read: false, + messageId: 'reply-user-stale', + relayOfMessageId: 'msg-custom-lead', + source: 'runtime_delivery', + }, + { + from: 'bob', + to: 'user', + text: 'Here is the observed answer for the user.', + timestamp: '2026-04-25T10:00:03.000Z', + read: false, + messageId: 'reply-user-custom', + relayOfMessageId: 'msg-custom-lead', + source: 'runtime_delivery', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + const proof = await (svc as any).findOpenCodeVisibleReplyByRelayOfMessageId({ + teamName: 'team-a', + replyRecipient: 'captain', + from: 'bob', + relayOfMessageId: 'msg-custom-lead', + expectedMessageId: 'reply-user-custom', + }); + + expect(proof).toMatchObject({ + inboxName: 'user', + message: { + messageId: 'reply-user-custom', + relayOfMessageId: 'msg-custom-lead', + from: 'bob', + to: 'user', + }, + missingRuntimeDeliverySource: false, + }); + }); + + it('uses the exact observed message id for direct OpenCode user replies', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', agentType: 'team-lead', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'user.json'), + `${JSON.stringify( + [ + { + from: 'bob', + to: 'user', + text: 'Old duplicate for the same delivery.', + timestamp: '2026-04-25T10:00:02.000Z', + read: false, + messageId: 'reply-user-stale', + relayOfMessageId: 'msg-direct-user', + source: 'runtime_delivery', + }, + { + from: 'bob', + to: 'user', + text: 'Current observed reply.', + timestamp: '2026-04-25T10:00:03.000Z', + read: false, + messageId: 'reply-user-current', + relayOfMessageId: 'msg-direct-user', + source: 'runtime_delivery', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + const proof = await (svc as any).findOpenCodeVisibleReplyByRelayOfMessageId({ + teamName: 'team-a', + replyRecipient: 'user', + from: 'bob', + relayOfMessageId: 'msg-direct-user', + expectedMessageId: 'reply-user-current', + }); + + expect(proof).toMatchObject({ + inboxName: 'user', + message: { + messageId: 'reply-user-current', + relayOfMessageId: 'msg-direct-user', + from: 'bob', + to: 'user', + }, + }); + }); + + it('accepts a unique OpenCode user fallback reply when relay correlation has no exact id', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'captain', providerId: 'codex', agentType: 'team-lead', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'user.json'), + `${JSON.stringify( + [ + { + from: 'alice', + to: 'user', + text: 'Different sender should not affect Bob proof.', + timestamp: '2026-04-25T10:00:01.000Z', + read: false, + messageId: 'reply-user-alice', + relayOfMessageId: 'msg-custom-lead-no-id', + source: 'runtime_delivery', + }, + { + from: 'bob', + to: 'user', + text: 'Here is the only Bob reply for this relay.', + timestamp: '2026-04-25T10:00:03.000Z', + read: false, + messageId: ' reply-user-single ', + relayOfMessageId: 'msg-custom-lead-no-id', + source: 'runtime_delivery', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + const proof = await (svc as any).findOpenCodeVisibleReplyByRelayOfMessageId({ + teamName: 'team-a', + replyRecipient: 'captain', + from: 'bob', + relayOfMessageId: 'msg-custom-lead-no-id', + allowUserFallbackForLeadRecipient: true, + }); + + expect(proof).toMatchObject({ + inboxName: 'user', + message: { + messageId: 'reply-user-single', + relayOfMessageId: 'msg-custom-lead-no-id', + from: 'bob', + to: 'user', + }, + missingRuntimeDeliverySource: false, + }); + }); + + it('does not use OpenCode user fallback for lead recipients without confirmed relay correlation', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'captain', providerId: 'codex', agentType: 'team-lead', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'user.json'), + `${JSON.stringify( + [ + { + from: 'bob', + to: 'user', + text: 'This exists, but the caller did not confirm relay correlation.', + timestamp: '2026-04-25T10:00:03.000Z', + read: false, + messageId: 'reply-user-single', + relayOfMessageId: 'msg-custom-lead-no-correlation', + source: 'runtime_delivery', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + const proof = await (svc as any).findOpenCodeVisibleReplyByRelayOfMessageId({ + teamName: 'team-a', + replyRecipient: 'captain', + from: 'bob', + relayOfMessageId: 'msg-custom-lead-no-correlation', + }); + + expect(proof).toBeNull(); + }); + + it('rejects ambiguous OpenCode user fallback replies when relay correlation has no exact id', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'captain', providerId: 'codex', agentType: 'team-lead', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'user.json'), + `${JSON.stringify( + [ + { + from: 'bob', + to: 'user', + text: 'First candidate.', + timestamp: '2026-04-25T10:00:02.000Z', + read: false, + messageId: 'reply-user-1', + relayOfMessageId: 'msg-custom-lead-ambiguous', + source: 'runtime_delivery', + }, + { + from: 'bob', + to: 'user', + text: 'Second candidate.', + timestamp: '2026-04-25T10:00:03.000Z', + read: false, + messageId: 'reply-user-2', + relayOfMessageId: 'msg-custom-lead-ambiguous', + source: 'runtime_delivery', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + const proof = await (svc as any).findOpenCodeVisibleReplyByRelayOfMessageId({ + teamName: 'team-a', + replyRecipient: 'captain', + from: 'bob', + relayOfMessageId: 'msg-custom-lead-ambiguous', + allowUserFallbackForLeadRecipient: true, + }); + + expect(proof).toBeNull(); + }); + + it('rejects custom lead user fallback replies without the exact observed message id', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'captain', providerId: 'codex', agentType: 'team-lead', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'user.json'), + `${JSON.stringify( + [ + { + from: 'bob', + to: 'user', + text: 'This is not the observed reply for the current delivery.', + timestamp: '2026-04-25T10:00:03.000Z', + read: false, + messageId: 'reply-user-stale', + relayOfMessageId: 'msg-custom-lead', + source: 'runtime_delivery', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + const proof = await (svc as any).findOpenCodeVisibleReplyByRelayOfMessageId({ + teamName: 'team-a', + replyRecipient: 'captain', + from: 'bob', + relayOfMessageId: 'msg-custom-lead', + expectedMessageId: 'reply-user-expected', + }); + + expect(proof).toBeNull(); + }); + it('uses legacy OpenCode prompt acceptance semantics when the watchdog is disabled', async () => { const previous = process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG; process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG = '0'; diff --git a/test/renderer/utils/teamMessageFiltering.test.ts b/test/renderer/utils/teamMessageFiltering.test.ts index c8e7ac65..af48730e 100644 --- a/test/renderer/utils/teamMessageFiltering.test.ts +++ b/test/renderer/utils/teamMessageFiltering.test.ts @@ -64,6 +64,32 @@ describe('filterTeamMessages', () => { expect(result[0].messageId).toBe('orig-1'); }); + it('hides same-direction relay bridge copies even when sanitized text differs', () => { + const messages = [ + makeMessage({ + messageId: 'orig-1', + to: 'alice', + source: 'system_notification', + text: 'Comment on task #abcd1234.\nhidden', + }), + makeMessage({ + messageId: 'relay-1', + to: 'alice', + source: 'lead_process', + text: 'Comment on task #abcd1234.', + relayOfMessageId: 'orig-1', + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['orig-1']); + }); + it('keeps relay bridge copies when the original message is not visible', () => { const messages = [ makeMessage({ @@ -85,6 +111,139 @@ describe('filterTeamMessages', () => { expect(result[0].messageId).toBe('relay-1'); }); + it('keeps OpenCode visible replies linked to a visible delivery prompt', () => { + const messages = [ + makeMessage({ + messageId: 'delivery-1', + from: 'team-lead', + to: 'jack', + source: 'runtime_delivery', + text: 'Please send a short greeting to the user.', + }), + makeMessage({ + messageId: 'reply-1', + from: 'jack', + to: 'user', + source: 'runtime_delivery', + text: 'Привет! Я Джек, готов помочь.', + relayOfMessageId: 'delivery-1', + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['delivery-1', 'reply-1']); + }); + + it('hides internal lead relay deliveries while keeping member replies', () => { + const messages = [ + makeMessage({ + messageId: 'user-request-1', + from: 'user', + to: 'team-lead', + source: 'user_sent', + text: 'Ask everyone to message me.', + }), + makeMessage({ + messageId: 'delivery-1', + from: 'team-lead', + to: 'jack', + source: 'runtime_delivery', + text: 'Please message the user directly.', + relayOfMessageId: 'user-request-1', + }), + makeMessage({ + messageId: 'reply-1', + from: 'jack', + to: 'user', + source: 'runtime_delivery', + text: 'Привет! Я Джек, готов помочь.', + relayOfMessageId: 'delivery-1', + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['user-request-1', 'reply-1']); + }); + + it('hides internal relay deliveries from custom-named leads', () => { + const messages = [ + makeMessage({ + messageId: 'user-request-1', + from: 'user', + to: 'captain', + source: 'user_sent', + text: 'Ask Alice to check this.', + }), + makeMessage({ + messageId: 'delivery-1', + from: 'captain', + to: 'alice', + source: 'lead_process', + text: 'Please check this for the user.', + relayOfMessageId: 'user-request-1', + }), + makeMessage({ + messageId: 'reply-1', + from: 'alice', + to: 'user', + source: 'runtime_delivery', + text: 'I checked it.', + relayOfMessageId: 'delivery-1', + }), + ]; + + const result = filterTeamMessages(messages, { + leadNames: ['captain'], + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['user-request-1', 'reply-1']); + }); + + it('keeps member relay messages when the sender is not a configured lead', () => { + const messages = [ + makeMessage({ + messageId: 'user-request-1', + from: 'user', + to: 'captain', + source: 'user_sent', + text: 'Ask Alice to check this.', + }), + makeMessage({ + messageId: 'member-relay-1', + from: 'captain', + to: 'alice', + source: 'runtime_delivery', + text: 'Alice, can you check this?', + relayOfMessageId: 'user-request-1', + }), + ]; + + const result = filterTeamMessages(messages, { + leadNames: ['team-lead'], + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual([ + 'user-request-1', + 'member-relay-1', + ]); + }); + it('still filters noise messages when showNoise is false', () => { const messages = [ makeMessage({