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:
parent
71143db3ac
commit
c93f3a4181
15 changed files with 572 additions and 88 deletions
|
|
@ -20,9 +20,21 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
text: z.string().min(1),
|
text: z.string().min(1),
|
||||||
fromMember: z.string().optional(),
|
fromMember: z.string().optional(),
|
||||||
summary: z.string().optional(),
|
summary: z.string().optional(),
|
||||||
|
conversationId: z.string().optional(),
|
||||||
|
replyToConversationId: z.string().optional(),
|
||||||
chainDepth: z.number().int().nonnegative().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(
|
await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
|
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
|
||||||
|
|
@ -30,6 +42,8 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
text,
|
text,
|
||||||
...(fromMember ? { fromMember } : {}),
|
...(fromMember ? { fromMember } : {}),
|
||||||
...(summary ? { summary } : {}),
|
...(summary ? { summary } : {}),
|
||||||
|
...(conversationId ? { conversationId } : {}),
|
||||||
|
...(replyToConversationId ? { replyToConversationId } : {}),
|
||||||
...(chainDepth !== undefined ? { chainDepth } : {}),
|
...(chainDepth !== undefined ? { chainDepth } : {}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,18 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect([...tools.keys()].sort()).toEqual([...expectedToolNames]);
|
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 () => {
|
it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const teamName = 'alpha';
|
const teamName = 'alpha';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants';
|
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 { createLogger } from '@shared/utils/logger';
|
||||||
|
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ import type {
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('CrossTeamService');
|
const logger = createLogger('CrossTeamService');
|
||||||
|
const { createController } = agentTeamsControllerModule;
|
||||||
|
|
||||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
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 };
|
return { messageId: duplicate.messageId, deliveredToInbox: true, deduplicated: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Write "sent" copy to SENDER's inbox so the message appears in their activity
|
// 6. Write a non-actionable sender copy so the message appears in activity without
|
||||||
const senderLeadName = (await this.dataService.getLeadMemberName(fromTeam)) ?? 'team-lead';
|
// waking the local lead through their inbox controller.
|
||||||
try {
|
try {
|
||||||
await this.inboxWriter.sendMessage(fromTeam, {
|
createController({
|
||||||
member: senderLeadName,
|
teamName: fromTeam,
|
||||||
|
claudeDir: getClaudeBasePath(),
|
||||||
|
}).messages.appendSentMessage({
|
||||||
|
from: fromMember,
|
||||||
|
to: `${toTeam}.${leadName}`,
|
||||||
text,
|
text,
|
||||||
from: 'user',
|
|
||||||
timestamp,
|
timestamp,
|
||||||
messageId,
|
messageId,
|
||||||
to: `${toTeam}.${leadName}`,
|
|
||||||
summary: summary ?? `Cross-team message to ${toTeam}`,
|
summary: summary ?? `Cross-team message to ${toTeam}`,
|
||||||
source: CROSS_TEAM_SENT_SOURCE,
|
source: CROSS_TEAM_SENT_SOURCE,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,16 @@ async function resolveNodePath(): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
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();
|
const builtEntry = getBuiltServerEntry();
|
||||||
if (await pathExists(builtEntry)) {
|
if (await pathExists(builtEntry)) {
|
||||||
return {
|
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');
|
throw new Error('agent-teams-mcp entrypoint not found in mcp-server package');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ import type {
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
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 {
|
function looksLikeQualifiedExternalRecipient(name: string): boolean {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
|
|
@ -21,17 +26,28 @@ function looksLikeQualifiedExternalRecipient(name: string): boolean {
|
||||||
|
|
||||||
function looksLikeCrossTeamPseudoRecipient(name: string): boolean {
|
function looksLikeCrossTeamPseudoRecipient(name: string): boolean {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (trimmed.startsWith('cross-team:')) {
|
const prefixes = [
|
||||||
const teamName = trimmed.slice('cross-team:'.length).trim();
|
'cross_team::',
|
||||||
return TEAM_NAME_PATTERN.test(teamName);
|
'cross_team--',
|
||||||
}
|
'cross-team:',
|
||||||
if (trimmed.startsWith('cross-team-')) {
|
'cross-team-',
|
||||||
const teamName = trimmed.slice('cross-team-'.length).trim();
|
'cross_team:',
|
||||||
return TEAM_NAME_PATTERN.test(teamName);
|
'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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeCrossTeamToolRecipient(name: string): boolean {
|
||||||
|
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim());
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamMemberResolver {
|
export class TeamMemberResolver {
|
||||||
resolveMembers(
|
resolveMembers(
|
||||||
config: TeamConfig,
|
config: TeamConfig,
|
||||||
|
|
@ -75,7 +91,10 @@ export class TeamMemberResolver {
|
||||||
for (const inboxName of inboxNames) {
|
for (const inboxName of inboxNames) {
|
||||||
if (typeof inboxName === 'string' && inboxName.trim() !== '') {
|
if (typeof inboxName === 'string' && inboxName.trim() !== '') {
|
||||||
const trimmed = inboxName.trim();
|
const trimmed = inboxName.trim();
|
||||||
if (looksLikeCrossTeamPseudoRecipient(trimmed)) {
|
if (
|
||||||
|
looksLikeCrossTeamPseudoRecipient(trimmed) ||
|
||||||
|
looksLikeCrossTeamToolRecipient(trimmed)
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,11 @@ const TASK_WAIT_FALLBACK_MS = 15_000;
|
||||||
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
|
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
|
||||||
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
|
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
const TEAM_INBOX_MAX_BYTES = 2 * 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_PROMPT = 'Output only the single word PONG.';
|
||||||
const PREFLIGHT_PING_ARGS = [
|
const PREFLIGHT_PING_ARGS = [
|
||||||
'-p',
|
'-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.
|
- 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.
|
- 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").
|
- 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.
|
- 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}".
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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".
|
- 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.
|
- 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.
|
- 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.
|
- 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 {
|
): { teamName: string; memberName: string } | null {
|
||||||
const trimmed = recipient.trim();
|
const trimmed = recipient.trim();
|
||||||
if (localRecipientNames.has(trimmed)) return null;
|
if (localRecipientNames.has(trimmed)) return null;
|
||||||
if (trimmed.startsWith('cross-team:')) {
|
const pseudoTeamName = this.extractCrossTeamPseudoTargetTeam(trimmed);
|
||||||
const teamName = trimmed.slice('cross-team:'.length).trim();
|
if (pseudoTeamName) {
|
||||||
if (!TEAM_NAME_PATTERN.test(teamName) || teamName === currentTeam) {
|
if (pseudoTeamName === currentTeam) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { teamName, memberName: 'team-lead' };
|
return { teamName: pseudoTeamName, memberName: 'team-lead' };
|
||||||
}
|
}
|
||||||
const dot = trimmed.indexOf('.');
|
const dot = trimmed.indexOf('.');
|
||||||
if (dot <= 0 || dot === trimmed.length - 1) return null;
|
if (dot <= 0 || dot === trimmed.length - 1) return null;
|
||||||
|
|
@ -1266,17 +1274,46 @@ export class TeamProvisioningService {
|
||||||
return { teamName, memberName };
|
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 {
|
private isCrossTeamPseudoRecipientName(name: string): boolean {
|
||||||
const trimmed = name.trim();
|
return this.extractCrossTeamPseudoTargetTeam(name) !== null;
|
||||||
if (trimmed.startsWith('cross-team:')) {
|
}
|
||||||
const teamName = trimmed.slice('cross-team:'.length).trim();
|
|
||||||
return TEAM_NAME_PATTERN.test(teamName);
|
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-')) {
|
return uniqueHints.size === 1 ? (Array.from(uniqueHints.values())[0] ?? null) : null;
|
||||||
const teamName = trimmed.slice('cross-team-'.length).trim();
|
|
||||||
return TEAM_NAME_PATTERN.test(teamName);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private looksLikeQualifiedExternalRecipientName(name: string): boolean {
|
private looksLikeQualifiedExternalRecipientName(name: string): boolean {
|
||||||
|
|
@ -2725,7 +2762,10 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async relayMemberInboxMessages(teamName: string, memberName: string): Promise<number> {
|
async relayMemberInboxMessages(teamName: string, memberName: string): Promise<number> {
|
||||||
if (this.isCrossTeamPseudoRecipientName(memberName)) {
|
if (
|
||||||
|
this.isCrossTeamPseudoRecipientName(memberName) ||
|
||||||
|
this.isCrossTeamToolRecipientName(memberName)
|
||||||
|
) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const relayKey = this.getMemberRelayKey(teamName, memberName);
|
const relayKey = this.getMemberRelayKey(teamName, memberName);
|
||||||
|
|
@ -2806,7 +2846,7 @@ export class TeamProvisioningService {
|
||||||
crossTeamMeta?.sourceTeam && conversationId
|
crossTeamMeta?.sourceTeam && conversationId
|
||||||
? [
|
? [
|
||||||
` Cross-team conversationId: ${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 [
|
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.`,
|
`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,
|
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.`,
|
`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,
|
AGENT_BLOCK_CLOSE,
|
||||||
``,
|
``,
|
||||||
`Messages:`,
|
`Messages:`,
|
||||||
...batch.flatMap((m, idx) => {
|
...batch.flatMap((m, idx) => {
|
||||||
const summaryLine = m.summary?.trim() ? `Summary: ${m.summary.trim()}` : null;
|
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 [
|
return [
|
||||||
`${idx + 1}) From: ${m.from || 'unknown'}`,
|
`${idx + 1}) From: ${m.from || 'unknown'}`,
|
||||||
` Timestamp: ${m.timestamp}`,
|
` Timestamp: ${m.timestamp}`,
|
||||||
...(summaryLine ? [` ${summaryLine}`] : []),
|
...(summaryLine ? [` ${summaryLine}`] : []),
|
||||||
|
...(typeof m.source === 'string' && m.source.trim()
|
||||||
|
? [` Source: ${m.source.trim()}`]
|
||||||
|
: []),
|
||||||
|
...replyInstructions,
|
||||||
` Text:`,
|
` Text:`,
|
||||||
...m.text.split('\n').map((line) => ` ${line}`),
|
...m.text.split('\n').map((line) => ` ${line}`),
|
||||||
``,
|
``,
|
||||||
|
|
@ -3334,15 +3398,30 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
private captureSendMessages(run: ProvisioningRun, content: Record<string, unknown>[]): void {
|
private captureSendMessages(run: ProvisioningRun, content: Record<string, unknown>[]): void {
|
||||||
for (const part of content) {
|
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;
|
const input = part.input;
|
||||||
if (!input || typeof input !== 'object') continue;
|
if (!input || typeof input !== 'object') continue;
|
||||||
const inp = input as Record<string, unknown>;
|
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;
|
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;
|
if (msgContent.trim().length === 0) continue;
|
||||||
|
|
||||||
const summary = typeof inp.summary === 'string' ? inp.summary : '';
|
const summary = typeof inp.summary === 'string' ? inp.summary : '';
|
||||||
|
|
@ -3362,16 +3441,20 @@ export class TeamProvisioningService {
|
||||||
localRecipientNames.add('user');
|
localRecipientNames.add('user');
|
||||||
localRecipientNames.add('team-lead');
|
localRecipientNames.add('team-lead');
|
||||||
|
|
||||||
const crossTeamRecipient = this.parseCrossTeamRecipient(
|
const mistakenToolHint = this.isCrossTeamToolRecipientName(recipient)
|
||||||
run.teamName,
|
? this.resolveSingleActiveCrossTeamReplyHint(run)
|
||||||
recipient,
|
: null;
|
||||||
localRecipientNames
|
const crossTeamRecipient =
|
||||||
);
|
this.parseCrossTeamRecipient(run.teamName, recipient, localRecipientNames) ??
|
||||||
|
(mistakenToolHint ? { teamName: mistakenToolHint.toTeam, memberName: 'team-lead' } : null);
|
||||||
if (crossTeamRecipient && this.crossTeamSender) {
|
if (crossTeamRecipient && this.crossTeamSender) {
|
||||||
const inferredReplyMeta = this.resolveCrossTeamReplyMetadata(
|
const inferredReplyMeta =
|
||||||
run.teamName,
|
mistakenToolHint && mistakenToolHint.toTeam === crossTeamRecipient.teamName
|
||||||
crossTeamRecipient.teamName
|
? {
|
||||||
);
|
conversationId: mistakenToolHint.conversationId,
|
||||||
|
replyToConversationId: mistakenToolHint.conversationId,
|
||||||
|
}
|
||||||
|
: this.resolveCrossTeamReplyMetadata(run.teamName, crossTeamRecipient.teamName);
|
||||||
const crossTeamMeta = parseCrossTeamPrefix(cleanContent);
|
const crossTeamMeta = parseCrossTeamPrefix(cleanContent);
|
||||||
const replyMeta = inferredReplyMeta;
|
const replyMeta = inferredReplyMeta;
|
||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
|
|
@ -3396,10 +3479,12 @@ export class TeamProvisioningService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const msg: InboxMessage = {
|
const msg: InboxMessage = {
|
||||||
from: 'user',
|
from: leadName,
|
||||||
to: recipient.startsWith('cross-team:')
|
to: recipient.startsWith('cross-team:')
|
||||||
? recipient
|
? recipient
|
||||||
: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`,
|
: this.isCrossTeamToolRecipientName(recipient)
|
||||||
|
? `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`
|
||||||
|
: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`,
|
||||||
text: strippedCrossTeamContent,
|
text: strippedCrossTeamContent,
|
||||||
timestamp,
|
timestamp,
|
||||||
read: true,
|
read: true,
|
||||||
|
|
@ -3432,6 +3517,14 @@ export class TeamProvisioningService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isCrossTeamToolRecipientName(recipient)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNativeSendMessage) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const msg: InboxMessage = {
|
const msg: InboxMessage = {
|
||||||
from: leadName,
|
from: leadName,
|
||||||
to: recipient,
|
to: recipient,
|
||||||
|
|
@ -4453,7 +4546,12 @@ export class TeamProvisioningService {
|
||||||
this.stopFilesystemMonitor(run);
|
this.stopFilesystemMonitor(run);
|
||||||
|
|
||||||
if (run.isLaunch) {
|
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);
|
await this.cleanupPrelaunchBackup(run.teamName);
|
||||||
|
|
||||||
// Defense in depth: if the CLI (or a stale config) produced auto-suffixed members (alice-2),
|
// 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.
|
// Persist teammates metadata separately from config.json.
|
||||||
await this.persistMembersMeta(run.teamName, run.request);
|
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', {
|
const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', {
|
||||||
cliLogsTail: extractCliLogsFromRun(run),
|
cliLogsTail: extractCliLogsFromRun(run),
|
||||||
|
|
@ -5015,6 +5118,13 @@ export class TeamProvisioningService {
|
||||||
if (!run.isLaunch) {
|
if (!run.isLaunch) {
|
||||||
await this.persistMembersMeta(run.teamName, run.request);
|
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
|
// Process was killed by timeout — mark as disconnected, not ready
|
||||||
const progress = updateProgress(run, 'disconnected', 'Team provisioned but process timed out', {
|
const progress = updateProgress(run, 'disconnected', 'Team provisioned but process timed out', {
|
||||||
warnings,
|
warnings,
|
||||||
|
|
@ -5159,7 +5269,8 @@ export class TeamProvisioningService {
|
||||||
private async updateConfigPostLaunch(
|
private async updateConfigPostLaunch(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
detectedSessionId: string | null
|
detectedSessionId: string | null,
|
||||||
|
color?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const MAX_SESSION_HISTORY = 5000;
|
const MAX_SESSION_HISTORY = 5000;
|
||||||
const MAX_PROJECT_PATH_HISTORY = 500;
|
const MAX_PROJECT_PATH_HISTORY = 500;
|
||||||
|
|
@ -5216,6 +5327,11 @@ export class TeamProvisioningService {
|
||||||
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
|
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
|
||||||
config.language = langCode;
|
config.language = langCode;
|
||||||
|
|
||||||
|
// Persist team color chosen by the user during creation
|
||||||
|
if (color && color.trim().length > 0) {
|
||||||
|
config.color = color.trim();
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure projectPath
|
// Ensure projectPath
|
||||||
if (projectPath.trim()) {
|
if (projectPath.trim()) {
|
||||||
config.projectPath = projectPath;
|
config.projectPath = projectPath;
|
||||||
|
|
@ -5817,6 +5933,7 @@ export class TeamProvisioningService {
|
||||||
const inboxNames = allInboxNames
|
const inboxNames = allInboxNames
|
||||||
.filter((name) => name !== 'team-lead' && name !== 'user')
|
.filter((name) => name !== 'team-lead' && name !== 'user')
|
||||||
.filter((name) => !this.isCrossTeamPseudoRecipientName(name))
|
.filter((name) => !this.isCrossTeamPseudoRecipientName(name))
|
||||||
|
.filter((name) => !this.isCrossTeamToolRecipientName(name))
|
||||||
.filter((name) => !this.looksLikeQualifiedExternalRecipientName(name))
|
.filter((name) => !this.looksLikeQualifiedExternalRecipientName(name))
|
||||||
.filter((name) => {
|
.filter((name) => {
|
||||||
const match = /^(.+)-(\d+)$/.exec(name);
|
const match = /^(.+)-(\d+)$/.exec(name);
|
||||||
|
|
|
||||||
|
|
@ -912,7 +912,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && !data) {
|
if ((loading && !data) || (data && data.teamName !== teamName)) {
|
||||||
return (
|
return (
|
||||||
<div className="size-full overflow-auto p-4">
|
<div className="size-full overflow-auto p-4">
|
||||||
<div className="mb-4 h-10 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
|
<div className="mb-4 h-10 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,9 @@ export const AnimatedHeightReveal = ({
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ minHeight: 0, overflow: isExpanded ? 'visible' : 'hidden' }}>{children}</div>
|
<div style={{ minHeight: 0, minWidth: 0, overflow: isExpanded ? 'visible' : 'hidden' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,9 @@ export function getSubagentTypeColorSet(
|
||||||
return TEAMMATE_COLORS[COLOR_NAMES[index]];
|
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 {
|
export function getTeamColorSet(colorName: string): TeamColorSet {
|
||||||
if (!colorName) return DEFAULT_COLOR;
|
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]];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import type { CrossTeamSendRequest, TeamConfig } from '@shared/types';
|
||||||
|
|
||||||
vi.mock('@main/utils/pathDecoder', () => ({
|
vi.mock('@main/utils/pathDecoder', () => ({
|
||||||
getTeamsBasePath: () => '/tmp/cross-team-test-nonexistent-dir-' + process.pid,
|
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;
|
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.deliveredToInbox).toBe(true);
|
||||||
expect(result.messageId).toBeDefined();
|
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];
|
const [teamName, req] = inboxWriter.sendMessage.mock.calls[0];
|
||||||
expect(teamName).toBe('team-b');
|
expect(teamName).toBe('team-b');
|
||||||
expect(req.member).toBe('team-lead');
|
expect(req.member).toBe('team-lead');
|
||||||
|
|
@ -118,33 +119,24 @@ describe('CrossTeamService', () => {
|
||||||
const [, req] = inboxWriter.sendMessage.mock.calls[0];
|
const [, req] = inboxWriter.sendMessage.mock.calls[0];
|
||||||
expect(req.text).toContain('TURN ACTION MODE: ASK');
|
expect(req.text).toContain('TURN ACTION MODE: ASK');
|
||||||
expect(req.text).toContain('STRICTLY read-only conversation mode');
|
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());
|
await service.send(makeRequest());
|
||||||
|
|
||||||
// Wait for the best-effort sender copy (void promise)
|
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [senderTeam, senderReq] = inboxWriter.sendMessage.mock.calls[1];
|
const sentMessagesPath = `${MOCK_TEAMS_BASE_PATH}/teams/team-a/sentMessages.json`;
|
||||||
expect(senderTeam).toBe('team-a');
|
const raw = fs.readFileSync(sentMessagesPath, 'utf8');
|
||||||
expect(senderReq.from).toBe('user');
|
const sentRows = JSON.parse(raw) as Array<Record<string, unknown>>;
|
||||||
expect(senderReq.source).toBe(CROSS_TEAM_SENT_SOURCE);
|
expect(sentRows).toHaveLength(1);
|
||||||
expect(senderReq.to).toBe('team-b.team-lead');
|
expect(sentRows[0]?.from).toBe('lead');
|
||||||
expect(senderReq.text).toBe('Hello from team-a');
|
expect(sentRows[0]?.source).toBe(CROSS_TEAM_SENT_SOURCE);
|
||||||
expect(senderReq.messageId).toBeDefined();
|
expect(sentRows[0]?.to).toBe('team-b.team-lead');
|
||||||
expect(senderReq.timestamp).toBeDefined();
|
expect(sentRows[0]?.text).toBe('Hello from team-a');
|
||||||
expect(senderReq.messageId).toBe(inboxWriter.sendMessage.mock.calls[0][1].messageId);
|
expect(sentRows[0]?.messageId).toBe(inboxWriter.sendMessage.mock.calls[0][1].messageId);
|
||||||
expect(senderReq.timestamp).toBe(inboxWriter.sendMessage.mock.calls[0][1].timestamp);
|
expect(sentRows[0]?.timestamp).toBe(inboxWriter.sendMessage.mock.calls[0][1].timestamp);
|
||||||
expect(senderReq.conversationId).toBeTruthy();
|
expect(sentRows[0]?.conversationId).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reuses replyToConversationId as the conversationId for replies', async () => {
|
it('reuses replyToConversationId as the conversationId for replies', async () => {
|
||||||
|
|
@ -216,10 +208,11 @@ describe('CrossTeamService', () => {
|
||||||
expect(order).toEqual([
|
expect(order).toEqual([
|
||||||
'register:team-a->team-b',
|
'register:team-a->team-b',
|
||||||
'write:team-b',
|
'write:team-b',
|
||||||
'write:team-a',
|
|
||||||
'clear:team-a->team-b',
|
'clear:team-a->team-b',
|
||||||
'relay: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 () => {
|
it('does not relay when team is offline', async () => {
|
||||||
|
|
@ -325,7 +318,7 @@ describe('CrossTeamService', () => {
|
||||||
|
|
||||||
expect(second.deduplicated).toBe(true);
|
expect(second.deduplicated).toBe(true);
|
||||||
expect(second.messageId).toBe(first.messageId);
|
expect(second.messageId).toBe(first.messageId);
|
||||||
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(2);
|
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
40
test/main/services/team/TeamMcpConfigBuilder.test.ts
Normal file
40
test/main/services/team/TeamMcpConfigBuilder.test.ts
Normal 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`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -129,6 +129,50 @@ describe('TeamMemberResolver', () => {
|
||||||
expect(names).not.toContain('cross-team-team-alpha-super');
|
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', () => {
|
it('keeps dotted names when config casing differs from inbox casing', () => {
|
||||||
const resolver = new TeamMemberResolver();
|
const resolver = new TeamMemberResolver();
|
||||||
const config: TeamConfig = {
|
const config: TeamConfig = {
|
||||||
|
|
|
||||||
|
|
@ -468,6 +468,7 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
||||||
|
|
||||||
const live = service.getLiveLeadProcessMessages('my-team');
|
const live = service.getLiveLeadProcessMessages('my-team');
|
||||||
expect(live).toHaveLength(1);
|
expect(live).toHaveLength(1);
|
||||||
|
expect(live[0].from).toBe('team-lead');
|
||||||
expect(live[0].source).toBe('cross_team_sent');
|
expect(live[0].source).toBe('cross_team_sent');
|
||||||
expect(live[0].to).toBe('team-best.user');
|
expect(live[0].to).toBe('team-best.user');
|
||||||
expect(live[0].text).toBe('Привет!');
|
expect(live[0].text).toBe('Привет!');
|
||||||
|
|
@ -513,11 +514,151 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
||||||
|
|
||||||
const live = service.getLiveLeadProcessMessages('my-team');
|
const live = service.getLiveLeadProcessMessages('my-team');
|
||||||
expect(live).toHaveLength(1);
|
expect(live).toHaveLength(1);
|
||||||
|
expect(live[0].from).toBe('team-lead');
|
||||||
expect(live[0].source).toBe('cross_team_sent');
|
expect(live[0].source).toBe('cross_team_sent');
|
||||||
expect(live[0].to).toBe('cross-team:team-best');
|
expect(live[0].to).toBe('cross-team:team-best');
|
||||||
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
|
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 () => {
|
it('strips canonical cross-team tag from outbound cross-team content', async () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
seedConfig('my-team');
|
seedConfig('my-team');
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,44 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
expect(service.resolveCrossTeamReplyMetadata(teamName, 'other-team')).toBeNull();
|
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 () => {
|
it('does not relay cross-team sender copies back into the live lead', async () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
|
|
@ -538,4 +576,46 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
expect(relayed).toBe(0);
|
expect(relayed).toBe(0);
|
||||||
expect(writeSpy).toHaveBeenCalledTimes(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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,19 @@ describe('getTeamColorSet', () => {
|
||||||
expect(result.text).toBe('#ff5500');
|
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');
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue