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),
|
||||
fromMember: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
conversationId: z.string().optional(),
|
||||
replyToConversationId: z.string().optional(),
|
||||
chainDepth: z.number().int().nonnegative().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, toTeam, text, fromMember, summary, chainDepth }) =>
|
||||
execute: async ({
|
||||
teamName,
|
||||
claudeDir,
|
||||
toTeam,
|
||||
text,
|
||||
fromMember,
|
||||
summary,
|
||||
conversationId,
|
||||
replyToConversationId,
|
||||
chainDepth,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
|
||||
|
|
@ -30,6 +42,8 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
text,
|
||||
...(fromMember ? { fromMember } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(conversationId ? { conversationId } : {}),
|
||||
...(replyToConversationId ? { replyToConversationId } : {}),
|
||||
...(chainDepth !== undefined ? { chainDepth } : {}),
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,16 @@ async function resolveNodePath(): Promise<string> {
|
|||
}
|
||||
|
||||
async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
||||
const sourceEntry = getSourceServerEntry();
|
||||
if (await pathExists(sourceEntry)) {
|
||||
// Prefer source in workspace/dev runs so newly added MCP tools are available
|
||||
// immediately and we do not accidentally serve a stale built dist bundle.
|
||||
return {
|
||||
command: 'pnpm',
|
||||
args: ['--dir', getMcpServerDir(), 'exec', 'tsx', sourceEntry],
|
||||
};
|
||||
}
|
||||
|
||||
const builtEntry = getBuiltServerEntry();
|
||||
if (await pathExists(builtEntry)) {
|
||||
return {
|
||||
|
|
@ -80,14 +90,6 @@ async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
|||
};
|
||||
}
|
||||
|
||||
const sourceEntry = getSourceServerEntry();
|
||||
if (await pathExists(sourceEntry)) {
|
||||
return {
|
||||
command: 'pnpm',
|
||||
args: ['--dir', getMcpServerDir(), 'exec', 'tsx', sourceEntry],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('agent-teams-mcp entrypoint not found in mcp-server package');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -95,6 +95,11 @@ const TASK_WAIT_FALLBACK_MS = 15_000;
|
|||
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
|
||||
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
||||
'cross_team_send',
|
||||
'cross_team_list_targets',
|
||||
'cross_team_get_outbox',
|
||||
]);
|
||||
const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.';
|
||||
const PREFLIGHT_PING_ARGS = [
|
||||
'-p',
|
||||
|
|
@ -592,7 +597,7 @@ Communication protocol (CRITICAL — you are running headless, no one sees your
|
|||
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
|
||||
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
|
||||
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
||||
- Cross-team communication: when work needs expertise, coordination, review, or a decision from ANOTHER team, use MCP tool "cross_team_send" with teamName: "${teamName}" and a focused actionable message.
|
||||
- Cross-team communication: when work needs expertise, coordination, review, or a decision from ANOTHER team, CALL the MCP tool named "cross_team_send" with teamName: "${teamName}" and a focused actionable message.
|
||||
- Before sending cross-team, use MCP tool "cross_team_list_targets" with teamName: "${teamName}" to discover valid target teams.
|
||||
- To review messages your team already sent to other teams, use MCP tool "cross_team_get_outbox" with teamName: "${teamName}".
|
||||
- Cross-team delivery goes to the target team's lead inbox and may be relayed to that live lead automatically.
|
||||
|
|
@ -600,9 +605,12 @@ Communication protocol (CRITICAL — you are running headless, no one sees your
|
|||
- Prefer concise messages that state: what you need, why that team is relevant, the expected response, and any task or file references they need.
|
||||
- Keep cross-team requests high-signal: one focused request per topic, with clear next action and desired outcome.
|
||||
- Before sending a follow-up on the same topic, check "cross_team_get_outbox" so you do not resend the same request unnecessarily.
|
||||
- If you receive a message that is clearly from another team (for example prefixed with "<${CROSS_TEAM_PREFIX_TAG} ... />"), treat it as an actionable cross-team request and respond to the originating team with "cross_team_send" when a reply, decision, or status update is needed.
|
||||
- If you receive a message that is clearly from another team (for example prefixed with "<${CROSS_TEAM_PREFIX_TAG} ... />"), treat it as an actionable cross-team request and respond to the originating team by CALLING the MCP tool "cross_team_send" when a reply, decision, or status update is needed.
|
||||
- Cross-team requests may include a stable conversationId in their metadata. When you reply to that thread, preserve the same conversationId and pass replyToConversationId with that same value so the system can correlate the reply reliably.
|
||||
- If the relay prompt shows explicit cross-team reply metadata/instructions for a message, follow that metadata exactly when calling "cross_team_send".
|
||||
- NEVER put "cross_team_send" into a SendMessage recipient or message_send "to" field. "cross_team_send" is a TOOL NAME, not a teammate or inbox name.
|
||||
- Correct example:
|
||||
cross_team_send({ teamName: "${teamName}", toTeam: "other-team", text: "your reply", conversationId: "<same-id>", replyToConversationId: "<same-id>" })
|
||||
- Never write protocol markup yourself in message text. Do NOT include "<${CROSS_TEAM_PREFIX_TAG} ... />" or any other metadata wrapper in the visible reply body; send plain user-visible text only.
|
||||
- When a cross-team request arrives, do NOT appear silent: first emit a brief plain-text status update visible in your own team's Messages/Activity (for example: "Accepted cross-team request from @other-team. Investigating and delegating now."), then do the research, task creation, or delegation work.
|
||||
- For cross-team work, your canonical progress trail should be team-visible first. Use plain text updates, task comments, and task state changes so your own team can see what is happening.
|
||||
|
|
@ -1249,12 +1257,12 @@ export class TeamProvisioningService {
|
|||
): { teamName: string; memberName: string } | null {
|
||||
const trimmed = recipient.trim();
|
||||
if (localRecipientNames.has(trimmed)) return null;
|
||||
if (trimmed.startsWith('cross-team:')) {
|
||||
const teamName = trimmed.slice('cross-team:'.length).trim();
|
||||
if (!TEAM_NAME_PATTERN.test(teamName) || teamName === currentTeam) {
|
||||
const pseudoTeamName = this.extractCrossTeamPseudoTargetTeam(trimmed);
|
||||
if (pseudoTeamName) {
|
||||
if (pseudoTeamName === currentTeam) {
|
||||
return null;
|
||||
}
|
||||
return { teamName, memberName: 'team-lead' };
|
||||
return { teamName: pseudoTeamName, memberName: 'team-lead' };
|
||||
}
|
||||
const dot = trimmed.indexOf('.');
|
||||
if (dot <= 0 || dot === trimmed.length - 1) return null;
|
||||
|
|
@ -1266,17 +1274,46 @@ export class TeamProvisioningService {
|
|||
return { teamName, memberName };
|
||||
}
|
||||
|
||||
private extractCrossTeamPseudoTargetTeam(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
const prefixes = [
|
||||
'cross_team::',
|
||||
'cross_team--',
|
||||
'cross-team:',
|
||||
'cross-team-',
|
||||
'cross_team:',
|
||||
'cross_team-',
|
||||
];
|
||||
for (const prefix of prefixes) {
|
||||
if (!trimmed.startsWith(prefix)) continue;
|
||||
const teamName = trimmed.slice(prefix.length).trim();
|
||||
if (TEAM_NAME_PATTERN.test(teamName)) {
|
||||
return teamName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private isCrossTeamToolRecipientName(name: string): boolean {
|
||||
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim());
|
||||
}
|
||||
|
||||
private isCrossTeamPseudoRecipientName(name: string): boolean {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.startsWith('cross-team:')) {
|
||||
const teamName = trimmed.slice('cross-team:'.length).trim();
|
||||
return TEAM_NAME_PATTERN.test(teamName);
|
||||
return this.extractCrossTeamPseudoTargetTeam(name) !== null;
|
||||
}
|
||||
|
||||
private resolveSingleActiveCrossTeamReplyHint(
|
||||
run: ProvisioningRun
|
||||
): { toTeam: string; conversationId: string } | null {
|
||||
const uniqueHints = new Map<string, { toTeam: string; conversationId: string }>();
|
||||
for (const hint of run.activeCrossTeamReplyHints ?? []) {
|
||||
const toTeam = typeof hint?.toTeam === 'string' ? hint.toTeam.trim() : '';
|
||||
const conversationId =
|
||||
typeof hint?.conversationId === 'string' ? hint.conversationId.trim() : '';
|
||||
if (!toTeam || !conversationId) continue;
|
||||
uniqueHints.set(`${toTeam}\0${conversationId}`, { toTeam, conversationId });
|
||||
}
|
||||
if (trimmed.startsWith('cross-team-')) {
|
||||
const teamName = trimmed.slice('cross-team-'.length).trim();
|
||||
return TEAM_NAME_PATTERN.test(teamName);
|
||||
}
|
||||
return false;
|
||||
return uniqueHints.size === 1 ? (Array.from(uniqueHints.values())[0] ?? null) : null;
|
||||
}
|
||||
|
||||
private looksLikeQualifiedExternalRecipientName(name: string): boolean {
|
||||
|
|
@ -2725,7 +2762,10 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
async relayMemberInboxMessages(teamName: string, memberName: string): Promise<number> {
|
||||
if (this.isCrossTeamPseudoRecipientName(memberName)) {
|
||||
if (
|
||||
this.isCrossTeamPseudoRecipientName(memberName) ||
|
||||
this.isCrossTeamToolRecipientName(memberName)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
const relayKey = this.getMemberRelayKey(teamName, memberName);
|
||||
|
|
@ -2806,7 +2846,7 @@ export class TeamProvisioningService {
|
|||
crossTeamMeta?.sourceTeam && conversationId
|
||||
? [
|
||||
` Cross-team conversationId: ${conversationId}`,
|
||||
` If replying with cross_team_send to ${crossTeamMeta.sourceTeam}, set conversationId="${conversationId}" and replyToConversationId="${conversationId}".`,
|
||||
` Call the MCP tool named cross_team_send with toTeam="${crossTeamMeta.sourceTeam}", conversationId="${conversationId}", and replyToConversationId="${conversationId}". Do NOT put "cross_team_send" into a SendMessage recipient or message_send "to" field.`,
|
||||
]
|
||||
: [];
|
||||
return [
|
||||
|
|
@ -3021,15 +3061,39 @@ export class TeamProvisioningService {
|
|||
`IMPORTANT: Your text response here is shown to the user. Always include a brief human-readable summary (e.g. "Delegated to carol." or "No action needed."). Do NOT respond with only an agent-only block.`,
|
||||
AGENT_BLOCK_OPEN,
|
||||
`Internal note: for task assignments, prefer task_create and rely on the board/runtime notification path instead of sending a separate SendMessage for the same assignment.`,
|
||||
`If a message below is marked Source: cross_team, CALL the MCP tool named cross_team_send. Do NOT use SendMessage or message_send for cross-team replies.`,
|
||||
`NEVER set recipient="cross_team_send" or to="cross_team_send". "cross_team_send" is a tool name, not a teammate.`,
|
||||
AGENT_BLOCK_CLOSE,
|
||||
``,
|
||||
`Messages:`,
|
||||
...batch.flatMap((m, idx) => {
|
||||
const summaryLine = m.summary?.trim() ? `Summary: ${m.summary.trim()}` : null;
|
||||
const crossTeamMeta =
|
||||
m.source === 'cross_team'
|
||||
? {
|
||||
origin: parseCrossTeamPrefix(m.text),
|
||||
sourceTeam: m.from.includes('.') ? m.from.split('.', 1)[0] : null,
|
||||
}
|
||||
: null;
|
||||
const conversationId =
|
||||
m.replyToConversationId?.trim() ??
|
||||
m.conversationId ??
|
||||
crossTeamMeta?.origin?.conversationId;
|
||||
const replyInstructions =
|
||||
crossTeamMeta?.sourceTeam && conversationId
|
||||
? [
|
||||
` Cross-team conversationId: ${conversationId}`,
|
||||
` Call the MCP tool named cross_team_send with toTeam="${crossTeamMeta.sourceTeam}", conversationId="${conversationId}", and replyToConversationId="${conversationId}". Do NOT use SendMessage or message_send. NEVER set recipient/to to "cross_team_send".`,
|
||||
]
|
||||
: [];
|
||||
return [
|
||||
`${idx + 1}) From: ${m.from || 'unknown'}`,
|
||||
` Timestamp: ${m.timestamp}`,
|
||||
...(summaryLine ? [` ${summaryLine}`] : []),
|
||||
...(typeof m.source === 'string' && m.source.trim()
|
||||
? [` Source: ${m.source.trim()}`]
|
||||
: []),
|
||||
...replyInstructions,
|
||||
` Text:`,
|
||||
...m.text.split('\n').map((line) => ` ${line}`),
|
||||
``,
|
||||
|
|
@ -3334,15 +3398,30 @@ export class TeamProvisioningService {
|
|||
|
||||
private captureSendMessages(run: ProvisioningRun, content: Record<string, unknown>[]): void {
|
||||
for (const part of content) {
|
||||
if (part.type !== 'tool_use' || part.name !== 'SendMessage') continue;
|
||||
if (part.type !== 'tool_use' || typeof part.name !== 'string') continue;
|
||||
const isNativeSendMessage = part.name === 'SendMessage';
|
||||
const isTeamMessageSendTool = part.name === 'mcp__agent-teams__message_send';
|
||||
if (!isNativeSendMessage && !isTeamMessageSendTool) continue;
|
||||
const input = part.input;
|
||||
if (!input || typeof input !== 'object') continue;
|
||||
const inp = input as Record<string, unknown>;
|
||||
|
||||
const recipient = typeof inp.recipient === 'string' ? inp.recipient : '';
|
||||
const recipient = isNativeSendMessage
|
||||
? typeof inp.recipient === 'string'
|
||||
? inp.recipient
|
||||
: ''
|
||||
: typeof inp.to === 'string'
|
||||
? inp.to
|
||||
: '';
|
||||
if (!recipient.trim()) continue;
|
||||
|
||||
const msgContent = typeof inp.content === 'string' ? inp.content : '';
|
||||
const msgContent = isNativeSendMessage
|
||||
? typeof inp.content === 'string'
|
||||
? inp.content
|
||||
: ''
|
||||
: typeof inp.text === 'string'
|
||||
? inp.text
|
||||
: '';
|
||||
if (msgContent.trim().length === 0) continue;
|
||||
|
||||
const summary = typeof inp.summary === 'string' ? inp.summary : '';
|
||||
|
|
@ -3362,16 +3441,20 @@ export class TeamProvisioningService {
|
|||
localRecipientNames.add('user');
|
||||
localRecipientNames.add('team-lead');
|
||||
|
||||
const crossTeamRecipient = this.parseCrossTeamRecipient(
|
||||
run.teamName,
|
||||
recipient,
|
||||
localRecipientNames
|
||||
);
|
||||
const mistakenToolHint = this.isCrossTeamToolRecipientName(recipient)
|
||||
? this.resolveSingleActiveCrossTeamReplyHint(run)
|
||||
: null;
|
||||
const crossTeamRecipient =
|
||||
this.parseCrossTeamRecipient(run.teamName, recipient, localRecipientNames) ??
|
||||
(mistakenToolHint ? { teamName: mistakenToolHint.toTeam, memberName: 'team-lead' } : null);
|
||||
if (crossTeamRecipient && this.crossTeamSender) {
|
||||
const inferredReplyMeta = this.resolveCrossTeamReplyMetadata(
|
||||
run.teamName,
|
||||
crossTeamRecipient.teamName
|
||||
);
|
||||
const inferredReplyMeta =
|
||||
mistakenToolHint && mistakenToolHint.toTeam === crossTeamRecipient.teamName
|
||||
? {
|
||||
conversationId: mistakenToolHint.conversationId,
|
||||
replyToConversationId: mistakenToolHint.conversationId,
|
||||
}
|
||||
: this.resolveCrossTeamReplyMetadata(run.teamName, crossTeamRecipient.teamName);
|
||||
const crossTeamMeta = parseCrossTeamPrefix(cleanContent);
|
||||
const replyMeta = inferredReplyMeta;
|
||||
const timestamp = nowIso();
|
||||
|
|
@ -3396,10 +3479,12 @@ export class TeamProvisioningService {
|
|||
return;
|
||||
}
|
||||
const msg: InboxMessage = {
|
||||
from: 'user',
|
||||
from: leadName,
|
||||
to: recipient.startsWith('cross-team:')
|
||||
? recipient
|
||||
: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`,
|
||||
: this.isCrossTeamToolRecipientName(recipient)
|
||||
? `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`
|
||||
: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`,
|
||||
text: strippedCrossTeamContent,
|
||||
timestamp,
|
||||
read: true,
|
||||
|
|
@ -3432,6 +3517,14 @@ export class TeamProvisioningService {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (this.isCrossTeamToolRecipientName(recipient)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isNativeSendMessage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const msg: InboxMessage = {
|
||||
from: leadName,
|
||||
to: recipient,
|
||||
|
|
@ -4453,7 +4546,12 @@ export class TeamProvisioningService {
|
|||
this.stopFilesystemMonitor(run);
|
||||
|
||||
if (run.isLaunch) {
|
||||
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
|
||||
await this.updateConfigPostLaunch(
|
||||
run.teamName,
|
||||
run.request.cwd,
|
||||
run.detectedSessionId,
|
||||
run.request.color
|
||||
);
|
||||
await this.cleanupPrelaunchBackup(run.teamName);
|
||||
|
||||
// Defense in depth: if the CLI (or a stale config) produced auto-suffixed members (alice-2),
|
||||
|
|
@ -4570,7 +4668,12 @@ export class TeamProvisioningService {
|
|||
|
||||
// Persist teammates metadata separately from config.json.
|
||||
await this.persistMembersMeta(run.teamName, run.request);
|
||||
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
|
||||
await this.updateConfigPostLaunch(
|
||||
run.teamName,
|
||||
run.request.cwd,
|
||||
run.detectedSessionId,
|
||||
run.request.color
|
||||
);
|
||||
|
||||
const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', {
|
||||
cliLogsTail: extractCliLogsFromRun(run),
|
||||
|
|
@ -5015,6 +5118,13 @@ export class TeamProvisioningService {
|
|||
if (!run.isLaunch) {
|
||||
await this.persistMembersMeta(run.teamName, run.request);
|
||||
}
|
||||
// Persist team color even on timeout path
|
||||
await this.updateConfigPostLaunch(
|
||||
run.teamName,
|
||||
run.request.cwd,
|
||||
run.detectedSessionId,
|
||||
run.request.color
|
||||
);
|
||||
// Process was killed by timeout — mark as disconnected, not ready
|
||||
const progress = updateProgress(run, 'disconnected', 'Team provisioned but process timed out', {
|
||||
warnings,
|
||||
|
|
@ -5159,7 +5269,8 @@ export class TeamProvisioningService {
|
|||
private async updateConfigPostLaunch(
|
||||
teamName: string,
|
||||
projectPath: string,
|
||||
detectedSessionId: string | null
|
||||
detectedSessionId: string | null,
|
||||
color?: string
|
||||
): Promise<void> {
|
||||
const MAX_SESSION_HISTORY = 5000;
|
||||
const MAX_PROJECT_PATH_HISTORY = 500;
|
||||
|
|
@ -5216,6 +5327,11 @@ export class TeamProvisioningService {
|
|||
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
|
||||
config.language = langCode;
|
||||
|
||||
// Persist team color chosen by the user during creation
|
||||
if (color && color.trim().length > 0) {
|
||||
config.color = color.trim();
|
||||
}
|
||||
|
||||
// Ensure projectPath
|
||||
if (projectPath.trim()) {
|
||||
config.projectPath = projectPath;
|
||||
|
|
@ -5817,6 +5933,7 @@ export class TeamProvisioningService {
|
|||
const inboxNames = allInboxNames
|
||||
.filter((name) => name !== 'team-lead' && name !== 'user')
|
||||
.filter((name) => !this.isCrossTeamPseudoRecipientName(name))
|
||||
.filter((name) => !this.isCrossTeamToolRecipientName(name))
|
||||
.filter((name) => !this.looksLikeQualifiedExternalRecipientName(name))
|
||||
.filter((name) => {
|
||||
const match = /^(.+)-(\d+)$/.exec(name);
|
||||
|
|
|
|||
|
|
@ -912,7 +912,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
);
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
if ((loading && !data) || (data && data.teamName !== teamName)) {
|
||||
return (
|
||||
<div className="size-full overflow-auto p-4">
|
||||
<div className="mb-4 h-10 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
|
||||
|
|
|
|||
|
|
@ -97,7 +97,9 @@ export const AnimatedHeightReveal = ({
|
|||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ minHeight: 0, overflow: isExpanded ? 'visible' : 'hidden' }}>{children}</div>
|
||||
<div style={{ minHeight: 0, minWidth: 0, overflow: isExpanded ? 'visible' : 'hidden' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type { CrossTeamSendRequest, TeamConfig } from '@shared/types';
|
|||
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getTeamsBasePath: () => '/tmp/cross-team-test-nonexistent-dir-' + process.pid,
|
||||
getClaudeBasePath: () => '/tmp/cross-team-test-nonexistent-dir-' + process.pid,
|
||||
}));
|
||||
|
||||
const MOCK_TEAMS_BASE_PATH = '/tmp/cross-team-test-nonexistent-dir-' + process.pid;
|
||||
|
|
@ -99,7 +100,7 @@ describe('CrossTeamService', () => {
|
|||
expect(result.deliveredToInbox).toBe(true);
|
||||
expect(result.messageId).toBeDefined();
|
||||
|
||||
// First call: target team inbox, second call: sender copy (best-effort)
|
||||
// Target team delivery goes through inboxWriter.
|
||||
const [teamName, req] = inboxWriter.sendMessage.mock.calls[0];
|
||||
expect(teamName).toBe('team-b');
|
||||
expect(req.member).toBe('team-lead');
|
||||
|
|
@ -118,33 +119,24 @@ describe('CrossTeamService', () => {
|
|||
const [, req] = inboxWriter.sendMessage.mock.calls[0];
|
||||
expect(req.text).toContain('TURN ACTION MODE: ASK');
|
||||
expect(req.text).toContain('STRICTLY read-only conversation mode');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
const [, senderReq] = inboxWriter.sendMessage.mock.calls[1];
|
||||
expect(senderReq.text).toBe('Can you inspect this?');
|
||||
});
|
||||
|
||||
it('writes sender copy to fromTeam inbox as user_sent', async () => {
|
||||
it('writes sender copy to sentMessages.json without touching the lead inbox', async () => {
|
||||
await service.send(makeRequest());
|
||||
|
||||
// Wait for the best-effort sender copy (void promise)
|
||||
await vi.waitFor(() => {
|
||||
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [senderTeam, senderReq] = inboxWriter.sendMessage.mock.calls[1];
|
||||
expect(senderTeam).toBe('team-a');
|
||||
expect(senderReq.from).toBe('user');
|
||||
expect(senderReq.source).toBe(CROSS_TEAM_SENT_SOURCE);
|
||||
expect(senderReq.to).toBe('team-b.team-lead');
|
||||
expect(senderReq.text).toBe('Hello from team-a');
|
||||
expect(senderReq.messageId).toBeDefined();
|
||||
expect(senderReq.timestamp).toBeDefined();
|
||||
expect(senderReq.messageId).toBe(inboxWriter.sendMessage.mock.calls[0][1].messageId);
|
||||
expect(senderReq.timestamp).toBe(inboxWriter.sendMessage.mock.calls[0][1].timestamp);
|
||||
expect(senderReq.conversationId).toBeTruthy();
|
||||
const sentMessagesPath = `${MOCK_TEAMS_BASE_PATH}/teams/team-a/sentMessages.json`;
|
||||
const raw = fs.readFileSync(sentMessagesPath, 'utf8');
|
||||
const sentRows = JSON.parse(raw) as Array<Record<string, unknown>>;
|
||||
expect(sentRows).toHaveLength(1);
|
||||
expect(sentRows[0]?.from).toBe('lead');
|
||||
expect(sentRows[0]?.source).toBe(CROSS_TEAM_SENT_SOURCE);
|
||||
expect(sentRows[0]?.to).toBe('team-b.team-lead');
|
||||
expect(sentRows[0]?.text).toBe('Hello from team-a');
|
||||
expect(sentRows[0]?.messageId).toBe(inboxWriter.sendMessage.mock.calls[0][1].messageId);
|
||||
expect(sentRows[0]?.timestamp).toBe(inboxWriter.sendMessage.mock.calls[0][1].timestamp);
|
||||
expect(sentRows[0]?.conversationId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reuses replyToConversationId as the conversationId for replies', async () => {
|
||||
|
|
@ -216,10 +208,11 @@ describe('CrossTeamService', () => {
|
|||
expect(order).toEqual([
|
||||
'register:team-a->team-b',
|
||||
'write:team-b',
|
||||
'write:team-a',
|
||||
'clear:team-a->team-b',
|
||||
'relay:team-b',
|
||||
]);
|
||||
const sentMessagesPath = `${MOCK_TEAMS_BASE_PATH}/teams/team-a/sentMessages.json`;
|
||||
expect(fs.existsSync(sentMessagesPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not relay when team is offline', async () => {
|
||||
|
|
@ -325,7 +318,7 @@ describe('CrossTeamService', () => {
|
|||
|
||||
expect(second.deduplicated).toBe(true);
|
||||
expect(second.messageId).toBe(first.messageId);
|
||||
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
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');
|
||||
});
|
||||
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -339,6 +339,44 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
expect(service.resolveCrossTeamReplyMetadata(teamName, 'other-team')).toBeNull();
|
||||
});
|
||||
|
||||
it('includes explicit cross-team reply instructions in lead relay prompts', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
seedLeadInbox(teamName, [
|
||||
{
|
||||
from: 'other-team.team-lead',
|
||||
to: 'team-lead',
|
||||
text: '<cross-team from="other-team.team-lead" depth="0" conversationId="conv-explicit" />\nNeed your answer.',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
source: 'cross_team',
|
||||
messageId: 'm-cross-team-explicit',
|
||||
conversationId: 'conv-explicit',
|
||||
},
|
||||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
const relayPromise = service.relayLeadInboxMessages(teamName);
|
||||
const run = await waitForCapture(service);
|
||||
expect(run?.leadRelayCapture).toBeTruthy();
|
||||
|
||||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||
expect(payload).toContain('Source: cross_team');
|
||||
expect(payload).toContain('Cross-team conversationId: conv-explicit');
|
||||
expect(payload).toContain('Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"');
|
||||
expect(payload).toContain('replyToConversationId=\\"conv-explicit\\"');
|
||||
expect(payload).toContain('NEVER set recipient/to to \\"cross_team_send\\"');
|
||||
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Replying properly.' }],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
|
||||
await relayPromise;
|
||||
});
|
||||
|
||||
it('does not relay cross-team sender copies back into the live lead', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
@ -538,4 +576,46 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
expect(relayed).toBe(0);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('does not relay tool-like cross-team inbox names as teammates', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
seedMemberInbox(teamName, 'cross_team_send', [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Wrongly routed tool recipient inbox',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'm-tool-recipient-1',
|
||||
},
|
||||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
const relayed = await service.relayMemberInboxMessages(teamName, 'cross_team_send');
|
||||
|
||||
expect(relayed).toBe(0);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('does not relay malformed underscore-style pseudo cross-team inbox names as teammates', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
seedMemberInbox(teamName, 'cross_team::team-best', [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Wrongly routed underscore pseudo inbox',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'm-underscore-pseudo-1',
|
||||
},
|
||||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
const relayed = await service.relayMemberInboxMessages(teamName, 'cross_team::team-best');
|
||||
|
||||
expect(relayed).toBe(0);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue