fix: show opencode teammate replies in messages

This commit is contained in:
777genius 2026-04-26 21:35:48 +03:00
parent 8061b66c34
commit b67168a9e8
6 changed files with 778 additions and 21 deletions

View file

@ -4765,33 +4765,62 @@ export class TeamProvisioningService {
replyRecipient?: string | null;
from: string;
relayOfMessageId: string;
expectedMessageId?: string | null;
allowUserFallbackForLeadRecipient?: boolean;
}): Promise<OpenCodeVisibleReplyProof | null> {
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<string[]> {
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({

View file

@ -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({

View file

@ -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(
() =>

View file

@ -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<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(
messages: InboxMessage[],
options: {
includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean;
leadNames?: Iterable<string>;
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
);
});
}

View file

@ -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<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 () => {
const previous = process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG;
process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG = '0';

View file

@ -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.\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', () => {
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({