feat: enhance cross-team messaging with new parameters and recipient handling

- Added optional parameters 'conversationId' and 'replyToConversationId' to the cross-team messaging tool for improved threading.
- Updated the TeamMemberResolver to ignore tool-like cross-team inbox names, ensuring cleaner member resolution.
- Enhanced TeamProvisioningService to handle explicit cross-team reply instructions and prevent relaying tool-like names.
- Improved tests to validate new cross-team messaging features and recipient handling, ensuring robust functionality across services.
This commit is contained in:
iliya 2026-03-10 15:40:42 +02:00
parent 71143db3ac
commit c93f3a4181
15 changed files with 572 additions and 88 deletions

View file

@ -20,9 +20,21 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
text: z.string().min(1),
fromMember: z.string().optional(),
summary: z.string().optional(),
conversationId: z.string().optional(),
replyToConversationId: z.string().optional(),
chainDepth: z.number().int().nonnegative().optional(),
}),
execute: async ({ teamName, claudeDir, toTeam, text, fromMember, summary, chainDepth }) =>
execute: async ({
teamName,
claudeDir,
toTeam,
text,
fromMember,
summary,
conversationId,
replyToConversationId,
chainDepth,
}) =>
await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
@ -30,6 +42,8 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
text,
...(fromMember ? { fromMember } : {}),
...(summary ? { summary } : {}),
...(conversationId ? { conversationId } : {}),
...(replyToConversationId ? { replyToConversationId } : {}),
...(chainDepth !== undefined ? { chainDepth } : {}),
})
)

View file

@ -77,6 +77,18 @@ describe('agent-teams-mcp tools', () => {
expect([...tools.keys()].sort()).toEqual([...expectedToolNames]);
});
it('accepts explicit conversation threading fields for cross_team_send', () => {
const parsed = getTool('cross_team_send').parameters?.safeParse({
teamName: 'alpha',
toTeam: 'beta',
text: 'Reply',
conversationId: 'conv-1',
replyToConversationId: 'conv-1',
});
expect(parsed?.success).toBe(true);
});
it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'alpha';

View file

@ -1,6 +1,7 @@
import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { getClaudeBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as agentTeamsControllerModule from 'agent-teams-controller';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
@ -20,6 +21,7 @@ import type {
} from '@shared/types';
const logger = createLogger('CrossTeamService');
const { createController } = agentTeamsControllerModule;
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
@ -132,16 +134,18 @@ export class CrossTeamService {
return { messageId: duplicate.messageId, deliveredToInbox: true, deduplicated: true };
}
// 6. Write "sent" copy to SENDER's inbox so the message appears in their activity
const senderLeadName = (await this.dataService.getLeadMemberName(fromTeam)) ?? 'team-lead';
// 6. Write a non-actionable sender copy so the message appears in activity without
// waking the local lead through their inbox controller.
try {
await this.inboxWriter.sendMessage(fromTeam, {
member: senderLeadName,
createController({
teamName: fromTeam,
claudeDir: getClaudeBasePath(),
}).messages.appendSentMessage({
from: fromMember,
to: `${toTeam}.${leadName}`,
text,
from: 'user',
timestamp,
messageId,
to: `${toTeam}.${leadName}`,
summary: summary ?? `Cross-team message to ${toTeam}`,
source: CROSS_TEAM_SENT_SOURCE,
conversationId,

View file

@ -72,6 +72,16 @@ async function resolveNodePath(): Promise<string> {
}
async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
const sourceEntry = getSourceServerEntry();
if (await pathExists(sourceEntry)) {
// Prefer source in workspace/dev runs so newly added MCP tools are available
// immediately and we do not accidentally serve a stale built dist bundle.
return {
command: 'pnpm',
args: ['--dir', getMcpServerDir(), 'exec', 'tsx', sourceEntry],
};
}
const builtEntry = getBuiltServerEntry();
if (await pathExists(builtEntry)) {
return {
@ -80,14 +90,6 @@ async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
};
}
const sourceEntry = getSourceServerEntry();
if (await pathExists(sourceEntry)) {
return {
command: 'pnpm',
args: ['--dir', getMcpServerDir(), 'exec', 'tsx', sourceEntry],
};
}
throw new Error('agent-teams-mcp entrypoint not found in mcp-server package');
}

View file

@ -9,6 +9,11 @@ import type {
} from '@shared/types';
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
'cross_team_send',
'cross_team_list_targets',
'cross_team_get_outbox',
]);
function looksLikeQualifiedExternalRecipient(name: string): boolean {
const trimmed = name.trim();
@ -21,17 +26,28 @@ function looksLikeQualifiedExternalRecipient(name: string): boolean {
function looksLikeCrossTeamPseudoRecipient(name: string): boolean {
const trimmed = name.trim();
if (trimmed.startsWith('cross-team:')) {
const teamName = trimmed.slice('cross-team:'.length).trim();
return TEAM_NAME_PATTERN.test(teamName);
}
if (trimmed.startsWith('cross-team-')) {
const teamName = trimmed.slice('cross-team-'.length).trim();
return TEAM_NAME_PATTERN.test(teamName);
const prefixes = [
'cross_team::',
'cross_team--',
'cross-team:',
'cross-team-',
'cross_team:',
'cross_team-',
];
for (const prefix of prefixes) {
if (!trimmed.startsWith(prefix)) continue;
const teamName = trimmed.slice(prefix.length).trim();
if (TEAM_NAME_PATTERN.test(teamName)) {
return true;
}
}
return false;
}
function looksLikeCrossTeamToolRecipient(name: string): boolean {
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim());
}
export class TeamMemberResolver {
resolveMembers(
config: TeamConfig,
@ -75,7 +91,10 @@ export class TeamMemberResolver {
for (const inboxName of inboxNames) {
if (typeof inboxName === 'string' && inboxName.trim() !== '') {
const trimmed = inboxName.trim();
if (looksLikeCrossTeamPseudoRecipient(trimmed)) {
if (
looksLikeCrossTeamPseudoRecipient(trimmed) ||
looksLikeCrossTeamToolRecipient(trimmed)
) {
continue;
}
if (

View file

@ -95,6 +95,11 @@ const TASK_WAIT_FALLBACK_MS = 15_000;
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024;
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
'cross_team_send',
'cross_team_list_targets',
'cross_team_get_outbox',
]);
const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.';
const PREFLIGHT_PING_ARGS = [
'-p',
@ -592,7 +597,7 @@ Communication protocol (CRITICAL — you are running headless, no one sees your
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
- Your plain text output is invisible to teammates they are separate processes and can only read their inbox.
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
- Cross-team communication: when work needs expertise, coordination, review, or a decision from ANOTHER team, use MCP tool "cross_team_send" with teamName: "${teamName}" and a focused actionable message.
- Cross-team communication: when work needs expertise, coordination, review, or a decision from ANOTHER team, CALL the MCP tool named "cross_team_send" with teamName: "${teamName}" and a focused actionable message.
- Before sending cross-team, use MCP tool "cross_team_list_targets" with teamName: "${teamName}" to discover valid target teams.
- To review messages your team already sent to other teams, use MCP tool "cross_team_get_outbox" with teamName: "${teamName}".
- Cross-team delivery goes to the target team's lead inbox and may be relayed to that live lead automatically.
@ -600,9 +605,12 @@ Communication protocol (CRITICAL — you are running headless, no one sees your
- Prefer concise messages that state: what you need, why that team is relevant, the expected response, and any task or file references they need.
- 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.
- 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 by CALLING the MCP tool "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".
- NEVER put "cross_team_send" into a SendMessage recipient or message_send "to" field. "cross_team_send" is a TOOL NAME, not a teammate or inbox name.
- Correct example:
cross_team_send({ teamName: "${teamName}", toTeam: "other-team", text: "your reply", conversationId: "<same-id>", replyToConversationId: "<same-id>" })
- Never write protocol markup yourself in message text. Do NOT include "<${CROSS_TEAM_PREFIX_TAG} ... />" or any other metadata wrapper in the visible reply body; send plain user-visible text only.
- 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.
@ -1249,12 +1257,12 @@ export class TeamProvisioningService {
): { teamName: string; memberName: string } | null {
const trimmed = recipient.trim();
if (localRecipientNames.has(trimmed)) return null;
if (trimmed.startsWith('cross-team:')) {
const teamName = trimmed.slice('cross-team:'.length).trim();
if (!TEAM_NAME_PATTERN.test(teamName) || teamName === currentTeam) {
const pseudoTeamName = this.extractCrossTeamPseudoTargetTeam(trimmed);
if (pseudoTeamName) {
if (pseudoTeamName === currentTeam) {
return null;
}
return { teamName, memberName: 'team-lead' };
return { teamName: pseudoTeamName, memberName: 'team-lead' };
}
const dot = trimmed.indexOf('.');
if (dot <= 0 || dot === trimmed.length - 1) return null;
@ -1266,17 +1274,46 @@ export class TeamProvisioningService {
return { teamName, memberName };
}
private extractCrossTeamPseudoTargetTeam(value: string): string | null {
const trimmed = value.trim();
const prefixes = [
'cross_team::',
'cross_team--',
'cross-team:',
'cross-team-',
'cross_team:',
'cross_team-',
];
for (const prefix of prefixes) {
if (!trimmed.startsWith(prefix)) continue;
const teamName = trimmed.slice(prefix.length).trim();
if (TEAM_NAME_PATTERN.test(teamName)) {
return teamName;
}
}
return null;
}
private isCrossTeamToolRecipientName(name: string): boolean {
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim());
}
private isCrossTeamPseudoRecipientName(name: string): boolean {
const trimmed = name.trim();
if (trimmed.startsWith('cross-team:')) {
const teamName = trimmed.slice('cross-team:'.length).trim();
return TEAM_NAME_PATTERN.test(teamName);
return this.extractCrossTeamPseudoTargetTeam(name) !== null;
}
private resolveSingleActiveCrossTeamReplyHint(
run: ProvisioningRun
): { toTeam: string; conversationId: string } | null {
const uniqueHints = new Map<string, { toTeam: string; conversationId: string }>();
for (const hint of run.activeCrossTeamReplyHints ?? []) {
const toTeam = typeof hint?.toTeam === 'string' ? hint.toTeam.trim() : '';
const conversationId =
typeof hint?.conversationId === 'string' ? hint.conversationId.trim() : '';
if (!toTeam || !conversationId) continue;
uniqueHints.set(`${toTeam}\0${conversationId}`, { toTeam, conversationId });
}
if (trimmed.startsWith('cross-team-')) {
const teamName = trimmed.slice('cross-team-'.length).trim();
return TEAM_NAME_PATTERN.test(teamName);
}
return false;
return uniqueHints.size === 1 ? (Array.from(uniqueHints.values())[0] ?? null) : null;
}
private looksLikeQualifiedExternalRecipientName(name: string): boolean {
@ -2725,7 +2762,10 @@ export class TeamProvisioningService {
}
async relayMemberInboxMessages(teamName: string, memberName: string): Promise<number> {
if (this.isCrossTeamPseudoRecipientName(memberName)) {
if (
this.isCrossTeamPseudoRecipientName(memberName) ||
this.isCrossTeamToolRecipientName(memberName)
) {
return 0;
}
const relayKey = this.getMemberRelayKey(teamName, memberName);
@ -2806,7 +2846,7 @@ export class TeamProvisioningService {
crossTeamMeta?.sourceTeam && conversationId
? [
` Cross-team conversationId: ${conversationId}`,
` If replying with cross_team_send to ${crossTeamMeta.sourceTeam}, set conversationId="${conversationId}" and replyToConversationId="${conversationId}".`,
` Call the MCP tool named cross_team_send with toTeam="${crossTeamMeta.sourceTeam}", conversationId="${conversationId}", and replyToConversationId="${conversationId}". Do NOT put "cross_team_send" into a SendMessage recipient or message_send "to" field.`,
]
: [];
return [
@ -3021,15 +3061,39 @@ export class TeamProvisioningService {
`IMPORTANT: Your text response here is shown to the user. Always include a brief human-readable summary (e.g. "Delegated to carol." or "No action needed."). Do NOT respond with only an agent-only block.`,
AGENT_BLOCK_OPEN,
`Internal note: for task assignments, prefer task_create and rely on the board/runtime notification path instead of sending a separate SendMessage for the same assignment.`,
`If a message below is marked Source: cross_team, CALL the MCP tool named cross_team_send. Do NOT use SendMessage or message_send for cross-team replies.`,
`NEVER set recipient="cross_team_send" or to="cross_team_send". "cross_team_send" is a tool name, not a teammate.`,
AGENT_BLOCK_CLOSE,
``,
`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.replyToConversationId?.trim() ??
m.conversationId ??
crossTeamMeta?.origin?.conversationId;
const replyInstructions =
crossTeamMeta?.sourceTeam && conversationId
? [
` Cross-team conversationId: ${conversationId}`,
` Call the MCP tool named cross_team_send with toTeam="${crossTeamMeta.sourceTeam}", conversationId="${conversationId}", and replyToConversationId="${conversationId}". Do NOT use SendMessage or message_send. NEVER set recipient/to to "cross_team_send".`,
]
: [];
return [
`${idx + 1}) From: ${m.from || 'unknown'}`,
` Timestamp: ${m.timestamp}`,
...(summaryLine ? [` ${summaryLine}`] : []),
...(typeof m.source === 'string' && m.source.trim()
? [` Source: ${m.source.trim()}`]
: []),
...replyInstructions,
` Text:`,
...m.text.split('\n').map((line) => ` ${line}`),
``,
@ -3334,15 +3398,30 @@ export class TeamProvisioningService {
private captureSendMessages(run: ProvisioningRun, content: Record<string, unknown>[]): void {
for (const part of content) {
if (part.type !== 'tool_use' || part.name !== 'SendMessage') continue;
if (part.type !== 'tool_use' || typeof part.name !== 'string') continue;
const isNativeSendMessage = part.name === 'SendMessage';
const isTeamMessageSendTool = part.name === 'mcp__agent-teams__message_send';
if (!isNativeSendMessage && !isTeamMessageSendTool) continue;
const input = part.input;
if (!input || typeof input !== 'object') continue;
const inp = input as Record<string, unknown>;
const recipient = typeof inp.recipient === 'string' ? inp.recipient : '';
const recipient = isNativeSendMessage
? typeof inp.recipient === 'string'
? inp.recipient
: ''
: typeof inp.to === 'string'
? inp.to
: '';
if (!recipient.trim()) continue;
const msgContent = typeof inp.content === 'string' ? inp.content : '';
const msgContent = isNativeSendMessage
? typeof inp.content === 'string'
? inp.content
: ''
: typeof inp.text === 'string'
? inp.text
: '';
if (msgContent.trim().length === 0) continue;
const summary = typeof inp.summary === 'string' ? inp.summary : '';
@ -3362,16 +3441,20 @@ export class TeamProvisioningService {
localRecipientNames.add('user');
localRecipientNames.add('team-lead');
const crossTeamRecipient = this.parseCrossTeamRecipient(
run.teamName,
recipient,
localRecipientNames
);
const mistakenToolHint = this.isCrossTeamToolRecipientName(recipient)
? this.resolveSingleActiveCrossTeamReplyHint(run)
: null;
const crossTeamRecipient =
this.parseCrossTeamRecipient(run.teamName, recipient, localRecipientNames) ??
(mistakenToolHint ? { teamName: mistakenToolHint.toTeam, memberName: 'team-lead' } : null);
if (crossTeamRecipient && this.crossTeamSender) {
const inferredReplyMeta = this.resolveCrossTeamReplyMetadata(
run.teamName,
crossTeamRecipient.teamName
);
const inferredReplyMeta =
mistakenToolHint && mistakenToolHint.toTeam === crossTeamRecipient.teamName
? {
conversationId: mistakenToolHint.conversationId,
replyToConversationId: mistakenToolHint.conversationId,
}
: this.resolveCrossTeamReplyMetadata(run.teamName, crossTeamRecipient.teamName);
const crossTeamMeta = parseCrossTeamPrefix(cleanContent);
const replyMeta = inferredReplyMeta;
const timestamp = nowIso();
@ -3396,10 +3479,12 @@ export class TeamProvisioningService {
return;
}
const msg: InboxMessage = {
from: 'user',
from: leadName,
to: recipient.startsWith('cross-team:')
? recipient
: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`,
: this.isCrossTeamToolRecipientName(recipient)
? `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`
: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`,
text: strippedCrossTeamContent,
timestamp,
read: true,
@ -3432,6 +3517,14 @@ export class TeamProvisioningService {
continue;
}
if (this.isCrossTeamToolRecipientName(recipient)) {
continue;
}
if (!isNativeSendMessage) {
continue;
}
const msg: InboxMessage = {
from: leadName,
to: recipient,
@ -4453,7 +4546,12 @@ export class TeamProvisioningService {
this.stopFilesystemMonitor(run);
if (run.isLaunch) {
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
await this.updateConfigPostLaunch(
run.teamName,
run.request.cwd,
run.detectedSessionId,
run.request.color
);
await this.cleanupPrelaunchBackup(run.teamName);
// Defense in depth: if the CLI (or a stale config) produced auto-suffixed members (alice-2),
@ -4570,7 +4668,12 @@ export class TeamProvisioningService {
// Persist teammates metadata separately from config.json.
await this.persistMembersMeta(run.teamName, run.request);
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
await this.updateConfigPostLaunch(
run.teamName,
run.request.cwd,
run.detectedSessionId,
run.request.color
);
const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', {
cliLogsTail: extractCliLogsFromRun(run),
@ -5015,6 +5118,13 @@ export class TeamProvisioningService {
if (!run.isLaunch) {
await this.persistMembersMeta(run.teamName, run.request);
}
// Persist team color even on timeout path
await this.updateConfigPostLaunch(
run.teamName,
run.request.cwd,
run.detectedSessionId,
run.request.color
);
// Process was killed by timeout — mark as disconnected, not ready
const progress = updateProgress(run, 'disconnected', 'Team provisioned but process timed out', {
warnings,
@ -5159,7 +5269,8 @@ export class TeamProvisioningService {
private async updateConfigPostLaunch(
teamName: string,
projectPath: string,
detectedSessionId: string | null
detectedSessionId: string | null,
color?: string
): Promise<void> {
const MAX_SESSION_HISTORY = 5000;
const MAX_PROJECT_PATH_HISTORY = 500;
@ -5216,6 +5327,11 @@ export class TeamProvisioningService {
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
config.language = langCode;
// Persist team color chosen by the user during creation
if (color && color.trim().length > 0) {
config.color = color.trim();
}
// Ensure projectPath
if (projectPath.trim()) {
config.projectPath = projectPath;
@ -5817,6 +5933,7 @@ export class TeamProvisioningService {
const inboxNames = allInboxNames
.filter((name) => name !== 'team-lead' && name !== 'user')
.filter((name) => !this.isCrossTeamPseudoRecipientName(name))
.filter((name) => !this.isCrossTeamToolRecipientName(name))
.filter((name) => !this.looksLikeQualifiedExternalRecipientName(name))
.filter((name) => {
const match = /^(.+)-(\d+)$/.exec(name);

View file

@ -912,7 +912,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
);
}
if (loading && !data) {
if ((loading && !data) || (data && data.teamName !== teamName)) {
return (
<div className="size-full overflow-auto p-4">
<div className="mb-4 h-10 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />

View file

@ -97,7 +97,9 @@ export const AnimatedHeightReveal = ({
...style,
}}
>
<div style={{ minHeight: 0, overflow: isExpanded ? 'visible' : 'hidden' }}>{children}</div>
<div style={{ minHeight: 0, minWidth: 0, overflow: isExpanded ? 'visible' : 'hidden' }}>
{children}
</div>
</div>
);
};

View file

@ -125,6 +125,9 @@ export function getSubagentTypeColorSet(
return TEAMMATE_COLORS[COLOR_NAMES[index]];
}
/** Assignable visual colors (excludes reserved 'user'). */
const ASSIGNABLE_COLORS = COLOR_NAMES.filter((c) => c !== 'user');
export function getTeamColorSet(colorName: string): TeamColorSet {
if (!colorName) return DEFAULT_COLOR;
@ -141,7 +144,10 @@ export function getTeamColorSet(colorName: string): TeamColorSet {
};
}
return DEFAULT_COLOR;
// Hash unknown palette names (e.g. "coral", "sapphire") to one of the
// available visual colors instead of always falling back to blue.
const index = hashString(colorName.toLowerCase()) % ASSIGNABLE_COLORS.length;
return TEAMMATE_COLORS[ASSIGNABLE_COLORS[index]];
}
/**

View file

@ -16,6 +16,7 @@ import type { CrossTeamSendRequest, TeamConfig } from '@shared/types';
vi.mock('@main/utils/pathDecoder', () => ({
getTeamsBasePath: () => '/tmp/cross-team-test-nonexistent-dir-' + process.pid,
getClaudeBasePath: () => '/tmp/cross-team-test-nonexistent-dir-' + process.pid,
}));
const MOCK_TEAMS_BASE_PATH = '/tmp/cross-team-test-nonexistent-dir-' + process.pid;
@ -99,7 +100,7 @@ describe('CrossTeamService', () => {
expect(result.deliveredToInbox).toBe(true);
expect(result.messageId).toBeDefined();
// First call: target team inbox, second call: sender copy (best-effort)
// Target team delivery goes through inboxWriter.
const [teamName, req] = inboxWriter.sendMessage.mock.calls[0];
expect(teamName).toBe('team-b');
expect(req.member).toBe('team-lead');
@ -118,33 +119,24 @@ describe('CrossTeamService', () => {
const [, req] = inboxWriter.sendMessage.mock.calls[0];
expect(req.text).toContain('TURN ACTION MODE: ASK');
expect(req.text).toContain('STRICTLY read-only conversation mode');
await vi.waitFor(() => {
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(2);
});
const [, senderReq] = inboxWriter.sendMessage.mock.calls[1];
expect(senderReq.text).toBe('Can you inspect this?');
});
it('writes sender copy to fromTeam inbox as user_sent', async () => {
it('writes sender copy to sentMessages.json without touching the lead inbox', async () => {
await service.send(makeRequest());
// Wait for the best-effort sender copy (void promise)
await vi.waitFor(() => {
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(2);
});
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1);
const [senderTeam, senderReq] = inboxWriter.sendMessage.mock.calls[1];
expect(senderTeam).toBe('team-a');
expect(senderReq.from).toBe('user');
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();
const sentMessagesPath = `${MOCK_TEAMS_BASE_PATH}/teams/team-a/sentMessages.json`;
const raw = fs.readFileSync(sentMessagesPath, 'utf8');
const sentRows = JSON.parse(raw) as Array<Record<string, unknown>>;
expect(sentRows).toHaveLength(1);
expect(sentRows[0]?.from).toBe('lead');
expect(sentRows[0]?.source).toBe(CROSS_TEAM_SENT_SOURCE);
expect(sentRows[0]?.to).toBe('team-b.team-lead');
expect(sentRows[0]?.text).toBe('Hello from team-a');
expect(sentRows[0]?.messageId).toBe(inboxWriter.sendMessage.mock.calls[0][1].messageId);
expect(sentRows[0]?.timestamp).toBe(inboxWriter.sendMessage.mock.calls[0][1].timestamp);
expect(sentRows[0]?.conversationId).toBeTruthy();
});
it('reuses replyToConversationId as the conversationId for replies', async () => {
@ -216,10 +208,11 @@ describe('CrossTeamService', () => {
expect(order).toEqual([
'register:team-a->team-b',
'write:team-b',
'write:team-a',
'clear:team-a->team-b',
'relay:team-b',
]);
const sentMessagesPath = `${MOCK_TEAMS_BASE_PATH}/teams/team-a/sentMessages.json`;
expect(fs.existsSync(sentMessagesPath)).toBe(true);
});
it('does not relay when team is offline', async () => {
@ -325,7 +318,7 @@ describe('CrossTeamService', () => {
expect(second.deduplicated).toBe(true);
expect(second.messageId).toBe(first.messageId);
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(2);
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,40 @@
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs';
import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder';
describe('TeamMcpConfigBuilder', () => {
const createdPaths: string[] = [];
afterEach(() => {
for (const filePath of createdPaths.splice(0)) {
try {
fs.rmSync(filePath, { force: true });
} catch {
// ignore cleanup issues in temp dir
}
}
});
it('prefers the source MCP entry when workspace source is available', async () => {
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw) as {
mcpServers?: Record<string, { command?: string; args?: string[] }>;
};
const server = parsed.mcpServers?.['agent-teams'];
expect(server?.command).toBe('pnpm');
expect(server?.args).toEqual([
'--dir',
`${process.cwd()}/mcp-server`,
'exec',
'tsx',
`${process.cwd()}/mcp-server/src/index.ts`,
]);
});
});

View file

@ -129,6 +129,50 @@ describe('TeamMemberResolver', () => {
expect(names).not.toContain('cross-team-team-alpha-super');
});
it('ignores tool-like cross-team inbox names', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }],
};
const members = resolver.resolveMembers(
config,
[],
['cross_team_send', 'cross_team_list_targets', 'alice'],
[],
[]
);
const names = members.map((m) => m.name);
expect(names).toContain('alice');
expect(names).toContain('team-lead');
expect(names).not.toContain('cross_team_send');
expect(names).not.toContain('cross_team_list_targets');
});
it('ignores malformed underscore-style pseudo cross-team inbox names', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }],
};
const members = resolver.resolveMembers(
config,
[],
['cross_team::team-alpha-super', 'cross_team--team-alpha-super', 'alice'],
[],
[]
);
const names = members.map((m) => m.name);
expect(names).toContain('alice');
expect(names).toContain('team-lead');
expect(names).not.toContain('cross_team::team-alpha-super');
expect(names).not.toContain('cross_team--team-alpha-super');
});
it('keeps dotted names when config casing differs from inbox casing', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {

View file

@ -468,6 +468,7 @@ describe('TeamProvisioningService pre-ready live messages', () => {
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].from).toBe('team-lead');
expect(live[0].source).toBe('cross_team_sent');
expect(live[0].to).toBe('team-best.user');
expect(live[0].text).toBe('Привет!');
@ -513,11 +514,151 @@ describe('TeamProvisioningService pre-ready live messages', () => {
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].from).toBe('team-lead');
expect(live[0].source).toBe('cross_team_sent');
expect(live[0].to).toBe('cross-team:team-best');
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
});
it('upgrades MCP message_send pseudo recipients into cross-team sends', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-mcp-1' }));
service.setCrossTeamSender(crossTeamSender);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
run.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-mcp-1' }];
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'mcp__agent-teams__message_send',
input: {
teamName: 'my-team',
to: 'cross-team:team-best',
text: 'Ответ через MCP.',
from: 'team-lead',
summary: 'MCP reply',
},
},
],
});
await vi.waitFor(() => {
expect(crossTeamSender).toHaveBeenCalledTimes(1);
});
expect(crossTeamSender).toHaveBeenCalledWith(
expect.objectContaining({
fromTeam: 'my-team',
fromMember: 'team-lead',
toTeam: 'team-best',
text: 'Ответ через MCP.',
conversationId: 'conv-mcp-1',
replyToConversationId: 'conv-mcp-1',
})
);
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].from).toBe('team-lead');
expect(live[0].source).toBe('cross_team_sent');
expect(live[0].to).toBe('cross-team:team-best');
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
});
it('rescues mistaken cross_team_send recipients into actual cross-team replies', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-mcp-tool-1' }));
service.setCrossTeamSender(crossTeamSender);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
run.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-tool-1' }];
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'mcp__agent-teams__message_send',
input: {
teamName: 'my-team',
to: 'cross_team_send',
text: 'Исправленный ответ.',
from: 'team-lead',
summary: 'Ответ через tool recipient mistake',
},
},
],
});
await vi.waitFor(() => {
expect(crossTeamSender).toHaveBeenCalledTimes(1);
});
expect(crossTeamSender).toHaveBeenCalledWith(
expect.objectContaining({
fromTeam: 'my-team',
fromMember: 'team-lead',
toTeam: 'team-best',
text: 'Исправленный ответ.',
conversationId: 'conv-tool-1',
replyToConversationId: 'conv-tool-1',
})
);
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].from).toBe('team-lead');
expect(live[0].source).toBe('cross_team_sent');
expect(live[0].to).toBe('team-best.team-lead');
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
});
it('rescues cross_team::team pseudo recipients into actual cross-team replies', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-colon-1' }));
service.setCrossTeamSender(crossTeamSender);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'mcp__agent-teams__message_send',
input: {
teamName: 'my-team',
to: 'cross_team::team-best',
text: 'Ответ через fallback pseudo recipient.',
summary: 'Fallback pseudo reply',
},
},
],
});
await vi.waitFor(() => {
expect(crossTeamSender).toHaveBeenCalledTimes(1);
});
expect(crossTeamSender).toHaveBeenCalledWith(
expect.objectContaining({
fromTeam: 'my-team',
fromMember: 'team-lead',
toTeam: 'team-best',
text: 'Ответ через fallback pseudo recipient.',
})
);
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.team-lead');
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
});
it('strips canonical cross-team tag from outbound cross-team content', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');

View file

@ -339,6 +339,44 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(service.resolveCrossTeamReplyMetadata(teamName, 'other-team')).toBeNull();
});
it('includes explicit cross-team reply instructions in lead relay prompts', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedLeadInbox(teamName, [
{
from: 'other-team.team-lead',
to: 'team-lead',
text: '<cross-team from="other-team.team-lead" depth="0" conversationId="conv-explicit" />\nNeed your answer.',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
source: 'cross_team',
messageId: 'm-cross-team-explicit',
conversationId: 'conv-explicit',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayPromise = service.relayLeadInboxMessages(teamName);
const run = await waitForCapture(service);
expect(run?.leadRelayCapture).toBeTruthy();
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('Source: cross_team');
expect(payload).toContain('Cross-team conversationId: conv-explicit');
expect(payload).toContain('Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"');
expect(payload).toContain('replyToConversationId=\\"conv-explicit\\"');
expect(payload).toContain('NEVER set recipient/to to \\"cross_team_send\\"');
(service as any).handleStreamJsonMessage(run, {
type: 'assistant',
content: [{ type: 'text', text: 'Replying properly.' }],
});
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
await relayPromise;
});
it('does not relay cross-team sender copies back into the live lead', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
@ -538,4 +576,46 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
});
it('does not relay tool-like cross-team inbox names as teammates', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedMemberInbox(teamName, 'cross_team_send', [
{
from: 'team-lead',
text: 'Wrongly routed tool recipient inbox',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
messageId: 'm-tool-recipient-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayed = await service.relayMemberInboxMessages(teamName, 'cross_team_send');
expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
});
it('does not relay malformed underscore-style pseudo cross-team inbox names as teammates', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedMemberInbox(teamName, 'cross_team::team-best', [
{
from: 'team-lead',
text: 'Wrongly routed underscore pseudo inbox',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
messageId: 'm-underscore-pseudo-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayed = await service.relayMemberInboxMessages(teamName, 'cross_team::team-best');
expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
});
});

View file

@ -34,9 +34,19 @@ describe('getTeamColorSet', () => {
expect(result.text).toBe('#ff5500');
});
it('falls back to blue for unknown non-hex strings', () => {
it('hashes unknown non-hex strings to a valid named color (not always blue)', () => {
const result = getTeamColorSet('nonexistent');
expect(result.border).toBe('#3b82f6');
// Should be a valid color set from the named palette, not necessarily blue
expect(isValidColorSet(result)).toBe(true);
// Should be deterministic
expect(getTeamColorSet('nonexistent')).toEqual(result);
// Different unknown strings should potentially yield different colors
const colors = new Set(
['coral', 'sapphire', 'honey', 'arctic', 'chartreuse'].map(
(name) => getTeamColorSet(name).border
)
);
expect(colors.size).toBeGreaterThan(1);
});
});