From c93f3a41813380bdf5b63498763acfdfc952ee41 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 10 Mar 2026 15:40:42 +0200 Subject: [PATCH] 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. --- mcp-server/src/tools/crossTeamTools.ts | 16 +- mcp-server/test/tools.test.ts | 12 ++ src/main/services/team/CrossTeamService.ts | 18 +- .../services/team/TeamMcpConfigBuilder.ts | 18 +- src/main/services/team/TeamMemberResolver.ts | 35 +++- .../services/team/TeamProvisioningService.ts | 185 ++++++++++++++---- .../components/team/TeamDetailView.tsx | 2 +- .../team/activity/AnimatedHeightReveal.tsx | 4 +- src/renderer/constants/teamColors.ts | 8 +- .../services/team/CrossTeamService.test.ts | 43 ++-- .../team/TeamMcpConfigBuilder.test.ts | 40 ++++ .../services/team/TeamMemberResolver.test.ts | 44 +++++ ...eamProvisioningServiceLiveMessages.test.ts | 141 +++++++++++++ .../team/TeamProvisioningServiceRelay.test.ts | 80 ++++++++ test/renderer/constants/teamColors.test.ts | 14 +- 15 files changed, 572 insertions(+), 88 deletions(-) create mode 100644 test/main/services/team/TeamMcpConfigBuilder.test.ts diff --git a/mcp-server/src/tools/crossTeamTools.ts b/mcp-server/src/tools/crossTeamTools.ts index 5586f7c7..486eb014 100644 --- a/mcp-server/src/tools/crossTeamTools.ts +++ b/mcp-server/src/tools/crossTeamTools.ts @@ -20,9 +20,21 @@ export function registerCrossTeamTools(server: Pick) { 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) { text, ...(fromMember ? { fromMember } : {}), ...(summary ? { summary } : {}), + ...(conversationId ? { conversationId } : {}), + ...(replyToConversationId ? { replyToConversationId } : {}), ...(chainDepth !== undefined ? { chainDepth } : {}), }) ) diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index e419ff7d..72e79e79 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -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'; diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index 5d96f1fd..cdc0e48b 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -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, diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 6c3d568c..0e70d756 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -72,6 +72,16 @@ async function resolveNodePath(): Promise { } async function resolveMcpLaunchSpec(): Promise { + 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 { }; } - 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'); } diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 65878ac5..af5f3873 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -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 ( diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9ff2679a..8be2ca02 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 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 ..., 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: "", replyToConversationId: "" }) - 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(); + 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 { - 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[]): 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; - 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 { 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); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 733f387a..5290ed30 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -912,7 +912,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele ); } - if (loading && !data) { + if ((loading && !data) || (data && data.teamName !== teamName)) { return (
diff --git a/src/renderer/components/team/activity/AnimatedHeightReveal.tsx b/src/renderer/components/team/activity/AnimatedHeightReveal.tsx index 06b11254..c6e4ee1b 100644 --- a/src/renderer/components/team/activity/AnimatedHeightReveal.tsx +++ b/src/renderer/components/team/activity/AnimatedHeightReveal.tsx @@ -97,7 +97,9 @@ export const AnimatedHeightReveal = ({ ...style, }} > -
{children}
+
+ {children} +
); }; diff --git a/src/renderer/constants/teamColors.ts b/src/renderer/constants/teamColors.ts index c57ecc4d..0c847002 100644 --- a/src/renderer/constants/teamColors.ts +++ b/src/renderer/constants/teamColors.ts @@ -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]]; } /** diff --git a/test/main/services/team/CrossTeamService.test.ts b/test/main/services/team/CrossTeamService.test.ts index 66327af9..ca8ad6cb 100644 --- a/test/main/services/team/CrossTeamService.test.ts +++ b/test/main/services/team/CrossTeamService.test.ts @@ -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>; + 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); }); }); diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts new file mode 100644 index 00000000..454462be --- /dev/null +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -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; + }; + + 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`, + ]); + }); +}); diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts index b819fc99..742d4c9f 100644 --- a/test/main/services/team/TeamMemberResolver.test.ts +++ b/test/main/services/team/TeamMemberResolver.test.ts @@ -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 = { diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index 36ef070f..f94aaef2 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -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'); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 2db23530..b9a79f8c 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -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: '\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); + }); }); diff --git a/test/renderer/constants/teamColors.test.ts b/test/renderer/constants/teamColors.test.ts index 00844991..cfc389a7 100644 --- a/test/renderer/constants/teamColors.test.ts +++ b/test/renderer/constants/teamColors.test.ts @@ -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); }); });