diff --git a/agent-teams-controller/src/internal/crossTeam.js b/agent-teams-controller/src/internal/crossTeam.js index 55dd4c9e..34216c97 100644 --- a/agent-teams-controller/src/internal/crossTeam.js +++ b/agent-teams-controller/src/internal/crossTeam.js @@ -137,6 +137,12 @@ function sendCrossTeamMessage(context, flags) { const fromTeam = context.teamName; const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : ''; const fromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead'; + const replyToConversationId = + typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : ''; + const conversationId = + typeof flags.conversationId === 'string' && flags.conversationId.trim() + ? flags.conversationId.trim() + : replyToConversationId || ''; const text = typeof flags.text === 'string' ? flags.text : ''; const summary = typeof flags.summary === 'string' ? flags.summary.trim() : undefined; const chainDepth = typeof flags.chainDepth === 'number' ? flags.chainDepth : 0; @@ -167,7 +173,12 @@ function sendCrossTeamMessage(context, flags) { // Format const from = `${fromTeam}.${fromMember}`; - const formattedText = formatCrossTeamText(from, chainDepth, text); + const resolvedConversationId = + conversationId || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`); + const formattedText = formatCrossTeamText(from, chainDepth, text, { + conversationId: resolvedConversationId, + replyToConversationId: replyToConversationId || undefined, + }); const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary); @@ -200,6 +211,8 @@ function sendCrossTeamMessage(context, flags) { summary: summary || `Cross-team message from ${fromTeam}`, messageId, source: CROSS_TEAM_SOURCE, + conversationId: resolvedConversationId, + replyToConversationId: replyToConversationId || undefined, }); writeJson(inboxPath, list); }); @@ -216,6 +229,8 @@ function sendCrossTeamMessage(context, flags) { fromTeam, fromMember, toTeam, + conversationId: resolvedConversationId, + replyToConversationId: replyToConversationId || undefined, text, summary, chainDepth, diff --git a/agent-teams-controller/src/internal/crossTeamProtocol.js b/agent-teams-controller/src/internal/crossTeamProtocol.js index b98d7132..7dfa9460 100644 --- a/agent-teams-controller/src/internal/crossTeamProtocol.js +++ b/agent-teams-controller/src/internal/crossTeamProtocol.js @@ -5,12 +5,19 @@ const CROSS_TEAM_PREFIX_TAG = 'Cross-team from'; const CROSS_TEAM_SOURCE = 'cross_team'; const CROSS_TEAM_SENT_SOURCE = 'cross_team_sent'; -function formatCrossTeamPrefix(from, chainDepth) { - return `[${CROSS_TEAM_PREFIX_TAG} ${from} | depth:${chainDepth}]`; +function formatCrossTeamPrefix(from, chainDepth, meta) { + const parts = [`${CROSS_TEAM_PREFIX_TAG} ${from}`, `depth:${chainDepth}`]; + if (meta && meta.conversationId) { + parts.push(`conversation:${meta.conversationId}`); + } + if (meta && meta.replyToConversationId) { + parts.push(`replyTo:${meta.replyToConversationId}`); + } + return `[${parts.join(' | ')}]`; } -function formatCrossTeamText(from, chainDepth, text) { - return `${formatCrossTeamPrefix(from, chainDepth)}\n${text}`; +function formatCrossTeamText(from, chainDepth, text, meta) { + return `${formatCrossTeamPrefix(from, chainDepth, meta)}\n${text}`; } module.exports = { diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js index 636e1bef..5bc59bfb 100644 --- a/agent-teams-controller/src/internal/messageStore.js +++ b/agent-teams-controller/src/internal/messageStore.js @@ -76,6 +76,12 @@ function buildMessage(flags, defaults) { ...(typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim() ? { leadSessionId: flags.leadSessionId.trim() } : {}), + ...(typeof flags.conversationId === 'string' && flags.conversationId.trim() + ? { conversationId: flags.conversationId.trim() } + : {}), + ...(typeof flags.replyToConversationId === 'string' && flags.replyToConversationId.trim() + ? { replyToConversationId: flags.replyToConversationId.trim() } + : {}), ...(typeof flags.color === 'string' && flags.color.trim() ? { color: flags.color.trim() } : {}), ...(typeof flags.toolSummary === 'string' && flags.toolSummary.trim() ? { toolSummary: flags.toolSummary.trim() } diff --git a/agent-teams-controller/test/crossTeam.test.js b/agent-teams-controller/test/crossTeam.test.js index 71fb7959..729bfd55 100644 --- a/agent-teams-controller/test/crossTeam.test.js +++ b/agent-teams-controller/test/crossTeam.test.js @@ -60,7 +60,9 @@ describe('crossTeam module', () => { expect(inbox).toHaveLength(1); expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE); expect(inbox[0].from).toBe('team-a.lead'); - expect(inbox[0].text).toContain(`[${CROSS_TEAM_PREFIX_TAG} team-a.lead | depth:0]`); + expect(inbox[0].text).toContain(`[${CROSS_TEAM_PREFIX_TAG} team-a.lead | depth:0`); + expect(inbox[0].conversationId).toBeTruthy(); + expect(inbox[0].text).toContain(`conversation:${inbox[0].conversationId}`); }); it('records outbox entry', () => { @@ -84,6 +86,34 @@ describe('crossTeam module', () => { const outbox = controller.crossTeam.getCrossTeamOutbox(); expect(outbox).toHaveLength(1); expect(outbox[0].toTeam).toBe('team-b'); + expect(outbox[0].conversationId).toBeTruthy(); + }); + + it('preserves reply conversation metadata for explicit replies', () => { + const claudeDir = makeClaudeDir({ + 'team-a': { + name: 'team-a', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + 'team-b': { + name: 'team-b', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }); + + const controller = createController({ teamName: 'team-a', claudeDir }); + controller.crossTeam.sendCrossTeamMessage({ + toTeam: 'team-b', + text: 'Answering the open question', + replyToConversationId: 'conv-123', + }); + + const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'); + const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); + expect(inbox[0].conversationId).toBe('conv-123'); + expect(inbox[0].replyToConversationId).toBe('conv-123'); + expect(inbox[0].text).toContain('conversation:conv-123'); + expect(inbox[0].text).toContain('replyTo:conv-123'); }); it('deduplicates the same recent cross-team request', () => { diff --git a/src/main/ipc/crossTeam.ts b/src/main/ipc/crossTeam.ts index 2f109358..bf520fda 100644 --- a/src/main/ipc/crossTeam.ts +++ b/src/main/ipc/crossTeam.ts @@ -52,6 +52,9 @@ async function handleSend( fromTeam: String(req.fromTeam ?? ''), fromMember: String(req.fromMember ?? ''), toTeam: String(req.toTeam ?? ''), + conversationId: typeof req.conversationId === 'string' ? req.conversationId : undefined, + replyToConversationId: + typeof req.replyToConversationId === 'string' ? req.replyToConversationId : undefined, text: String(req.text ?? ''), summary: typeof req.summary === 'string' ? req.summary : undefined, chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined, diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index 95c07f1b..bd7838ba 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -43,6 +43,19 @@ export class CrossTeamService { async send(request: CrossTeamSendRequest): Promise { const { fromTeam, fromMember, toTeam, text, summary } = request; const chainDepth = request.chainDepth ?? 0; + const inferredReplyMeta = + !request.conversationId && !request.replyToConversationId + ? (this.provisioning?.resolveCrossTeamReplyMetadata(fromTeam, toTeam) ?? null) + : null; + const replyToConversationId = + request.replyToConversationId?.trim() || + inferredReplyMeta?.replyToConversationId || + undefined; + const conversationId = + request.conversationId?.trim() || + inferredReplyMeta?.conversationId || + replyToConversationId || + randomUUID(); // 1. Validate if (!TEAM_NAME_PATTERN.test(fromTeam)) { @@ -71,13 +84,18 @@ export class CrossTeamService { // 3. Format const from = `${fromTeam}.${fromMember}`; - const formattedText = formatCrossTeamText(from, chainDepth, text); + const formattedText = formatCrossTeamText(from, chainDepth, text, { + conversationId, + replyToConversationId, + }); const messageId = randomUUID(); const outboxMessage: CrossTeamMessage = { messageId, fromTeam, fromMember, toTeam, + conversationId, + replyToConversationId, text, summary, chainDepth, @@ -96,6 +114,8 @@ export class CrossTeamService { from, summary: summary ?? `Cross-team message from ${fromTeam}`, source: CROSS_TEAM_SOURCE, + conversationId, + replyToConversationId, }); }); @@ -113,6 +133,8 @@ export class CrossTeamService { to: `${toTeam}.${leadName}`, summary: summary ?? `Cross-team message to ${toTeam}`, source: CROSS_TEAM_SENT_SOURCE, + conversationId, + replyToConversationId, }) .catch((e: unknown) => { logger.warn( diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index 3e1473f8..b28216e5 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -103,6 +103,9 @@ export class TeamInboxReader { messageId: row.messageId, source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined, leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined, + conversationId: typeof row.conversationId === 'string' ? row.conversationId : undefined, + replyToConversationId: + typeof row.replyToConversationId === 'string' ? row.replyToConversationId : undefined, attachments: Array.isArray(row.attachments) ? row.attachments : undefined, toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined, toolCalls: Array.isArray(row.toolCalls) diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index c8e55f07..d648dc6a 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -32,6 +32,10 @@ export class TeamInboxWriter { attachments: attachmentMeta?.length ? attachmentMeta : undefined, ...(request.source && { source: request.source }), ...(request.leadSessionId && { leadSessionId: request.leadSessionId }), + ...(request.conversationId && { conversationId: request.conversationId }), + ...(request.replyToConversationId && { + replyToConversationId: request.replyToConversationId, + }), }; await withFileLock(inboxPath, async () => { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 8965218a..68347932 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -19,7 +19,11 @@ import { AGENT_BLOCK_OPEN, stripAgentBlocks, } from '@shared/constants/agentBlocks'; -import { CROSS_TEAM_PREFIX_TAG } from '@shared/constants/crossTeam'; +import { + CROSS_TEAM_PREFIX_TAG, + CROSS_TEAM_SENT_SOURCE, + parseCrossTeamPrefix, +} from '@shared/constants/crossTeam'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; @@ -176,6 +180,10 @@ interface ProvisioningRun { rejectOnce: (error: string) => void; timeoutHandle: NodeJS.Timeout; } | null; + activeCrossTeamReplyHints: Array<{ + toTeam: string; + conversationId: string; + }>; /** Monotonic counter for individual lead assistant messages. */ leadMsgSeq: number; /** Accumulated tool_use details between text messages. */ @@ -575,6 +583,8 @@ Communication protocol (CRITICAL — you are running headless, no one sees your - Keep cross-team requests high-signal: one focused request per topic, with clear next action and desired outcome. - Before sending a follow-up on the same topic, check "cross_team_get_outbox" so you do not resend the same request unnecessarily. - If you receive a message that is clearly from another team (for example prefixed with "[${CROSS_TEAM_PREFIX_TAG} ...]"), treat it as an actionable cross-team request and respond to the originating team with "cross_team_send" when a reply, decision, or status update is needed. +- Cross-team requests may include a stable conversationId in their metadata. When you reply to that thread, preserve the same conversationId and pass replyToConversationId with that same value so the system can correlate the reply reliably. +- If the relay prompt shows explicit cross-team reply metadata/instructions for a message, follow that metadata exactly when calling "cross_team_send". - When a cross-team request arrives, do NOT appear silent: first emit a brief plain-text status update visible in your own team's Messages/Activity (for example: "Accepted cross-team request from @other-team. Investigating and delegating now."), then do the research, task creation, or delegation work. - For cross-team work, your canonical progress trail should be team-visible first. Use plain text updates, task comments, and task state changes so your own team can see what is happening. - Do not wait silently on another team: if cross-team coordination is blocking progress, send the request promptly, then continue any useful local work that does not depend on that answer. @@ -1847,6 +1857,7 @@ export class TeamProvisioningService { isLaunch: false, fsPhase: 'waiting_config', leadRelayCapture: null, + activeCrossTeamReplyHints: [], leadMsgSeq: 0, pendingToolCalls: [], lastLeadTextEmitMs: 0, @@ -2169,6 +2180,7 @@ export class TeamProvisioningService { isLaunch: true, fsPhase: 'waiting_members', leadRelayCapture: null, + activeCrossTeamReplyHints: [], leadMsgSeq: 0, pendingToolCalls: [], lastLeadTextEmitMs: 0, @@ -2556,10 +2568,26 @@ export class TeamProvisioningService { `Messages:`, ...batch.flatMap((m, idx) => { const summaryLine = m.summary?.trim() ? `Summary: ${m.summary.trim()}` : null; + const crossTeamMeta = + m.source === 'cross_team' + ? { + origin: parseCrossTeamPrefix(m.text), + sourceTeam: m.from.includes('.') ? m.from.split('.', 1)[0] : null, + } + : null; + const conversationId = m.conversationId ?? crossTeamMeta?.origin?.conversationId; + const replyInstructions = + crossTeamMeta?.sourceTeam && conversationId + ? [ + ` Cross-team conversationId: ${conversationId}`, + ` If replying with cross_team_send to ${crossTeamMeta.sourceTeam}, set conversationId="${conversationId}" and replyToConversationId="${conversationId}".`, + ] + : []; return [ `${idx + 1}) From: ${m.from || 'unknown'}`, ` Timestamp: ${m.timestamp}`, ...(summaryLine ? [` ${summaryLine}`] : []), + ...replyInstructions, ` Text:`, ...m.text.split('\n').map((line) => ` ${line}`), ``, @@ -2657,22 +2685,33 @@ export class TeamProvisioningService { if (unread.length === 0) return 0; // Ignore (and auto-mark read) internal coordination noise like idle/shutdown messages. - // These frequently appear when teammates are idle/available and should not prompt - // the lead to respond with "No action needed." - const noiseUnread = unread.filter((m) => isInboxNoiseMessage(m.text)); - if (noiseUnread.length > 0) { + // Also ignore local sender-copy rows for cross-team traffic: those exist only so the UI + // can show outbound activity and must not be re-injected into the live lead as new work. + const ignoredUnread = unread.filter( + (m) => isInboxNoiseMessage(m.text) || m.source === CROSS_TEAM_SENT_SOURCE + ); + if (ignoredUnread.length > 0) { try { - await this.markInboxMessagesRead(teamName, leadName, noiseUnread); + await this.markInboxMessagesRead(teamName, leadName, ignoredUnread); } catch { // best-effort } } - const actionableUnread = unread.filter((m) => !isInboxNoiseMessage(m.text)); + const actionableUnread = unread.filter( + (m) => !isInboxNoiseMessage(m.text) && m.source !== CROSS_TEAM_SENT_SOURCE + ); if (actionableUnread.length === 0) return 0; const MAX_RELAY = 10; const batch = actionableUnread.slice(0, MAX_RELAY); + run.activeCrossTeamReplyHints = batch.flatMap((m) => { + if (m.source !== 'cross_team') return []; + const sourceTeam = m.from.includes('.') ? m.from.split('.', 1)[0] : ''; + const conversationId = m.conversationId ?? parseCrossTeamPrefix(m.text)?.conversationId; + if (!sourceTeam || !conversationId) return []; + return [{ toTeam: sourceTeam, conversationId }]; + }); const message = [ `You have new inbox messages addressed to you (team lead "${leadName}").`, @@ -3048,6 +3087,24 @@ export class TeamProvisioningService { this.liveLeadProcessMessages.set(teamName, list); } + resolveCrossTeamReplyMetadata( + teamName: string, + toTeam: string + ): { conversationId: string; replyToConversationId: string } | null { + const runId = this.activeByTeam.get(teamName); + if (!runId) return null; + const run = this.runs.get(runId); + if (!run || run.activeCrossTeamReplyHints.length === 0) return null; + + const matches = run.activeCrossTeamReplyHints.filter((hint) => hint.toTeam === toTeam); + if (matches.length !== 1) return null; + + return { + conversationId: matches[0].conversationId, + replyToConversationId: matches[0].conversationId, + }; + } + /** * Create an InboxMessage from assistant text and push it into the live cache. * Used for both pre-ready (provisioning) and post-ready assistant text. @@ -3370,6 +3427,7 @@ export class TeamProvisioningService { capture.resolveOnce(combined); } // Clear silent relay flag after any successful turn. + run.activeCrossTeamReplyHints = []; run.silentUserDmForward = null; if (run.silentUserDmForwardClearHandle) { clearTimeout(run.silentUserDmForwardClearHandle); @@ -3398,6 +3456,7 @@ export class TeamProvisioningService { run.leadRelayCapture.rejectOnce(errorMsg); } // Clear silent relay flag after any errored turn. + run.activeCrossTeamReplyHints = []; run.silentUserDmForward = null; if (run.silentUserDmForwardClearHandle) { clearTimeout(run.silentUserDmForwardClearHandle); @@ -4129,6 +4188,7 @@ export class TeamProvisioningService { this.activeByTeam.delete(run.teamName); this.leadInboxRelayInFlight.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName); + run.activeCrossTeamReplyHints = []; for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { if (key.startsWith(`${run.teamName}:`)) { this.memberInboxRelayInFlight.delete(key); diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts index b225f903..10dece31 100644 --- a/src/main/services/team/TeamSentMessagesStore.ts +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -78,6 +78,9 @@ export class TeamSentMessagesStore { attachments: Array.isArray(row.attachments) ? row.attachments : undefined, source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined, leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined, + conversationId: typeof row.conversationId === 'string' ? row.conversationId : undefined, + replyToConversationId: + typeof row.replyToConversationId === 'string' ? row.replyToConversationId : undefined, toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined, toolCalls: Array.isArray(row.toolCalls) ? (row.toolCalls as unknown[]) diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index f8010eaf..770d1a93 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -23,6 +23,7 @@ import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath'; +import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; @@ -640,6 +641,32 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); + const pendingCrossTeamReplies = useMemo( + () => computePendingCrossTeamReplies(data?.messages ?? []), + [data?.messages] + ); + + /** Whether the Status block has any visible items (pending replies or active tasks). */ + const hasStatusItems = useMemo(() => { + const members = data?.members ?? []; + const tasks = data?.tasks ?? []; + + // Check pending replies (mirrors PendingRepliesBlock logic) + const hasPendingReplies = Object.keys(pendingRepliesByMember).some((name) => + members.some((m) => m.name === name) + ); + if (hasPendingReplies) return true; + if (pendingCrossTeamReplies.length > 0) return true; + + // Check active tasks (mirrors ActiveTasksBlock logic) + const tMap = new Map(tasks.map((t) => [t.id, t])); + return members.some((m) => { + if (!m.currentTaskId) return false; + const task = tMap.get(m.currentTaskId); + if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false; + return true; + }); + }, [data?.members, data?.tasks, pendingRepliesByMember, pendingCrossTeamReplies.length]); useEffect(() => { if (!data || Object.keys(pendingRepliesByMember).length === 0) return; @@ -1559,35 +1586,42 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele }); }} /> -
- - {!statusBlockCollapsed && ( - <> - - - - )} -
+ {/* Status block: button floats right (absolute, no layout impact); + expanded content renders full-width in normal flow. */} + {hasStatusItems && ( + <> +
+ +
+ {!statusBlockCollapsed && ( +
+ + +
+ )} + + )} 0 ? calls : undefined; }, [thoughts]); + // Extract text preview for header: use newest thought's text, fallback through group + const headerTextPreview = useMemo(() => { + // Try newest first (most relevant), then scan for any text + for (const t of thoughts) { + if (t.text && t.text.trim()) { + const plain = extractMarkdownPlainText(t.text); + const firstLine = plain.split('\n').find((l) => l.trim().length > 0) ?? ''; + return firstLine.trim(); + } + } + return null; + }, [thoughts]); + // Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought) const computeIsLive = useCallback( () => @@ -716,7 +730,26 @@ export const LeadThoughtsGroupRow = ({ ? formatTime(oldest.timestamp) : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`} - {totalToolSummary && ( + {!isBodyVisible && headerTextPreview ? ( + + + + {headerTextPreview} + + + {totalToolSummary ? ( + + + + ) : null} + + ) : totalToolSummary ? ( @@ -730,23 +763,9 @@ export const LeadThoughtsGroupRow = ({ /> - )} + ) : null} - {/* Last thought preview when body is collapsed */} - {!isBodyVisible && newest.text && ( -
- {newest.text.slice(0, 200)} -
- )} - {/* Scrollable body — live thoughts follow bottom unless user scrolls up */} {isBodyVisible ? (
; + pendingCrossTeamReplies?: PendingCrossTeamReply[]; onMemberClick?: (member: ResolvedTeamMember) => void; } export const PendingRepliesBlock = ({ members, pendingRepliesByMember, + pendingCrossTeamReplies = [], onMemberClick, }: PendingRepliesBlockProps): React.JSX.Element | null => { const { isLight } = useTheme(); const colorMap = buildMemberColorMap(members); - const pending = Object.entries(pendingRepliesByMember) + const memberPending = Object.entries(pendingRepliesByMember) .map(([name, sentAtMs]) => ({ + kind: 'member' as const, member: members.find((m) => m.name === name) ?? null, name, sentAtMs, })) - .filter((p): p is { member: ResolvedTeamMember; name: string; sentAtMs: number } => !!p.member) - .sort((a, b) => b.sentAtMs - a.sentAtMs); + .filter( + (p): p is { kind: 'member'; member: ResolvedTeamMember; name: string; sentAtMs: number } => + !!p.member + ); + const teamPending = pendingCrossTeamReplies.map((entry) => ({ + kind: 'team' as const, + teamName: entry.teamName, + sentAtMs: entry.sentAtMs, + })); + const pending = [...memberPending, ...teamPending].sort((a, b) => b.sentAtMs - a.sentAtMs); if (pending.length === 0) return null; @@ -36,16 +54,89 @@ export const PendingRepliesBlock = ({

Awaiting replies

- {pending.map(({ member, sentAtMs }) => { - const colors = getTeamColorSet(colorMap.get(member.name) ?? ''); - const roleLabel = formatAgentRole( - member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined) - ); - const since = formatDistanceToNowStrict(sentAtMs, { addSuffix: true }); + {pending.map((entry) => { + const since = formatDistanceToNowStrict(entry.sentAtMs, { addSuffix: true }); + if (entry.kind === 'member') { + const { member } = entry; + const colors = getTeamColorSet(colorMap.get(member.name) ?? ''); + const roleLabel = formatAgentRole( + member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined) + ); + + return ( +
+
+ + + + + + + + {onMemberClick ? ( + + ) : ( + + {member.name} + + )} + {roleLabel ? ( + + {roleLabel} + + ) : null} + + awaiting reply + + + {since} + +
+
+ ); + } + + const colors = nameColorSet(entry.teamName, isLight); return (
- - + + - {onMemberClick ? ( - - ) : ( - - {member.name} - - )} - {roleLabel ? ( - - {roleLabel} - - ) : null} + + {entry.teamName} + + + external team + awaiting reply diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx index 61664849..2a4907fb 100644 --- a/src/renderer/components/team/review/CodeMirrorDiffView.tsx +++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx @@ -90,11 +90,11 @@ const diffSpecificTheme = EditorView.theme({ }, '.cm-insertedLine': { backgroundColor: 'var(--diff-cm-changed-bg) !important' }, '.cm-deletedLine': { backgroundColor: 'var(--diff-cm-deleted-bg) !important' }, - // Merge toolbar — absolute, Y set dynamically by mousemove handler + // Merge toolbar — absolute, Y and right set dynamically by mousemove handler '.cm-deletedChunk .cm-chunkButtons': { position: 'absolute', top: '0', - insetInlineEnd: '8px', + right: '8px', zIndex: 10, display: 'flex', justifyContent: 'flex-end', @@ -467,6 +467,17 @@ export const CodeMirrorDiffView = ({ // Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk if (showMergeControls) { + // Helper: pin chunkButtons to right edge of visible viewport, accounting for horizontal scroll + const pinToViewportRight = ( + btnContainer: HTMLElement, + parentRect: DOMRect, + scroller: Element + ): void => { + const scrollLeft = scroller.scrollLeft; + // When scrolled right, shift the button left so it stays visible + btnContainer.style.right = `${-scrollLeft + 8}px`; + }; + // Helper: position a chunkButtons container so it's below the change block, // but clamped to the visible viewport if that would be off-screen. const positionAtBottom = (chunkEl: Element, scroller: Element): void => { @@ -482,6 +493,7 @@ export const CodeMirrorDiffView = ({ targetY = scrollerRect.bottom - tbHeight; } btnContainer.style.top = `${targetY - parentRect.top}px`; + pinToViewportRight(btnContainer, parentRect, scroller); }; const positionAtCursor = (chunkEl: Element, clientY: number, scroller: Element): void => { @@ -499,6 +511,7 @@ export const CodeMirrorDiffView = ({ targetY = scrollerRect.top; } btnContainer.style.top = `${targetY - parentRect.top}px`; + pinToViewportRight(btnContainer, parentRect, scroller); }; // Find which chunk index the mouse is directly over (deleted or inserted area) @@ -581,6 +594,20 @@ export const CodeMirrorDiffView = ({ } return false; }, + scroll(_event, view) { + // Reposition active toolbar on horizontal scroll so buttons stay at viewport edge + const activeToolbar = view.dom.querySelector('.cm-merge-toolbar-active'); + if (activeToolbar) { + const chunkEl = activeToolbar.closest('.cm-deletedChunk'); + if (chunkEl) { + const btnContainer = chunkEl.querySelector('.cm-chunkButtons'); + if (btnContainer) { + pinToViewportRight(btnContainer, chunkEl.getBoundingClientRect(), view.scrollDOM); + } + } + } + return false; + }, }) ); diff --git a/src/renderer/components/team/review/portionCollapse.ts b/src/renderer/components/team/review/portionCollapse.ts index cba6375e..e27d40cb 100644 --- a/src/renderer/components/team/review/portionCollapse.ts +++ b/src/renderer/components/team/review/portionCollapse.ts @@ -219,6 +219,8 @@ const portionCollapseTheme = EditorView.theme({ minHeight: '28px', cursor: 'default', userSelect: 'none', + position: 'sticky', + left: '0', }, '.cm-portion-collapse-text': { diff --git a/src/renderer/utils/crossTeamPendingReplies.ts b/src/renderer/utils/crossTeamPendingReplies.ts new file mode 100644 index 00000000..a3d5622c --- /dev/null +++ b/src/renderer/utils/crossTeamPendingReplies.ts @@ -0,0 +1,82 @@ +import type { InboxMessage } from '@shared/types'; + +export interface PendingCrossTeamReply { + teamName: string; + sentAtMs: number; + conversationId?: string; +} + +function parseQualifiedTeamName(value: string | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const dot = trimmed.indexOf('.'); + if (dot <= 0) return null; + return trimmed.slice(0, dot); +} + +export function computePendingCrossTeamReplies( + messages: InboxMessage[] | null | undefined +): PendingCrossTeamReply[] { + if (!messages || messages.length === 0) return []; + + const latestSentByTeam = new Map(); + const latestInboundByTeam = new Map(); + const latestSentByConversation = new Map< + string, + { teamName: string; sentAtMs: number; conversationId: string } + >(); + const latestInboundByConversation = new Map(); + + for (const message of messages) { + const timestampMs = Date.parse(message.timestamp); + if (!Number.isFinite(timestampMs)) continue; + + if (message.source === 'cross_team_sent') { + const teamName = parseQualifiedTeamName(message.to); + if (!teamName) continue; + if (message.conversationId) { + const existing = latestSentByConversation.get(message.conversationId); + if (!existing || timestampMs > existing.sentAtMs) { + latestSentByConversation.set(message.conversationId, { + teamName, + sentAtMs: timestampMs, + conversationId: message.conversationId, + }); + } + } else { + latestSentByTeam.set(teamName, Math.max(latestSentByTeam.get(teamName) ?? 0, timestampMs)); + } + continue; + } + + if (message.source === 'cross_team') { + const teamName = parseQualifiedTeamName(message.from); + if (!teamName) continue; + if (message.conversationId) { + latestInboundByConversation.set( + message.conversationId, + Math.max(latestInboundByConversation.get(message.conversationId) ?? 0, timestampMs) + ); + } else { + latestInboundByTeam.set( + teamName, + Math.max(latestInboundByTeam.get(teamName) ?? 0, timestampMs) + ); + } + } + } + + const exactPending = Array.from(latestSentByConversation.values()).filter( + ({ conversationId, sentAtMs }) => + sentAtMs > (latestInboundByConversation.get(conversationId) ?? 0) + ); + const teamsCoveredExactly = new Set(exactPending.map((entry) => entry.teamName)); + const legacyPending = Array.from(latestSentByTeam.entries()) + .filter(([teamName]) => !teamsCoveredExactly.has(teamName)) + .filter(([teamName, sentAtMs]) => sentAtMs > (latestInboundByTeam.get(teamName) ?? 0)) + .map(([teamName, sentAtMs]) => ({ teamName, sentAtMs })) + .sort((a, b) => b.sentAtMs - a.sentAtMs); + + return [...exactPending, ...legacyPending].sort((a, b) => b.sentAtMs - a.sentAtMs); +} diff --git a/src/shared/constants/crossTeam.ts b/src/shared/constants/crossTeam.ts index d30ea33b..d905a482 100644 --- a/src/shared/constants/crossTeam.ts +++ b/src/shared/constants/crossTeam.ts @@ -5,21 +5,68 @@ /** Prefix tag that wraps cross-team metadata in stored message text. */ export const CROSS_TEAM_PREFIX_TAG = 'Cross-team from'; -/** Build the full prefix line: `[Cross-team from team.member | depth:N]` */ -export function formatCrossTeamPrefix(from: string, chainDepth: number): string { - return `[${CROSS_TEAM_PREFIX_TAG} ${from} | depth:${chainDepth}]`; +export interface CrossTeamPrefixMeta { + conversationId?: string; + replyToConversationId?: string; +} + +export interface ParsedCrossTeamPrefix extends CrossTeamPrefixMeta { + from: string; + chainDepth: number; +} + +/** + * Build the full prefix line: + * `[Cross-team from team.member | depth:N | conversation:abc | replyTo:def]` + */ +export function formatCrossTeamPrefix( + from: string, + chainDepth: number, + meta?: CrossTeamPrefixMeta +): string { + const parts = [`${CROSS_TEAM_PREFIX_TAG} ${from}`, `depth:${chainDepth}`]; + if (meta?.conversationId) { + parts.push(`conversation:${meta.conversationId}`); + } + if (meta?.replyToConversationId) { + parts.push(`replyTo:${meta.replyToConversationId}`); + } + return `[${parts.join(' | ')}]`; } /** Format the full message text with prefix + body. */ -export function formatCrossTeamText(from: string, chainDepth: number, text: string): string { - return `${formatCrossTeamPrefix(from, chainDepth)}\n${text}`; +export function formatCrossTeamText( + from: string, + chainDepth: number, + text: string, + meta?: CrossTeamPrefixMeta +): string { + return `${formatCrossTeamPrefix(from, chainDepth, meta)}\n${text}`; } /** * Regex that matches the cross-team prefix line at the start of a message. - * Captures nothing — use `.replace(CROSS_TEAM_PREFIX_RE, '')` to strip it. + * Compatible with legacy rows that only contain `depth`. */ -export const CROSS_TEAM_PREFIX_RE = /^\[Cross-team from [^\]]+\]\n?/; +export const CROSS_TEAM_PREFIX_RE = + /^\[Cross-team from (?[^\]|]+?) \| depth:(?\d+)(?: \| conversation:(?[^\]|]+))?(?: \| replyTo:(?[^\]|]+))?\]\n?/; + +/** Parse metadata from a cross-team prefix line. */ +export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null { + const match = text.match(CROSS_TEAM_PREFIX_RE); + if (!match?.groups) return null; + + const from = match.groups.from?.trim(); + const chainDepth = Number.parseInt(match.groups.depth ?? '', 10); + if (!from || !Number.isFinite(chainDepth)) return null; + + return { + from, + chainDepth, + conversationId: match.groups.conversationId?.trim() || undefined, + replyToConversationId: match.groups.replyToConversationId?.trim() || undefined, + }; +} /** Strip the cross-team prefix from message text (for UI display). */ export function stripCrossTeamPrefix(text: string): string { diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 42e5c47c..4272a407 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -256,6 +256,10 @@ export interface InboxMessage { attachments?: AttachmentMeta[]; /** Lead session ID that produced this message (for session boundary detection). */ leadSessionId?: string; + /** Stable cross-team thread ID shared across request/reply turns. */ + conversationId?: string; + /** Explicit parent conversation/message reference for replies. */ + replyToConversationId?: string; /** Tool usage summary from assistant message, e.g. "3 tools (2 Read, Bash)" */ toolSummary?: string; /** Structured tool call details for tooltip display. */ @@ -273,6 +277,8 @@ export interface SendMessageRequest { source?: InboxMessage['source']; /** Lead session ID for session boundary detection. */ leadSessionId?: string; + conversationId?: string; + replyToConversationId?: string; } export interface SendMessageResult { @@ -609,6 +615,8 @@ export interface CrossTeamMessage { fromTeam: string; fromMember: string; toTeam: string; + conversationId?: string; + replyToConversationId?: string; text: string; summary?: string; chainDepth: number; @@ -619,6 +627,8 @@ export interface CrossTeamSendRequest { fromTeam: string; fromMember: string; toTeam: string; + conversationId?: string; + replyToConversationId?: string; text: string; summary?: string; chainDepth?: number; diff --git a/test/main/services/team/CrossTeamService.test.ts b/test/main/services/team/CrossTeamService.test.ts index 1ef67393..fc34f226 100644 --- a/test/main/services/team/CrossTeamService.test.ts +++ b/test/main/services/team/CrossTeamService.test.ts @@ -5,7 +5,7 @@ import { CrossTeamService } from '@main/services/team/CrossTeamService'; import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, - formatCrossTeamText, + parseCrossTeamPrefix, } from '@shared/constants/crossTeam'; import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; @@ -55,6 +55,7 @@ describe('CrossTeamService', () => { let provisioning: { isTeamAlive: ReturnType; relayLeadInboxMessages: ReturnType; + resolveCrossTeamReplyMetadata: ReturnType; }; beforeEach(() => { @@ -71,6 +72,7 @@ describe('CrossTeamService', () => { provisioning = { isTeamAlive: vi.fn().mockReturnValue(false), relayLeadInboxMessages: vi.fn().mockResolvedValue(0), + resolveCrossTeamReplyMetadata: vi.fn().mockReturnValue(null), }; service = new CrossTeamService( @@ -99,7 +101,11 @@ describe('CrossTeamService', () => { expect(req.member).toBe('team-lead'); expect(req.source).toBe(CROSS_TEAM_SOURCE); expect(req.from).toBe('team-a.lead'); - expect(req.text).toBe(formatCrossTeamText('team-a.lead', 0, 'Hello from team-a')); + expect(req.text).toContain('Hello from team-a'); + const prefix = parseCrossTeamPrefix(req.text); + expect(prefix?.from).toBe('team-a.lead'); + expect(prefix?.chainDepth).toBe(0); + expect(prefix?.conversationId).toBeTruthy(); }); it('writes sender copy to fromTeam inbox as user_sent', async () => { @@ -116,6 +122,45 @@ describe('CrossTeamService', () => { expect(senderReq.source).toBe(CROSS_TEAM_SENT_SOURCE); expect(senderReq.to).toBe('team-b.team-lead'); expect(senderReq.text).toBe('Hello from team-a'); + expect(senderReq.conversationId).toBeTruthy(); + }); + + it('reuses replyToConversationId as the conversationId for replies', async () => { + await service.send( + makeRequest({ + replyToConversationId: 'conv-123', + text: 'Here is the answer', + }) + ); + + const [, req] = inboxWriter.sendMessage.mock.calls[0]; + expect(req.conversationId).toBe('conv-123'); + expect(req.replyToConversationId).toBe('conv-123'); + }); + + it('auto-infers reply conversation metadata from provisioning hint when omitted', async () => { + provisioning.resolveCrossTeamReplyMetadata.mockReturnValue({ + conversationId: 'conv-auto', + replyToConversationId: 'conv-auto', + }); + + await service.send(makeRequest({ fromTeam: 'team-a', toTeam: 'team-b' })); + + const [, req] = inboxWriter.sendMessage.mock.calls[0]; + expect(req.conversationId).toBe('conv-auto'); + expect(req.replyToConversationId).toBe('conv-auto'); + expect(provisioning.resolveCrossTeamReplyMetadata).toHaveBeenCalledWith('team-a', 'team-b'); + }); + + it('does not ask provisioning for reply metadata when request already carries conversation ids', async () => { + await service.send( + makeRequest({ + conversationId: 'conv-explicit', + replyToConversationId: 'conv-explicit', + }) + ); + + expect(provisioning.resolveCrossTeamReplyMetadata).not.toHaveBeenCalled(); }); it('calls relayLeadInboxMessages when team is alive', async () => { diff --git a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts index adf42576..88818167 100644 --- a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts +++ b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts @@ -282,6 +282,8 @@ describe('TeamProvisioningService post-compact lifecycle', () => { expect(text).toContain('blocked by another team'); expect(text).toContain('one focused request per topic'); expect(text).toContain('If you receive a message that is clearly from another team'); + expect(text).toContain('preserve the same conversationId'); + expect(text).toContain('replyToConversationId'); expect(text).toContain('Do not wait silently on another team'); expect(text).toContain('Golden format for cross-team requests'); expect(text).toContain('Golden format for cross-team replies'); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index ea9a241f..b06ee16f 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -316,6 +316,58 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(hoisted.appendSentMessage).not.toHaveBeenCalled(); }); + it('resolves cross-team reply metadata only for a single matching team hint', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + attachAliveRun(service, teamName); + + const run = (service as unknown as { runs: Map }).runs.get('run-1') as { + activeCrossTeamReplyHints: Array<{ toTeam: string; conversationId: string }>; + }; + run.activeCrossTeamReplyHints = [{ toTeam: 'other-team', conversationId: 'conv-1' }]; + + expect(service.resolveCrossTeamReplyMetadata(teamName, 'other-team')).toEqual({ + conversationId: 'conv-1', + replyToConversationId: 'conv-1', + }); + + run.activeCrossTeamReplyHints = [ + { toTeam: 'other-team', conversationId: 'conv-1' }, + { toTeam: 'other-team', conversationId: 'conv-2' }, + ]; + expect(service.resolveCrossTeamReplyMetadata(teamName, 'other-team')).toBeNull(); + }); + + it('does not relay cross-team sender copies back into the live lead', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + seedLeadInbox(teamName, [ + { + from: 'user', + to: 'other-team.team-lead', + text: 'How is the progress on that task?', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + source: 'cross_team_sent', + messageId: 'm-cross-team-sent-1', + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + const relayed = await service.relayLeadInboxMessages(teamName); + + expect(relayed).toBe(0); + expect(writeSpy).toHaveBeenCalledTimes(0); + + const updatedInbox = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]' + ) as Array<{ messageId?: string }>; + expect(updatedInbox).toHaveLength(1); + expect(updatedInbox[0]?.messageId).toBe('m-cross-team-sent-1'); + }); + it('relays unread teammate inbox messages through the live team process', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; diff --git a/test/renderer/utils/crossTeamPendingReplies.test.ts b/test/renderer/utils/crossTeamPendingReplies.test.ts new file mode 100644 index 00000000..437bd761 --- /dev/null +++ b/test/renderer/utils/crossTeamPendingReplies.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies'; + +import type { InboxMessage } from '@shared/types'; + +function makeMessage(overrides: Partial = {}): InboxMessage { + return { + from: 'user', + text: 'hello', + timestamp: '2026-03-09T12:00:00.000Z', + read: true, + messageId: 'msg-1', + ...overrides, + }; +} + +describe('computePendingCrossTeamReplies', () => { + it('returns pending entry for outbound cross-team message without reply', () => { + const result = computePendingCrossTeamReplies([ + makeMessage({ + conversationId: 'conv-1', + source: 'cross_team_sent', + to: 'team-best.team-lead', + timestamp: '2026-03-09T12:00:00.000Z', + }), + ]); + + expect(result).toEqual([ + { + conversationId: 'conv-1', + teamName: 'team-best', + sentAtMs: Date.parse('2026-03-09T12:00:00.000Z'), + }, + ]); + }); + + it('clears pending entry when a newer cross-team reply arrives in the same conversation', () => { + const result = computePendingCrossTeamReplies([ + makeMessage({ + conversationId: 'conv-1', + source: 'cross_team_sent', + to: 'team-best.team-lead', + timestamp: '2026-03-09T12:00:00.000Z', + }), + makeMessage({ + conversationId: 'conv-1', + replyToConversationId: 'conv-1', + from: 'team-best.team-lead', + source: 'cross_team', + timestamp: '2026-03-09T12:05:00.000Z', + messageId: 'msg-2', + }), + ]); + + expect(result).toEqual([]); + }); + + it('keeps pending entry when the latest outbound is newer than the last reply', () => { + const result = computePendingCrossTeamReplies([ + makeMessage({ + conversationId: 'conv-1', + replyToConversationId: 'conv-1', + from: 'team-best.team-lead', + source: 'cross_team', + timestamp: '2026-03-09T12:05:00.000Z', + messageId: 'msg-1-reply', + }), + makeMessage({ + conversationId: 'conv-1', + source: 'cross_team_sent', + to: 'team-best.team-lead', + timestamp: '2026-03-09T12:10:00.000Z', + messageId: 'msg-2', + }), + ]); + + expect(result).toEqual([ + { + conversationId: 'conv-1', + teamName: 'team-best', + sentAtMs: Date.parse('2026-03-09T12:10:00.000Z'), + }, + ]); + }); + + it('keeps a pending conversation even when another team message arrives in a different conversation', () => { + const result = computePendingCrossTeamReplies([ + makeMessage({ + conversationId: 'conv-1', + source: 'cross_team_sent', + to: 'team-best.team-lead', + timestamp: '2026-03-09T12:00:00.000Z', + }), + makeMessage({ + conversationId: 'conv-2', + from: 'team-best.team-lead', + source: 'cross_team', + timestamp: '2026-03-09T12:05:00.000Z', + messageId: 'msg-2', + }), + ]); + + expect(result).toEqual([ + { + conversationId: 'conv-1', + teamName: 'team-best', + sentAtMs: Date.parse('2026-03-09T12:00:00.000Z'), + }, + ]); + }); + + it('ignores non-cross-team messages', () => { + const result = computePendingCrossTeamReplies([ + makeMessage({ + from: 'alice', + to: 'team-lead', + timestamp: '2026-03-09T12:00:00.000Z', + }), + ]); + + expect(result).toEqual([]); + }); + + it('falls back to legacy team-level matching when conversationId is missing', () => { + const result = computePendingCrossTeamReplies([ + makeMessage({ + source: 'cross_team_sent', + to: 'team-best.team-lead', + timestamp: '2026-03-09T12:00:00.000Z', + }), + ]); + + expect(result).toEqual([ + { + teamName: 'team-best', + sentAtMs: Date.parse('2026-03-09T12:00:00.000Z'), + }, + ]); + }); +});