fix: show opencode teammate replies in messages
This commit is contained in:
parent
8061b66c34
commit
b67168a9e8
6 changed files with 778 additions and 21 deletions
|
|
@ -4765,33 +4765,62 @@ export class TeamProvisioningService {
|
||||||
replyRecipient?: string | null;
|
replyRecipient?: string | null;
|
||||||
from: string;
|
from: string;
|
||||||
relayOfMessageId: string;
|
relayOfMessageId: string;
|
||||||
|
expectedMessageId?: string | null;
|
||||||
|
allowUserFallbackForLeadRecipient?: boolean;
|
||||||
}): Promise<OpenCodeVisibleReplyProof | null> {
|
}): Promise<OpenCodeVisibleReplyProof | null> {
|
||||||
const relayOfMessageId = input.relayOfMessageId.trim();
|
const relayOfMessageId = input.relayOfMessageId.trim();
|
||||||
if (!relayOfMessageId) {
|
if (!relayOfMessageId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const expectedMessageId = input.expectedMessageId?.trim() || null;
|
||||||
const candidates = await this.getOpenCodeVisibleReplyInboxCandidates({
|
const candidates = await this.getOpenCodeVisibleReplyInboxCandidates({
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
replyRecipient: input.replyRecipient,
|
replyRecipient: input.replyRecipient,
|
||||||
|
includeUserFallbackForLeadRecipient: Boolean(
|
||||||
|
expectedMessageId || input.allowUserFallbackForLeadRecipient
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
const explicitRecipient = input.replyRecipient?.trim() || 'user';
|
||||||
const expectedFrom = input.from.trim().toLowerCase();
|
const expectedFrom = input.from.trim().toLowerCase();
|
||||||
for (const inboxName of candidates) {
|
for (const inboxName of candidates) {
|
||||||
const messages = await this.inboxReader
|
const messages = await this.inboxReader
|
||||||
.getMessagesFor(input.teamName, inboxName)
|
.getMessagesFor(input.teamName, inboxName)
|
||||||
.catch(() => []);
|
.catch(() => []);
|
||||||
|
const isUserFallbackForNonUserRecipient =
|
||||||
|
inboxName.trim().toLowerCase() === 'user' &&
|
||||||
|
explicitRecipient.trim().toLowerCase() !== 'user';
|
||||||
const matches = messages.filter(
|
const matches = messages.filter(
|
||||||
(message): message is InboxMessage & { messageId: string } =>
|
(message): message is InboxMessage & { messageId: string } => {
|
||||||
typeof message.messageId === 'string' &&
|
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||||
message.messageId.trim().length > 0 &&
|
const messageRelayOf =
|
||||||
message.relayOfMessageId === relayOfMessageId &&
|
typeof message.relayOfMessageId === 'string' ? message.relayOfMessageId.trim() : '';
|
||||||
message.from.trim().toLowerCase() === expectedFrom
|
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 =
|
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) {
|
if (match) {
|
||||||
|
const matchMessageId = typeof match.messageId === 'string' ? match.messageId.trim() : '';
|
||||||
|
if (!matchMessageId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
inboxName,
|
inboxName,
|
||||||
message: { ...match, messageId: match.messageId! },
|
message: { ...match, messageId: matchMessageId },
|
||||||
missingRuntimeDeliverySource: match.source !== 'runtime_delivery',
|
missingRuntimeDeliverySource: match.source !== 'runtime_delivery',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -4802,21 +4831,29 @@ export class TeamProvisioningService {
|
||||||
private async getOpenCodeVisibleReplyInboxCandidates(input: {
|
private async getOpenCodeVisibleReplyInboxCandidates(input: {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
replyRecipient?: string | null;
|
replyRecipient?: string | null;
|
||||||
|
includeUserFallbackForLeadRecipient?: boolean;
|
||||||
}): Promise<string[]> {
|
}): Promise<string[]> {
|
||||||
const explicitRecipient = input.replyRecipient?.trim() || 'user';
|
const explicitRecipient = input.replyRecipient?.trim() || 'user';
|
||||||
const candidates = [explicitRecipient];
|
const candidates = [explicitRecipient];
|
||||||
if (this.isOpenCodeLeadReplyRecipientAlias(explicitRecipient)) {
|
const configuredLeadName = await this.configReader
|
||||||
const configuredLeadName = await this.configReader
|
.getConfig(input.teamName)
|
||||||
.getConfig(input.teamName)
|
.then(
|
||||||
.then(
|
(config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null
|
||||||
(config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null
|
)
|
||||||
)
|
.catch(() => null);
|
||||||
.catch(() => null);
|
const isConfiguredLeadRecipient =
|
||||||
|
Boolean(configuredLeadName) &&
|
||||||
|
configuredLeadName?.toLowerCase() === explicitRecipient.toLowerCase();
|
||||||
|
|
||||||
|
if (this.isOpenCodeLeadReplyRecipientAlias(explicitRecipient) || isConfiguredLeadRecipient) {
|
||||||
if (configuredLeadName) {
|
if (configuredLeadName) {
|
||||||
candidates.push(configuredLeadName);
|
candidates.push(configuredLeadName);
|
||||||
}
|
}
|
||||||
candidates.push('lead');
|
candidates.push('lead');
|
||||||
candidates.push('team-lead');
|
candidates.push('team-lead');
|
||||||
|
if (input.includeUserFallbackForLeadRecipient) {
|
||||||
|
candidates.push('user');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return candidates
|
return candidates
|
||||||
.filter((value): value is string => Boolean(value && value.trim()))
|
.filter((value): value is string => Boolean(value && value.trim()))
|
||||||
|
|
@ -4854,6 +4891,12 @@ export class TeamProvisioningService {
|
||||||
replyRecipient: input.replyRecipient ?? input.ledgerRecord.replyRecipient,
|
replyRecipient: input.replyRecipient ?? input.ledgerRecord.replyRecipient,
|
||||||
from: input.memberName,
|
from: input.memberName,
|
||||||
relayOfMessageId: input.ledgerRecord.inboxMessageId,
|
relayOfMessageId: input.ledgerRecord.inboxMessageId,
|
||||||
|
expectedMessageId:
|
||||||
|
input.ledgerRecord.visibleReplyCorrelation === 'relayOfMessageId'
|
||||||
|
? input.ledgerRecord.visibleReplyMessageId
|
||||||
|
: null,
|
||||||
|
allowUserFallbackForLeadRecipient:
|
||||||
|
input.ledgerRecord.visibleReplyCorrelation === 'relayOfMessageId',
|
||||||
});
|
});
|
||||||
if (!visibleReply) {
|
if (!visibleReply) {
|
||||||
return { ledgerRecord: input.ledgerRecord, visibleReply: null };
|
return { ledgerRecord: input.ledgerRecord, visibleReply: null };
|
||||||
|
|
@ -5693,6 +5736,12 @@ export class TeamProvisioningService {
|
||||||
replyRecipient: input.replyRecipient ?? ledgerRecord.replyRecipient,
|
replyRecipient: input.replyRecipient ?? ledgerRecord.replyRecipient,
|
||||||
from: canonicalMemberName,
|
from: canonicalMemberName,
|
||||||
relayOfMessageId: ledgerRecord.inboxMessageId,
|
relayOfMessageId: ledgerRecord.inboxMessageId,
|
||||||
|
expectedMessageId:
|
||||||
|
ledgerRecord.visibleReplyCorrelation === 'relayOfMessageId'
|
||||||
|
? ledgerRecord.visibleReplyMessageId
|
||||||
|
: null,
|
||||||
|
allowUserFallbackForLeadRecipient:
|
||||||
|
ledgerRecord.visibleReplyCorrelation === 'relayOfMessageId',
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
const readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,14 @@ export function buildMemberActivityEntries({
|
||||||
tasks: TeamTaskWithKanban[];
|
tasks: TeamTaskWithKanban[];
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
}): InlineActivityEntry[] {
|
}): InlineActivityEntry[] {
|
||||||
|
const leadName = members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`;
|
||||||
const filteredMessages = filterTeamMessages(messages, {
|
const filteredMessages = filterTeamMessages(messages, {
|
||||||
|
leadNames: [leadName],
|
||||||
timeWindow: null,
|
timeWindow: null,
|
||||||
filter: { from: new Set(), to: new Set(), showNoise: true },
|
filter: { from: new Set(), to: new Set(), showNoise: true },
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
});
|
});
|
||||||
const leadId = `lead:${teamName}`;
|
const leadId = `lead:${teamName}`;
|
||||||
const leadName = members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`;
|
|
||||||
const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`;
|
const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`;
|
||||||
const ownerNodeIds = new Set([leadId, ownerNodeId]);
|
const ownerNodeIds = new Set([leadId, ownerNodeId]);
|
||||||
const entriesByOwner = buildInlineActivityEntries({
|
const entriesByOwner = buildInlineActivityEntries({
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { selectTeamMessages } from '@renderer/store/slices/teamSlice';
|
||||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import {
|
import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
|
|
@ -408,22 +409,29 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
};
|
};
|
||||||
}, [position, mountPoint]);
|
}, [position, mountPoint]);
|
||||||
|
|
||||||
|
const leadNames = useMemo(
|
||||||
|
() => members.filter((member) => isLeadMember(member)).map((member) => member.name),
|
||||||
|
[members]
|
||||||
|
);
|
||||||
|
|
||||||
const filteredMessages = useMemo(() => {
|
const filteredMessages = useMemo(() => {
|
||||||
return filterTeamMessages(effectiveMessages, {
|
return filterTeamMessages(effectiveMessages, {
|
||||||
|
leadNames,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
filter: messagesFilter,
|
filter: messagesFilter,
|
||||||
searchQuery: messagesSearchQuery,
|
searchQuery: messagesSearchQuery,
|
||||||
});
|
});
|
||||||
}, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]);
|
}, [effectiveMessages, leadNames, messagesFilter, messagesSearchQuery, timeWindow]);
|
||||||
|
|
||||||
const activityTimelineMessages = useMemo(() => {
|
const activityTimelineMessages = useMemo(() => {
|
||||||
return filterTeamMessages(effectiveMessages, {
|
return filterTeamMessages(effectiveMessages, {
|
||||||
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
||||||
|
leadNames,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
filter: messagesFilter,
|
filter: messagesFilter,
|
||||||
searchQuery: messagesSearchQuery,
|
searchQuery: messagesSearchQuery,
|
||||||
});
|
});
|
||||||
}, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]);
|
}, [effectiveMessages, leadNames, messagesFilter, messagesSearchQuery, timeWindow]);
|
||||||
|
|
||||||
const replyCandidateMessages = useMemo(
|
const replyCandidateMessages = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,88 @@ export interface TeamMessagesFilter {
|
||||||
showNoise: boolean;
|
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<string> | undefined): Set<string> {
|
||||||
|
const normalized = new Set<string>();
|
||||||
|
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<string>): boolean {
|
||||||
|
const normalized = normalizeParticipant(value);
|
||||||
|
return isLeadAlias(value) || (normalized.length > 0 && leadNames.has(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRelayDuplicateOfVisibleMessage(
|
||||||
|
message: InboxMessage,
|
||||||
|
original: InboxMessage | undefined,
|
||||||
|
leadNames: Set<string>
|
||||||
|
): 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(
|
export function filterTeamMessages(
|
||||||
messages: InboxMessage[],
|
messages: InboxMessage[],
|
||||||
options: {
|
options: {
|
||||||
includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean;
|
includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean;
|
||||||
|
leadNames?: Iterable<string>;
|
||||||
timeWindow?: { start: number; end: number } | null;
|
timeWindow?: { start: number; end: number } | null;
|
||||||
filter: TeamMessagesFilter;
|
filter: TeamMessagesFilter;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
|
|
@ -24,10 +102,12 @@ export function filterTeamMessages(
|
||||||
): InboxMessage[] {
|
): InboxMessage[] {
|
||||||
const {
|
const {
|
||||||
includePassiveIdlePeerSummariesWhenNoiseHidden = false,
|
includePassiveIdlePeerSummariesWhenNoiseHidden = false,
|
||||||
|
leadNames: rawLeadNames,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
filter,
|
filter,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
} = options;
|
} = options;
|
||||||
|
const leadNames = normalizeLeadNames(rawLeadNames);
|
||||||
|
|
||||||
let list = messages.filter((m) => m.messageKind !== 'task_comment_notification');
|
let list = messages.filter((m) => m.messageKind !== 'task_comment_notification');
|
||||||
if (timeWindow) {
|
if (timeWindow) {
|
||||||
|
|
@ -74,10 +154,13 @@ export function filterTeamMessages(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleMessageIds = new Set(
|
const visibleMessagesById = new Map(
|
||||||
list
|
list
|
||||||
.map((m) => (typeof m.messageId === 'string' ? m.messageId.trim() : ''))
|
.map((m) => {
|
||||||
.filter((id) => id.length > 0)
|
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) => {
|
return list.filter((m) => {
|
||||||
|
|
@ -90,6 +173,10 @@ export function filterTeamMessages(
|
||||||
if (relayOfMessageId === ownMessageId) {
|
if (relayOfMessageId === ownMessageId) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return !visibleMessageIds.has(relayOfMessageId);
|
return !isRelayDuplicateOfVisibleMessage(
|
||||||
|
m,
|
||||||
|
visibleMessagesById.get(relayOfMessageId),
|
||||||
|
leadNames
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3668,6 +3668,459 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(sendMessageToMember).not.toHaveBeenCalled();
|
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<string, unknown>) => ({
|
||||||
|
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 () => {
|
it('uses legacy OpenCode prompt acceptance semantics when the watchdog is disabled', async () => {
|
||||||
const previous = process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG;
|
const previous = process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG;
|
||||||
process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG = '0';
|
process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG = '0';
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,32 @@ describe('filterTeamMessages', () => {
|
||||||
expect(result[0].messageId).toBe('orig-1');
|
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.\n<agent-block>hidden</agent-block>',
|
||||||
|
}),
|
||||||
|
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', () => {
|
it('keeps relay bridge copies when the original message is not visible', () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
|
|
@ -85,6 +111,139 @@ describe('filterTeamMessages', () => {
|
||||||
expect(result[0].messageId).toBe('relay-1');
|
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', () => {
|
it('still filters noise messages when showNoise is false', () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue