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;
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue