feat: add relay message ID support for improved message tracking
- Introduced `relayOfMessageId` to various message handling components, allowing for better tracking of relayed messages. - Updated `buildMessage`, `TeamInboxReader`, `TeamInboxWriter`, and related services to accommodate the new relay ID. - Enhanced filtering logic in `filterTeamMessages` to hide relay copies when the original message is visible, improving message clarity. - Added tests to validate the functionality of relay message handling and ensure proper integration across services.
This commit is contained in:
parent
194bd1bf1e
commit
0c0088c871
15 changed files with 339 additions and 10 deletions
|
|
@ -91,6 +91,9 @@ function buildMessage(flags, defaults) {
|
||||||
...(typeof flags.summary === 'string' && flags.summary.trim()
|
...(typeof flags.summary === 'string' && flags.summary.trim()
|
||||||
? { summary: flags.summary.trim() }
|
? { summary: flags.summary.trim() }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(typeof flags.relayOfMessageId === 'string' && flags.relayOfMessageId.trim()
|
||||||
|
? { relayOfMessageId: flags.relayOfMessageId.trim() }
|
||||||
|
: {}),
|
||||||
...(typeof flags.source === 'string' && flags.source.trim() ? { source: flags.source.trim() } : {}),
|
...(typeof flags.source === 'string' && flags.source.trim() ? { source: flags.source.trim() } : {}),
|
||||||
...(typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()
|
...(typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()
|
||||||
? { leadSessionId: flags.leadSessionId.trim() }
|
? { leadSessionId: flags.leadSessionId.trim() }
|
||||||
|
|
|
||||||
|
|
@ -539,6 +539,7 @@ describe('agent-teams-controller API', () => {
|
||||||
from: 'team-lead',
|
from: 'team-lead',
|
||||||
text: 'Need your review',
|
text: 'Need your review',
|
||||||
summary: 'Review request',
|
summary: 'Review request',
|
||||||
|
relayOfMessageId: 'm-original-1',
|
||||||
source: 'system_notification',
|
source: 'system_notification',
|
||||||
leadSessionId: 'session-42',
|
leadSessionId: 'session-42',
|
||||||
attachments: [{ id: 'a1', filename: 'note.txt', mimeType: 'text/plain', size: 7 }],
|
attachments: [{ id: 'a1', filename: 'note.txt', mimeType: 'text/plain', size: 7 }],
|
||||||
|
|
@ -551,6 +552,7 @@ describe('agent-teams-controller API', () => {
|
||||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
expect(rows[0].source).toBe('system_notification');
|
expect(rows[0].source).toBe('system_notification');
|
||||||
|
expect(rows[0].relayOfMessageId).toBe('m-original-1');
|
||||||
expect(rows[0].leadSessionId).toBe('session-42');
|
expect(rows[0].leadSessionId).toBe('session-42');
|
||||||
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,8 @@ export class TeamInboxReader {
|
||||||
summary: typeof row.summary === 'string' ? row.summary : undefined,
|
summary: typeof row.summary === 'string' ? row.summary : undefined,
|
||||||
color: typeof row.color === 'string' ? row.color : undefined,
|
color: typeof row.color === 'string' ? row.color : undefined,
|
||||||
messageId: row.messageId,
|
messageId: row.messageId,
|
||||||
|
relayOfMessageId:
|
||||||
|
typeof row.relayOfMessageId === 'string' ? row.relayOfMessageId : undefined,
|
||||||
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
||||||
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
||||||
conversationId: typeof row.conversationId === 'string' ? row.conversationId : undefined,
|
conversationId: typeof row.conversationId === 'string' ? row.conversationId : undefined,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export class TeamInboxWriter {
|
||||||
taskRefs: request.taskRefs?.length ? request.taskRefs : undefined,
|
taskRefs: request.taskRefs?.length ? request.taskRefs : undefined,
|
||||||
summary: request.summary,
|
summary: request.summary,
|
||||||
messageId,
|
messageId,
|
||||||
|
...(request.relayOfMessageId && { relayOfMessageId: request.relayOfMessageId }),
|
||||||
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
|
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
|
||||||
...(request.source && { source: request.source }),
|
...(request.source && { source: request.source }),
|
||||||
...(request.leadSessionId && { leadSessionId: request.leadSessionId }),
|
...(request.leadSessionId && { leadSessionId: request.leadSessionId }),
|
||||||
|
|
|
||||||
|
|
@ -250,9 +250,15 @@ interface ProvisioningRun {
|
||||||
* When set, the current stdin-injected turn is an internal "forward user DM to teammate"
|
* When set, the current stdin-injected turn is an internal "forward user DM to teammate"
|
||||||
* request triggered by the UI. We suppress any lead→user echo for that turn.
|
* request triggered by the UI. We suppress any lead→user echo for that turn.
|
||||||
*/
|
*/
|
||||||
silentUserDmForward: { target: string; startedAt: string } | null;
|
silentUserDmForward: {
|
||||||
|
target: string;
|
||||||
|
startedAt: string;
|
||||||
|
mode: 'user_dm' | 'member_inbox_relay';
|
||||||
|
} | null;
|
||||||
/** Safety valve: clears silentUserDmForward if turn never completes. */
|
/** Safety valve: clears silentUserDmForward if turn never completes. */
|
||||||
silentUserDmForwardClearHandle: NodeJS.Timeout | null;
|
silentUserDmForwardClearHandle: NodeJS.Timeout | null;
|
||||||
|
/** Exact inbox rows currently being bridged into the live teammate process. */
|
||||||
|
pendingInboxRelayCandidates: PendingInboxRelayCandidate[];
|
||||||
/** Accumulates assistant text during provisioning phase for live UI preview. */
|
/** Accumulates assistant text during provisioning phase for live UI preview. */
|
||||||
provisioningOutputParts: string[];
|
provisioningOutputParts: string[];
|
||||||
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
|
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
|
||||||
|
|
@ -1343,9 +1349,18 @@ function isTransientProbeWarning(warning: string): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingInboxRelayCandidate {
|
||||||
|
recipient: string;
|
||||||
|
sourceMessageId: string;
|
||||||
|
normalizedText: string;
|
||||||
|
normalizedSummary: string;
|
||||||
|
queuedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamProvisioningService {
|
export class TeamProvisioningService {
|
||||||
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
|
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
|
||||||
private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000;
|
private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000;
|
||||||
|
private static readonly PENDING_INBOX_RELAY_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
private readonly runs = new Map<string, ProvisioningRun>();
|
private readonly runs = new Map<string, ProvisioningRun>();
|
||||||
private readonly provisioningRunByTeam = new Map<string, string>();
|
private readonly provisioningRunByTeam = new Map<string, string>();
|
||||||
|
|
@ -1904,6 +1919,7 @@ export class TeamProvisioningService {
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
summary: message.summary,
|
summary: message.summary,
|
||||||
messageId: message.messageId,
|
messageId: message.messageId,
|
||||||
|
relayOfMessageId: message.relayOfMessageId,
|
||||||
source: message.source,
|
source: message.source,
|
||||||
leadSessionId: message.leadSessionId,
|
leadSessionId: message.leadSessionId,
|
||||||
conversationId: message.conversationId,
|
conversationId: message.conversationId,
|
||||||
|
|
@ -1931,6 +1947,7 @@ export class TeamProvisioningService {
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
summary: message.summary,
|
summary: message.summary,
|
||||||
messageId: message.messageId,
|
messageId: message.messageId,
|
||||||
|
relayOfMessageId: message.relayOfMessageId,
|
||||||
source: message.source,
|
source: message.source,
|
||||||
leadSessionId: message.leadSessionId,
|
leadSessionId: message.leadSessionId,
|
||||||
conversationId: message.conversationId,
|
conversationId: message.conversationId,
|
||||||
|
|
@ -1950,8 +1967,100 @@ export class TeamProvisioningService {
|
||||||
return `${teamName}:${memberName.trim()}`;
|
return `${teamName}:${memberName.trim()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private armSilentTeammateForward(run: ProvisioningRun, teammateName: string): void {
|
private normalizeRelayCandidateText(text: string): string {
|
||||||
run.silentUserDmForward = { target: teammateName, startedAt: nowIso() };
|
return stripAgentBlocks(String(text)).trim().replace(/\r\n/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRelayCandidateSummary(summary?: string): string {
|
||||||
|
return typeof summary === 'string' ? summary.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private prunePendingInboxRelayCandidates(run: ProvisioningRun): PendingInboxRelayCandidate[] {
|
||||||
|
const cutoff = Date.now() - TeamProvisioningService.PENDING_INBOX_RELAY_TTL_MS;
|
||||||
|
run.pendingInboxRelayCandidates = (run.pendingInboxRelayCandidates ?? []).filter(
|
||||||
|
(candidate) => candidate.queuedAtMs >= cutoff
|
||||||
|
);
|
||||||
|
return run.pendingInboxRelayCandidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberPendingInboxRelayCandidates(
|
||||||
|
run: ProvisioningRun,
|
||||||
|
recipient: string,
|
||||||
|
messages: Array<Pick<InboxMessage, 'messageId' | 'text' | 'summary'>>
|
||||||
|
): string[] {
|
||||||
|
const candidates = this.prunePendingInboxRelayCandidates(run);
|
||||||
|
const queuedAtMs = Date.now();
|
||||||
|
const rememberedIds: string[] = [];
|
||||||
|
for (const message of messages) {
|
||||||
|
const sourceMessageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||||
|
const normalizedText = this.normalizeRelayCandidateText(message.text);
|
||||||
|
if (!sourceMessageId || !normalizedText) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push({
|
||||||
|
recipient,
|
||||||
|
sourceMessageId,
|
||||||
|
normalizedText,
|
||||||
|
normalizedSummary: this.normalizeRelayCandidateSummary(message.summary),
|
||||||
|
queuedAtMs,
|
||||||
|
});
|
||||||
|
rememberedIds.push(sourceMessageId);
|
||||||
|
}
|
||||||
|
return rememberedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private forgetPendingInboxRelayCandidates(
|
||||||
|
run: ProvisioningRun,
|
||||||
|
recipient: string,
|
||||||
|
sourceMessageIds: readonly string[]
|
||||||
|
): void {
|
||||||
|
if (sourceMessageIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idSet = new Set(sourceMessageIds);
|
||||||
|
run.pendingInboxRelayCandidates = this.prunePendingInboxRelayCandidates(run).filter(
|
||||||
|
(candidate) => !(candidate.recipient === recipient && idSet.has(candidate.sourceMessageId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private consumePendingInboxRelayCandidate(
|
||||||
|
run: ProvisioningRun,
|
||||||
|
recipient: string,
|
||||||
|
text: string,
|
||||||
|
summary?: string
|
||||||
|
): string | undefined {
|
||||||
|
const normalizedText = this.normalizeRelayCandidateText(text);
|
||||||
|
if (!normalizedText) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const normalizedSummary = this.normalizeRelayCandidateSummary(summary);
|
||||||
|
const candidates = this.prunePendingInboxRelayCandidates(run);
|
||||||
|
const exactSummaryIdx = candidates.findIndex(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.recipient === recipient &&
|
||||||
|
candidate.normalizedText === normalizedText &&
|
||||||
|
candidate.normalizedSummary === normalizedSummary
|
||||||
|
);
|
||||||
|
const fallbackIdx =
|
||||||
|
exactSummaryIdx >= 0
|
||||||
|
? exactSummaryIdx
|
||||||
|
: candidates.findIndex(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.recipient === recipient && candidate.normalizedText === normalizedText
|
||||||
|
);
|
||||||
|
if (fallbackIdx < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const [matched] = candidates.splice(fallbackIdx, 1);
|
||||||
|
return matched?.sourceMessageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private armSilentTeammateForward(
|
||||||
|
run: ProvisioningRun,
|
||||||
|
teammateName: string,
|
||||||
|
mode: 'user_dm' | 'member_inbox_relay'
|
||||||
|
): void {
|
||||||
|
run.silentUserDmForward = { target: teammateName, startedAt: nowIso(), mode };
|
||||||
if (run.silentUserDmForwardClearHandle) {
|
if (run.silentUserDmForwardClearHandle) {
|
||||||
clearTimeout(run.silentUserDmForwardClearHandle);
|
clearTimeout(run.silentUserDmForwardClearHandle);
|
||||||
run.silentUserDmForwardClearHandle = null;
|
run.silentUserDmForwardClearHandle = null;
|
||||||
|
|
@ -2732,6 +2841,7 @@ export class TeamProvisioningService {
|
||||||
lastLeadTextEmitMs: 0,
|
lastLeadTextEmitMs: 0,
|
||||||
silentUserDmForward: null,
|
silentUserDmForward: null,
|
||||||
silentUserDmForwardClearHandle: null,
|
silentUserDmForwardClearHandle: null,
|
||||||
|
pendingInboxRelayCandidates: [],
|
||||||
provisioningOutputParts: [],
|
provisioningOutputParts: [],
|
||||||
detectedSessionId: null,
|
detectedSessionId: null,
|
||||||
leadActivityState: 'active',
|
leadActivityState: 'active',
|
||||||
|
|
@ -3095,6 +3205,7 @@ export class TeamProvisioningService {
|
||||||
lastLeadTextEmitMs: 0,
|
lastLeadTextEmitMs: 0,
|
||||||
silentUserDmForward: null,
|
silentUserDmForward: null,
|
||||||
silentUserDmForwardClearHandle: null,
|
silentUserDmForwardClearHandle: null,
|
||||||
|
pendingInboxRelayCandidates: [],
|
||||||
provisioningOutputParts: [],
|
provisioningOutputParts: [],
|
||||||
detectedSessionId: null,
|
detectedSessionId: null,
|
||||||
leadActivityState: 'active',
|
leadActivityState: 'active',
|
||||||
|
|
@ -3388,7 +3499,7 @@ export class TeamProvisioningService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.armSilentTeammateForward(run, teammateName);
|
this.armSilentTeammateForward(run, teammateName, 'user_dm');
|
||||||
|
|
||||||
const summaryLine = userSummary?.trim() ? `Summary: ${userSummary.trim()}` : null;
|
const summaryLine = userSummary?.trim() ? `Summary: ${userSummary.trim()}` : null;
|
||||||
const internal = wrapInAgentBlock(
|
const internal = wrapInAgentBlock(
|
||||||
|
|
@ -3466,7 +3577,8 @@ export class TeamProvisioningService {
|
||||||
const MAX_RELAY = 10;
|
const MAX_RELAY = 10;
|
||||||
const batch = actionableUnread.slice(0, MAX_RELAY);
|
const batch = actionableUnread.slice(0, MAX_RELAY);
|
||||||
|
|
||||||
this.armSilentTeammateForward(run, memberName);
|
this.armSilentTeammateForward(run, memberName, 'member_inbox_relay');
|
||||||
|
const rememberedRelayIds = this.rememberPendingInboxRelayCandidates(run, memberName, batch);
|
||||||
|
|
||||||
const message = [
|
const message = [
|
||||||
`Relay inbox messages to teammate "${memberName}".`,
|
`Relay inbox messages to teammate "${memberName}".`,
|
||||||
|
|
@ -3517,6 +3629,7 @@ export class TeamProvisioningService {
|
||||||
try {
|
try {
|
||||||
await this.sendMessageToTeam(teamName, message);
|
await this.sendMessageToTeam(teamName, message);
|
||||||
} catch {
|
} catch {
|
||||||
|
this.forgetPendingInboxRelayCandidates(run, memberName, rememberedRelayIds);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4213,6 +4326,16 @@ export class TeamProvisioningService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const relayOfMessageId =
|
||||||
|
recipient !== 'user'
|
||||||
|
? this.consumePendingInboxRelayCandidate(
|
||||||
|
run,
|
||||||
|
recipient,
|
||||||
|
strippedCrossTeamContent,
|
||||||
|
summary
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const msg: InboxMessage = {
|
const msg: InboxMessage = {
|
||||||
from: leadName,
|
from: leadName,
|
||||||
to: recipient,
|
to: recipient,
|
||||||
|
|
@ -4224,6 +4347,7 @@ export class TeamProvisioningService {
|
||||||
? (summary || strippedCrossTeamContent).slice(0, 57) + '...'
|
? (summary || strippedCrossTeamContent).slice(0, 57) + '...'
|
||||||
: summary || strippedCrossTeamContent,
|
: summary || strippedCrossTeamContent,
|
||||||
messageId: `lead-sendmsg-${run.runId}-${Date.now()}`,
|
messageId: `lead-sendmsg-${run.runId}-${Date.now()}`,
|
||||||
|
...(relayOfMessageId ? { relayOfMessageId } : {}),
|
||||||
source: 'lead_process',
|
source: 'lead_process',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -4521,7 +4645,7 @@ export class TeamProvisioningService {
|
||||||
// Capture SendMessage tool_use blocks from assistant output.
|
// Capture SendMessage tool_use blocks from assistant output.
|
||||||
// Works in both pre-ready and post-ready phases so outbound runtime messages
|
// Works in both pre-ready and post-ready phases so outbound runtime messages
|
||||||
// are visible in our team message artifacts even if Claude's own routing drifts.
|
// are visible in our team message artifacts even if Claude's own routing drifts.
|
||||||
if (!run.silentUserDmForward) {
|
if (!run.silentUserDmForward || run.silentUserDmForward.mode === 'member_inbox_relay') {
|
||||||
this.captureSendMessages(run, content ?? []);
|
this.captureSendMessages(run, content ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4683,6 +4807,7 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
// Clear silent relay flag after any successful turn.
|
// Clear silent relay flag after any successful turn.
|
||||||
run.activeCrossTeamReplyHints = [];
|
run.activeCrossTeamReplyHints = [];
|
||||||
|
run.pendingInboxRelayCandidates = [];
|
||||||
run.silentUserDmForward = null;
|
run.silentUserDmForward = null;
|
||||||
if (run.silentUserDmForwardClearHandle) {
|
if (run.silentUserDmForwardClearHandle) {
|
||||||
clearTimeout(run.silentUserDmForwardClearHandle);
|
clearTimeout(run.silentUserDmForwardClearHandle);
|
||||||
|
|
@ -4719,6 +4844,7 @@ export class TeamProvisioningService {
|
||||||
// Clear silent relay flag after any errored turn.
|
// Clear silent relay flag after any errored turn.
|
||||||
run.pendingDirectCrossTeamSendRefresh = false;
|
run.pendingDirectCrossTeamSendRefresh = false;
|
||||||
run.activeCrossTeamReplyHints = [];
|
run.activeCrossTeamReplyHints = [];
|
||||||
|
run.pendingInboxRelayCandidates = [];
|
||||||
run.silentUserDmForward = null;
|
run.silentUserDmForward = null;
|
||||||
if (run.silentUserDmForwardClearHandle) {
|
if (run.silentUserDmForwardClearHandle) {
|
||||||
clearTimeout(run.silentUserDmForwardClearHandle);
|
clearTimeout(run.silentUserDmForwardClearHandle);
|
||||||
|
|
@ -5463,6 +5589,7 @@ export class TeamProvisioningService {
|
||||||
this.pendingCrossTeamFirstReplies.delete(run.teamName);
|
this.pendingCrossTeamFirstReplies.delete(run.teamName);
|
||||||
this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName);
|
this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName);
|
||||||
run.activeCrossTeamReplyHints = [];
|
run.activeCrossTeamReplyHints = [];
|
||||||
|
run.pendingInboxRelayCandidates = [];
|
||||||
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
|
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
|
||||||
if (key.startsWith(`${run.teamName}:`)) {
|
if (key.startsWith(`${run.teamName}:`)) {
|
||||||
this.memberInboxRelayInFlight.delete(key);
|
this.memberInboxRelayInFlight.delete(key);
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ export class TeamSentMessagesStore {
|
||||||
taskRefs: Array.isArray(row.taskRefs) ? row.taskRefs : undefined,
|
taskRefs: Array.isArray(row.taskRefs) ? row.taskRefs : undefined,
|
||||||
summary: typeof row.summary === 'string' ? row.summary : undefined,
|
summary: typeof row.summary === 'string' ? row.summary : undefined,
|
||||||
messageId: row.messageId,
|
messageId: row.messageId,
|
||||||
|
relayOfMessageId:
|
||||||
|
typeof row.relayOfMessageId === 'string' ? row.relayOfMessageId : undefined,
|
||||||
color: typeof row.color === 'string' ? row.color : undefined,
|
color: typeof row.color === 'string' ? row.color : undefined,
|
||||||
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
|
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
|
||||||
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||||
|
|
||||||
import { ActivityItem } from '../activity/ActivityItem';
|
import { ActivityItem } from '../activity/ActivityItem';
|
||||||
|
|
||||||
import type { InboxMessage } from '@shared/types';
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
@ -15,7 +19,15 @@ export const MemberMessagesTab = ({
|
||||||
teamName,
|
teamName,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
}: MemberMessagesTabProps): React.JSX.Element => {
|
}: MemberMessagesTabProps): React.JSX.Element => {
|
||||||
const displayMessages = messages.slice(0, MAX_MESSAGES);
|
const displayMessages = useMemo(
|
||||||
|
() =>
|
||||||
|
filterTeamMessages(messages, {
|
||||||
|
timeWindow: null,
|
||||||
|
filter: { from: new Set(), to: new Set(), showNoise: true },
|
||||||
|
searchQuery: '',
|
||||||
|
}).slice(0, MAX_MESSAGES),
|
||||||
|
[messages]
|
||||||
|
);
|
||||||
|
|
||||||
if (displayMessages.length === 0) {
|
if (displayMessages.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ export function areInboxMessagesEquivalentForRender(
|
||||||
if (prev.summary !== next.summary) return false;
|
if (prev.summary !== next.summary) return false;
|
||||||
if (prev.color !== next.color) return false;
|
if (prev.color !== next.color) return false;
|
||||||
if (prev.read !== next.read) return false;
|
if (prev.read !== next.read) return false;
|
||||||
|
if (prev.relayOfMessageId !== next.relayOfMessageId) return false;
|
||||||
if (prev.source !== next.source) return false;
|
if (prev.source !== next.source) return false;
|
||||||
if (prev.leadSessionId !== next.leadSessionId) return false;
|
if (prev.leadSessionId !== next.leadSessionId) return false;
|
||||||
if (prev.toolSummary !== next.toolSummary) return false;
|
if (prev.toolSummary !== next.toolSummary) return false;
|
||||||
|
|
|
||||||
|
|
@ -56,5 +56,22 @@ export function filterTeamMessages(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return list;
|
const visibleMessageIds = new Set(
|
||||||
|
list
|
||||||
|
.map((m) => (typeof m.messageId === 'string' ? m.messageId.trim() : ''))
|
||||||
|
.filter((id) => id.length > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return list.filter((m) => {
|
||||||
|
const relayOfMessageId =
|
||||||
|
typeof m.relayOfMessageId === 'string' ? m.relayOfMessageId.trim() : '';
|
||||||
|
if (!relayOfMessageId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const ownMessageId = typeof m.messageId === 'string' ? m.messageId.trim() : '';
|
||||||
|
if (relayOfMessageId === ownMessageId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !visibleMessageIds.has(relayOfMessageId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,8 @@ export interface InboxMessage {
|
||||||
summary?: string;
|
summary?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
|
/** Original inbox messageId when this row is only a relay/delivery bridge copy. */
|
||||||
|
relayOfMessageId?: string;
|
||||||
source?:
|
source?:
|
||||||
| 'inbox'
|
| 'inbox'
|
||||||
| 'lead_session'
|
| 'lead_session'
|
||||||
|
|
@ -290,6 +292,7 @@ export interface SendMessageRequest {
|
||||||
from?: string;
|
from?: string;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
|
relayOfMessageId?: string;
|
||||||
/** Override the `to` field in the stored message (defaults to `member`). */
|
/** Override the `to` field in the stored message (defaults to `member`). */
|
||||||
to?: string;
|
to?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ describe('TeamInboxWriter', () => {
|
||||||
text: 'Hello cross-team',
|
text: 'Hello cross-team',
|
||||||
summary: 'Cross-team response',
|
summary: 'Cross-team response',
|
||||||
messageId: 'lead-sendmsg-run-1-123',
|
messageId: 'lead-sendmsg-run-1-123',
|
||||||
|
relayOfMessageId: 'msg-original-1',
|
||||||
timestamp: '2026-03-10T00:33:55.000Z',
|
timestamp: '2026-03-10T00:33:55.000Z',
|
||||||
source: 'lead_process',
|
source: 'lead_process',
|
||||||
color: 'purple',
|
color: 'purple',
|
||||||
|
|
@ -180,6 +181,7 @@ describe('TeamInboxWriter', () => {
|
||||||
text: 'Hello cross-team',
|
text: 'Hello cross-team',
|
||||||
summary: 'Cross-team response',
|
summary: 'Cross-team response',
|
||||||
messageId: 'lead-sendmsg-run-1-123',
|
messageId: 'lead-sendmsg-run-1-123',
|
||||||
|
relayOfMessageId: 'msg-original-1',
|
||||||
timestamp: '2026-03-10T00:33:55.000Z',
|
timestamp: '2026-03-10T00:33:55.000Z',
|
||||||
source: 'lead_process',
|
source: 'lead_process',
|
||||||
color: 'purple',
|
color: 'purple',
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,9 @@ interface RunLike {
|
||||||
pendingDirectCrossTeamSendRefresh: boolean;
|
pendingDirectCrossTeamSendRefresh: boolean;
|
||||||
lastLeadTextEmitMs: number;
|
lastLeadTextEmitMs: number;
|
||||||
leadRelayCapture: null;
|
leadRelayCapture: null;
|
||||||
silentUserDmForward: null;
|
silentUserDmForward:
|
||||||
|
| null
|
||||||
|
| { target: string; startedAt: string; mode: 'user_dm' | 'member_inbox_relay' };
|
||||||
suppressPostCompactReminderOutput?: boolean;
|
suppressPostCompactReminderOutput?: boolean;
|
||||||
child: Record<string, unknown> | null;
|
child: Record<string, unknown> | null;
|
||||||
processKilled: boolean;
|
processKilled: boolean;
|
||||||
|
|
@ -139,6 +141,7 @@ interface RunLike {
|
||||||
provisioningOutputParts: string[];
|
provisioningOutputParts: string[];
|
||||||
request: { members: { name: string; role?: string }[] };
|
request: { members: { name: string; role?: string }[] };
|
||||||
activeCrossTeamReplyHints?: Array<{ toTeam: string; conversationId: string }>;
|
activeCrossTeamReplyHints?: Array<{ toTeam: string; conversationId: string }>;
|
||||||
|
pendingInboxRelayCandidates?: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -161,6 +164,7 @@ function attachRun(
|
||||||
lastLeadTextEmitMs: 0,
|
lastLeadTextEmitMs: 0,
|
||||||
leadRelayCapture: null,
|
leadRelayCapture: null,
|
||||||
silentUserDmForward: null,
|
silentUserDmForward: null,
|
||||||
|
pendingInboxRelayCandidates: [],
|
||||||
child: { stdin: { writable: true, write: vi.fn(), end: vi.fn() } },
|
child: { stdin: { writable: true, write: vi.fn(), end: vi.fn() } },
|
||||||
processKilled: false,
|
processKilled: false,
|
||||||
cancelRequested: false,
|
cancelRequested: false,
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,11 @@ describe('TeamProvisioningService post-compact lifecycle', () => {
|
||||||
it('injectPostCompactReminder defers when silentUserDmForward is active', async () => {
|
it('injectPostCompactReminder defers when silentUserDmForward is active', async () => {
|
||||||
const { svc, run, runId } = await setupRunningTeam('compact-test-6');
|
const { svc, run, runId } = await setupRunningTeam('compact-test-6');
|
||||||
run.pendingPostCompactReminder = true;
|
run.pendingPostCompactReminder = true;
|
||||||
run.silentUserDmForward = { target: 'alice', startedAt: new Date().toISOString() };
|
run.silentUserDmForward = {
|
||||||
|
target: 'alice',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
mode: 'user_dm',
|
||||||
|
};
|
||||||
|
|
||||||
await (svc as any).injectPostCompactReminder(run);
|
await (svc as any).injectPostCompactReminder(run);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,18 @@ function attachAliveRun(
|
||||||
(service as unknown as { runs: Map<string, unknown> }).runs.set(runId, {
|
(service as unknown as { runs: Map<string, unknown> }).runs.set(runId, {
|
||||||
runId,
|
runId,
|
||||||
teamName,
|
teamName,
|
||||||
|
request: {
|
||||||
|
teamName,
|
||||||
|
members: [{ name: 'team-lead', role: 'team-lead' }],
|
||||||
|
},
|
||||||
|
leadMsgSeq: 0,
|
||||||
|
pendingToolCalls: [],
|
||||||
|
pendingDirectCrossTeamSendRefresh: false,
|
||||||
|
lastLeadTextEmitMs: 0,
|
||||||
|
activeCrossTeamReplyHints: [],
|
||||||
|
pendingInboxRelayCandidates: [],
|
||||||
|
silentUserDmForward: null,
|
||||||
|
silentUserDmForwardClearHandle: null,
|
||||||
child: {
|
child: {
|
||||||
stdin: {
|
stdin: {
|
||||||
writable,
|
writable,
|
||||||
|
|
@ -198,6 +210,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
hoisted.readFile.mockClear();
|
hoisted.readFile.mockClear();
|
||||||
hoisted.atomicWrite.mockClear();
|
hoisted.atomicWrite.mockClear();
|
||||||
hoisted.appendSentMessage.mockClear();
|
hoisted.appendSentMessage.mockClear();
|
||||||
|
hoisted.sendInboxMessage.mockClear();
|
||||||
hoisted.setAtomicWriteShouldFail(false);
|
hoisted.setAtomicWriteShouldFail(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -610,6 +623,94 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
expect(payload).toContain('Please retry with logging enabled.');
|
expect(payload).toContain('Please retry with logging enabled.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks exact teammate relay copies with relayOfMessageId', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
seedConfig(teamName);
|
||||||
|
seedMemberInbox(teamName, 'alice', [
|
||||||
|
{
|
||||||
|
from: 'team-lead',
|
||||||
|
text:
|
||||||
|
`**Comment on task #abcd1234**\n> Investigate\n\n> Please retry with logging enabled.\n\n` +
|
||||||
|
'<agent-block>\nReply using task_add_comment\n</agent-block>',
|
||||||
|
timestamp: '2026-02-23T10:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
summary: 'Comment on #abcd1234',
|
||||||
|
messageId: 'm-alice-1',
|
||||||
|
source: 'system_notification',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
attachAliveRun(service, teamName);
|
||||||
|
const relayed = await service.relayMemberInboxMessages(teamName, 'alice');
|
||||||
|
expect(relayed).toBe(1);
|
||||||
|
|
||||||
|
const run = (service as unknown as { runs: Map<string, unknown> }).runs.get('run-1') as unknown;
|
||||||
|
expect(run).toBeTruthy();
|
||||||
|
|
||||||
|
(service as any).handleStreamJsonMessage(run, {
|
||||||
|
type: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
name: 'SendMessage',
|
||||||
|
input: {
|
||||||
|
recipient: 'alice',
|
||||||
|
summary: 'Comment on #abcd1234',
|
||||||
|
content:
|
||||||
|
`**Comment on task #abcd1234**\n> Investigate\n\n> Please retry with logging enabled.\n\n` +
|
||||||
|
'<agent-block>\nHidden internal instructions\n</agent-block>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const inbox = JSON.parse(
|
||||||
|
hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]'
|
||||||
|
) as Array<{ messageId?: string; relayOfMessageId?: string; source?: string }>;
|
||||||
|
const relayedCopy = inbox.find((row) => row.messageId?.startsWith('lead-sendmsg-run-1-'));
|
||||||
|
expect(relayedCopy).toMatchObject({
|
||||||
|
source: 'lead_process',
|
||||||
|
relayOfMessageId: 'm-alice-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not capture user-dm silent forwards as extra lead_process messages', () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
seedConfig(teamName);
|
||||||
|
attachAliveRun(service, teamName);
|
||||||
|
|
||||||
|
const run = (service as unknown as { runs: Map<string, unknown> }).runs.get('run-1') as {
|
||||||
|
silentUserDmForward: { target: string; startedAt: string; mode: 'user_dm' | 'member_inbox_relay' } | null;
|
||||||
|
};
|
||||||
|
run.silentUserDmForward = {
|
||||||
|
target: 'alice',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
mode: 'user_dm',
|
||||||
|
};
|
||||||
|
|
||||||
|
(service as any).handleStreamJsonMessage(run, {
|
||||||
|
type: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
name: 'SendMessage',
|
||||||
|
input: {
|
||||||
|
recipient: 'alice',
|
||||||
|
summary: 'Forwarded DM',
|
||||||
|
content: 'User DM payload',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const inbox = JSON.parse(
|
||||||
|
hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]'
|
||||||
|
) as Array<{ messageId?: string; source?: string }>;
|
||||||
|
expect(inbox).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not relay pseudo cross-team member inboxes as teammates', async () => {
|
it('does not relay pseudo cross-team member inboxes as teammates', async () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,54 @@ describe('filterTeamMessages', () => {
|
||||||
expect(result[0].source).toBe('lead_process');
|
expect(result[0].source).toBe('lead_process');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides relay bridge copies when the original message is visible', () => {
|
||||||
|
const messages = [
|
||||||
|
makeMessage({
|
||||||
|
messageId: 'orig-1',
|
||||||
|
to: 'alice',
|
||||||
|
source: 'system_notification',
|
||||||
|
text: 'Original inbox notification',
|
||||||
|
}),
|
||||||
|
makeMessage({
|
||||||
|
messageId: 'relay-1',
|
||||||
|
to: 'alice',
|
||||||
|
source: 'lead_process',
|
||||||
|
text: 'Original inbox notification',
|
||||||
|
relayOfMessageId: 'orig-1',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = filterTeamMessages(messages, {
|
||||||
|
timeWindow: null,
|
||||||
|
filter: { from: new Set(), to: new Set(), showNoise: true },
|
||||||
|
searchQuery: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].messageId).toBe('orig-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps relay bridge copies when the original message is not visible', () => {
|
||||||
|
const messages = [
|
||||||
|
makeMessage({
|
||||||
|
messageId: 'relay-1',
|
||||||
|
to: 'alice',
|
||||||
|
source: 'lead_process',
|
||||||
|
text: 'Original inbox notification',
|
||||||
|
relayOfMessageId: 'orig-1',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = filterTeamMessages(messages, {
|
||||||
|
timeWindow: null,
|
||||||
|
filter: { from: new Set(), to: new Set(), showNoise: true },
|
||||||
|
searchQuery: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].messageId).toBe('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