feat: enhance cross-team messaging with message identity and metadata

- Added messageId and timestamp fields to CrossTeamSendRequest for better message tracking.
- Updated CrossTeamService to utilize these fields when sending messages, ensuring consistent message identity.
- Enhanced TeamProvisioningService to support cross-team sender functionality, allowing for improved message handling.
- Introduced parsing for cross-team reply prefixes to manage threaded conversations effectively.
- Updated tests to validate the inclusion of messageId and timestamp in cross-team messages.
This commit is contained in:
iliya 2026-03-10 00:58:19 +02:00
parent e99cbe1335
commit 477c28ed30
10 changed files with 362 additions and 74 deletions

View file

@ -676,6 +676,7 @@ function initializeServices(): void {
crossTeamInboxWriter,
teamProvisioningService
);
teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request));
const teamMemberLogsFinder = new TeamMemberLogsFinder();
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);

View file

@ -45,6 +45,8 @@ export class CrossTeamService {
async send(request: CrossTeamSendRequest): Promise<CrossTeamSendResult> {
const { fromTeam, fromMember, toTeam, text, summary } = request;
const chainDepth = request.chainDepth ?? 0;
const messageId = request.messageId?.trim() || randomUUID();
const timestamp = request.timestamp ?? new Date().toISOString();
const inferredReplyMeta =
!request.conversationId && !request.replyToConversationId
? (this.provisioning?.resolveCrossTeamReplyMetadata(fromTeam, toTeam) ?? null)
@ -90,7 +92,6 @@ export class CrossTeamService {
conversationId,
replyToConversationId,
});
const messageId = randomUUID();
const outboxMessage: CrossTeamMessage = {
messageId,
fromTeam,
@ -101,7 +102,7 @@ export class CrossTeamService {
text,
summary,
chainDepth,
timestamp: new Date().toISOString(),
timestamp,
};
const { duplicate } = await this.outbox.appendIfNotRecent(fromTeam, outboxMessage, async () => {
@ -114,6 +115,8 @@ export class CrossTeamService {
member: leadName,
text: formattedText,
from,
timestamp,
messageId,
summary: summary ?? `Cross-team message from ${fromTeam}`,
source: CROSS_TEAM_SOURCE,
conversationId,
@ -132,6 +135,8 @@ export class CrossTeamService {
member: senderLeadName,
text,
from: 'user',
timestamp,
messageId,
to: `${toTeam}.${leadName}`,
summary: summary ?? `Cross-team message to ${toTeam}`,
source: CROSS_TEAM_SENT_SOURCE,

View file

@ -23,6 +23,8 @@ import {
CROSS_TEAM_PREFIX_TAG,
CROSS_TEAM_SENT_SOURCE,
parseCrossTeamPrefix,
parseCrossTeamReplyPrefix,
stripCrossTeamPrefix,
} from '@shared/constants/crossTeam';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
@ -52,6 +54,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader';
import type {
CrossTeamSendResult,
InboxMessage,
LeadContextUsage,
TeamChangeEvent,
@ -72,6 +75,7 @@ import type {
const logger = createLogger('Service:TeamProvisioning');
const { createController } = agentTeamsControllerModule;
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
const RUN_TIMEOUT_MS = 300_000;
const VERIFY_TIMEOUT_MS = 15_000;
const VERIFY_POLL_MS = 500;
@ -1067,6 +1071,19 @@ export class TeamProvisioningService {
private toolApprovalSettings: ToolApprovalSettings = DEFAULT_TOOL_APPROVAL_SETTINGS;
private pendingTimeouts = new Map<string, NodeJS.Timeout>();
private inFlightResponses = new Set<string>();
private crossTeamSender:
| ((request: {
fromTeam: string;
fromMember: string;
toTeam: string;
text: string;
summary?: string;
messageId?: string;
timestamp?: string;
conversationId?: string;
replyToConversationId?: string;
}) => Promise<CrossTeamSendResult>)
| null = null;
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
@ -1076,6 +1093,24 @@ export class TeamProvisioningService {
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder()
) {}
setCrossTeamSender(
sender:
| ((request: {
fromTeam: string;
fromMember: string;
toTeam: string;
text: string;
summary?: string;
messageId?: string;
timestamp?: string;
conversationId?: string;
replyToConversationId?: string;
}) => Promise<CrossTeamSendResult>)
| null
): void {
this.crossTeamSender = sender;
}
getClaudeLogs(
teamName: string,
query?: { offset?: number; limit?: number }
@ -1186,6 +1221,21 @@ export class TeamProvisioningService {
this.teamChangeEmitter = emitter;
}
private parseCrossTeamRecipient(
currentTeam: string,
recipient: string
): { teamName: string; memberName: string } | null {
const trimmed = recipient.trim();
const dot = trimmed.indexOf('.');
if (dot <= 0 || dot === trimmed.length - 1) return null;
const teamName = trimmed.slice(0, dot).trim();
const memberName = trimmed.slice(dot + 1).trim();
if (!TEAM_NAME_PATTERN.test(teamName) || !memberName || teamName === currentTeam) {
return null;
}
return { teamName, memberName };
}
private persistSentMessage(teamName: string, message: InboxMessage): void {
try {
createController({
@ -1200,6 +1250,8 @@ export class TeamProvisioningService {
messageId: message.messageId,
source: message.source,
leadSessionId: message.leadSessionId,
conversationId: message.conversationId,
replyToConversationId: message.replyToConversationId,
attachments: message.attachments,
color: message.color,
toolSummary: message.toolSummary,
@ -1224,6 +1276,8 @@ export class TeamProvisioningService {
messageId: message.messageId,
source: message.source,
leadSessionId: message.leadSessionId,
conversationId: message.conversationId,
replyToConversationId: message.replyToConversationId,
attachments: message.attachments,
color: message.color,
toolSummary: message.toolSummary,
@ -3016,17 +3070,91 @@ export class TeamProvisioningService {
const cleanContent = stripAgentBlocks(msgContent);
if (cleanContent.trim().length === 0) continue;
const strippedCrossTeamContent = stripCrossTeamPrefix(cleanContent).trim();
if (strippedCrossTeamContent.length === 0) continue;
const crossTeamRecipient = this.parseCrossTeamRecipient(run.teamName, recipient);
if (crossTeamRecipient && this.crossTeamSender) {
const explicitReplyMeta = parseCrossTeamReplyPrefix(cleanContent);
const inferredReplyMeta = this.resolveCrossTeamReplyMetadata(
run.teamName,
crossTeamRecipient.teamName
);
const crossTeamMeta = parseCrossTeamPrefix(cleanContent);
const replyMeta = explicitReplyMeta ?? inferredReplyMeta;
const timestamp = nowIso();
const messageId = `lead-sendmsg-${run.runId}-${Date.now()}`;
void this.crossTeamSender({
fromTeam: run.teamName,
fromMember: leadName,
toTeam: crossTeamRecipient.teamName,
text: strippedCrossTeamContent,
summary,
messageId,
timestamp,
conversationId:
explicitReplyMeta?.conversationId ??
crossTeamMeta?.conversationId ??
replyMeta?.conversationId,
replyToConversationId:
explicitReplyMeta?.replyToConversationId ??
replyMeta?.replyToConversationId ??
explicitReplyMeta?.conversationId ??
crossTeamMeta?.conversationId ??
replyMeta?.conversationId,
})
.then(() => {
const msg: InboxMessage = {
from: 'user',
to: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`,
text: strippedCrossTeamContent,
timestamp,
read: true,
summary:
(summary || strippedCrossTeamContent).length > 60
? (summary || strippedCrossTeamContent).slice(0, 57) + '...'
: summary || strippedCrossTeamContent,
messageId,
source: 'cross_team_sent',
conversationId:
explicitReplyMeta?.conversationId ??
crossTeamMeta?.conversationId ??
replyMeta?.conversationId,
replyToConversationId:
explicitReplyMeta?.replyToConversationId ??
replyMeta?.replyToConversationId ??
explicitReplyMeta?.conversationId ??
crossTeamMeta?.conversationId ??
replyMeta?.conversationId,
};
this.pushLiveLeadProcessMessage(run.teamName, msg);
this.teamChangeEmitter?.({
type: 'lead-message',
teamName: run.teamName,
detail: 'cross-team-send',
});
})
.catch((error: unknown) => {
logger.warn(
`[${run.teamName}] qualified SendMessage→${recipient} cross-team fallback failed: ${
error instanceof Error ? error.message : String(error)
}`
);
});
continue;
}
const msg: InboxMessage = {
from: leadName,
to: recipient,
text: cleanContent,
text: strippedCrossTeamContent,
timestamp: nowIso(),
read: recipient !== 'user',
summary:
(summary || cleanContent).length > 60
? (summary || cleanContent).slice(0, 57) + '...'
: summary || cleanContent,
(summary || strippedCrossTeamContent).length > 60
? (summary || strippedCrossTeamContent).slice(0, 57) + '...'
: summary || strippedCrossTeamContent,
messageId: `lead-sendmsg-${run.runId}-${Date.now()}`,
source: 'lead_process',
};
@ -3094,9 +3222,10 @@ export class TeamProvisioningService {
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 hints = run?.activeCrossTeamReplyHints ?? [];
if (hints.length === 0) return null;
const matches = run.activeCrossTeamReplyHints.filter((hint) => hint.toTeam === toTeam);
const matches = hints.filter((hint) => hint.toTeam === toTeam);
if (matches.length !== 1) return null;
return {

View file

@ -10,7 +10,15 @@ import { useCallback, useEffect, useMemo } from 'react';
import { isElectronMode } from '@renderer/api';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { formatBytes } from '@renderer/utils/formatters';
import { AlertTriangle, CheckCircle, Download, Loader2, RefreshCw, Terminal } from 'lucide-react';
import {
AlertTriangle,
CheckCircle,
Download,
Loader2,
Puzzle,
RefreshCw,
Terminal,
} from 'lucide-react';
import { SettingsSectionHeader } from '../components';
@ -66,15 +74,61 @@ export const CliStatusSection = (): React.JSX.Element | null => {
<div className="space-y-2">
{cliStatus.installed ? (
<div className="space-y-1">
<div
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text)' }}
>
<div className="flex items-center gap-2 text-sm">
<Terminal
className="size-4 shrink-0"
style={{ color: 'var(--color-text-muted)' }}
/>
<span>Claude CLI v{cliStatus.installedVersion ?? 'unknown'}</span>
<span style={{ color: 'var(--color-text)' }}>
Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
</span>
{/* Inline action buttons */}
{cliStatus.updateAvailable ? (
<button
onClick={handleInstall}
disabled={isBusy}
className="flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-3.5" />
Update
</button>
) : (
<button
onClick={handleRefresh}
disabled={cliStatusLoading}
className="flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
{cliStatusLoading ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Checking...
</>
) : (
<>
<RefreshCw className="size-3.5" />
Check for Updates
</>
)}
</button>
)}
{/* Extensions button — right-aligned */}
<button
type="button"
onClick={() => {}}
className="ml-auto flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<Puzzle className="size-3.5" />
Extensions
</button>
</div>
{cliStatus.binaryPath && (
<p
@ -103,54 +157,18 @@ export const CliStatusSection = (): React.JSX.Element | null => {
</div>
)}
{/* Action buttons */}
<div className="flex gap-2">
{!cliStatus.installed && (
<button
onClick={handleInstall}
disabled={isBusy}
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-3.5" />
Install Claude CLI
</button>
)}
{cliStatus.installed && cliStatus.updateAvailable && (
<button
onClick={handleInstall}
disabled={isBusy}
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-3.5" />
Update
</button>
)}
{cliStatus.installed && !cliStatus.updateAvailable && (
<button
onClick={handleRefresh}
disabled={cliStatusLoading}
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
{cliStatusLoading ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Checking...
</>
) : (
<>
<RefreshCw className="size-3.5" />
Check for Updates
</>
)}
</button>
)}
</div>
{/* Install button (CLI not installed) */}
{!cliStatus.installed && (
<button
onClick={handleInstall}
disabled={isBusy}
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-3.5" />
Install Claude CLI
</button>
)}
</div>
)}

View file

@ -27,6 +27,8 @@ import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import {
CROSS_TEAM_SENT_SOURCE,
CROSS_TEAM_SOURCE,
parseCrossTeamPrefix,
parseCrossTeamReplyPrefix,
stripCrossTeamPrefix,
} from '@shared/constants/crossTeam';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
@ -43,6 +45,19 @@ import type { InboxMessage } from '@shared/types';
type StructuredMessage = Record<string, unknown>;
function parseQualifiedRecipient(
value: string | undefined
): { teamName: string; memberName: string } | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
const dot = trimmed.indexOf('.');
if (dot <= 0 || dot === trimmed.length - 1) return null;
return {
teamName: trimmed.slice(0, dot),
memberName: trimmed.slice(dot + 1),
};
}
interface ActivityItemProps {
message: InboxMessage;
teamName: string;
@ -259,24 +274,36 @@ export const ActivityItem = ({
const isManaged = isManagedCollapseState(collapseState);
const isExpanded = isManaged ? !collapseState.isCollapsed : true;
const isCrossTeam = message.source === CROSS_TEAM_SOURCE;
const isCrossTeamSent = message.source === CROSS_TEAM_SENT_SOURCE;
const parsedCrossTeamPrefix = useMemo(() => parseCrossTeamPrefix(message.text), [message.text]);
const parsedCrossTeamReplyPrefix = useMemo(
() => parseCrossTeamReplyPrefix(message.text),
[message.text]
);
const qualifiedRecipient = useMemo(() => parseQualifiedRecipient(message.to), [message.to]);
const isCrossTeam = message.source === CROSS_TEAM_SOURCE || parsedCrossTeamPrefix !== null;
const isCrossTeamSent =
message.source === CROSS_TEAM_SENT_SOURCE ||
parsedCrossTeamReplyPrefix !== null ||
(qualifiedRecipient?.teamName !== undefined && qualifiedRecipient.teamName !== teamName);
const isCrossTeamAny = isCrossTeam || isCrossTeamSent;
const crossTeamOrigin = useMemo(() => {
if (!isCrossTeam) return null;
const dot = message.from.indexOf('.');
if (dot <= 0 || dot === message.from.length - 1) return null;
const fromValue = parsedCrossTeamPrefix?.from ?? message.from;
const dot = fromValue.indexOf('.');
if (dot <= 0 || dot === fromValue.length - 1) return null;
return {
teamName: message.from.substring(0, dot),
memberName: message.from.substring(dot + 1),
teamName: fromValue.substring(0, dot),
memberName: fromValue.substring(dot + 1),
};
}, [isCrossTeam, message.from]);
}, [isCrossTeam, message.from, parsedCrossTeamPrefix]);
const crossTeamTarget = useMemo(() => {
if (!isCrossTeamSent || !message.to) return null;
if (!isCrossTeamSent) return null;
if (qualifiedRecipient) return qualifiedRecipient.teamName;
if (!message.to) return null;
const dot = message.to.indexOf('.');
if (dot <= 0) return message.to;
return message.to.substring(0, dot);
}, [isCrossTeamSent, message.to]);
}, [isCrossTeamSent, message.to, qualifiedRecipient]);
// Strip agent-only blocks + normalize escape sequences (before linkification)
const strippedText = useMemo(() => {
@ -493,9 +520,9 @@ export const ActivityItem = ({
&rarr;
</span>
<MemberBadge
name={message.to}
name={qualifiedRecipient?.memberName ?? message.to}
color={recipientColor}
hideAvatar={message.to === 'user'}
hideAvatar={(qualifiedRecipient?.memberName ?? message.to) === 'user'}
onClick={onMemberNameClick}
/>
</>

View file

@ -51,6 +51,9 @@ export function formatCrossTeamText(
export const CROSS_TEAM_PREFIX_RE =
/^\[Cross-team from (?<from>[^\]|]+?) \| depth:(?<depth>\d+)(?: \| conversation:(?<conversationId>[^\]|]+))?(?: \| replyTo:(?<replyToConversationId>[^\]|]+))?\]\n?/;
export const CROSS_TEAM_REPLY_PREFIX_RE =
/^\[Cross-team reply(?: \| conversation:(?<conversationId>[^\]|]+))?(?: \| replyTo:(?<replyToConversationId>[^\]|]+))?\]\n?/;
/** Parse metadata from a cross-team prefix line. */
export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null {
const match = text.match(CROSS_TEAM_PREFIX_RE);
@ -68,9 +71,19 @@ export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null
};
}
export function parseCrossTeamReplyPrefix(text: string): CrossTeamPrefixMeta | null {
const match = text.match(CROSS_TEAM_REPLY_PREFIX_RE);
if (!match?.groups) return null;
return {
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 {
return text.replace(CROSS_TEAM_PREFIX_RE, '');
return text.replace(CROSS_TEAM_PREFIX_RE, '').replace(CROSS_TEAM_REPLY_PREFIX_RE, '');
}
// ── Source discriminators ────────────────────────────────────────────────────

View file

@ -634,6 +634,8 @@ export interface CrossTeamSendRequest {
fromTeam: string;
fromMember: string;
toTeam: string;
timestamp?: string;
messageId?: string;
conversationId?: string;
replyToConversationId?: string;
text: string;

View file

@ -122,6 +122,10 @@ 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.messageId).toBeDefined();
expect(senderReq.timestamp).toBeDefined();
expect(senderReq.messageId).toBe(inboxWriter.sendMessage.mock.calls[0][1].messageId);
expect(senderReq.timestamp).toBe(inboxWriter.sendMessage.mock.calls[0][1].timestamp);
expect(senderReq.conversationId).toBeTruthy();
});

View file

@ -422,4 +422,53 @@ describe('TeamProvisioningService pre-ready live messages', () => {
})
);
});
it('upgrades qualified SendMessage recipients into cross-team sends', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-1' }));
service.setTeamChangeEmitter(emitter);
service.setCrossTeamSender(crossTeamSender);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'team-best.user',
content: '[Cross-team reply | conversation:conv-123] Привет!',
summary: 'Ответ',
},
},
],
});
await vi.waitFor(() => {
expect(crossTeamSender).toHaveBeenCalledTimes(1);
});
expect(crossTeamSender).toHaveBeenCalledWith(
expect.objectContaining({
fromTeam: 'my-team',
fromMember: 'team-lead',
toTeam: 'team-best',
text: 'Привет!',
conversationId: 'conv-123',
replyToConversationId: 'conv-123',
})
);
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].source).toBe('cross_team_sent');
expect(live[0].to).toBe('team-best.user');
expect(live[0].text).toBe('Привет!');
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import {
parseCrossTeamPrefix,
parseCrossTeamReplyPrefix,
stripCrossTeamPrefix,
} from '@shared/constants/crossTeam';
describe('crossTeam protocol helpers', () => {
it('parses canonical cross-team prefix metadata', () => {
const parsed = parseCrossTeamPrefix(
'[Cross-team from dream-team.team-lead | depth:0 | conversation:conv-1 | replyTo:conv-0]\nHello'
);
expect(parsed).toEqual({
from: 'dream-team.team-lead',
chainDepth: 0,
conversationId: 'conv-1',
replyToConversationId: 'conv-0',
});
});
it('parses manual cross-team reply prefix metadata', () => {
const parsed = parseCrossTeamReplyPrefix(
'[Cross-team reply | conversation:conv-1 | replyTo:conv-1]\nHello'
);
expect(parsed).toEqual({
conversationId: 'conv-1',
replyToConversationId: 'conv-1',
});
});
it('strips both canonical and reply prefixes from UI text', () => {
expect(stripCrossTeamPrefix('[Cross-team from a.b | depth:0 | conversation:conv-1]\nHello')).toBe(
'Hello'
);
expect(stripCrossTeamPrefix('[Cross-team reply | conversation:conv-1]\nHello')).toBe('Hello');
});
});