agent-ecosystem/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts
iliya d923661127 fix(tests): update test expectations to match current implementation
- Add missing AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES to
  agent-teams-controller mocks in provisioning tests
- Fix ChangeExtractorService test: pass createdAt to match actual signature
- Fix TeamGraphAdapter particle label expectations (max=52 truncation)
2026-03-31 01:08:43 +03:00

966 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const files = new Map<string, string>();
const norm = (p: string): string => p.replace(/\\/g, '/');
const stat = vi.fn(async (filePath: string) => {
const data = files.get(norm(filePath));
if (data === undefined) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return { isFile: () => true, size: Buffer.byteLength(data, 'utf8') };
});
const readFile = vi.fn(async (filePath: string) => {
const data = files.get(norm(filePath));
if (data === undefined) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return data;
});
const atomicWrite = vi.fn(async (filePath: string, data: string) => {
files.set(norm(filePath), data);
});
return {
files,
stat,
readFile,
atomicWrite,
appendSentMessage: vi.fn((teamName: string, message: Record<string, unknown>) => {
const p = `/mock/teams/${teamName}/sentMessages.json`;
const current = files.get(p);
const rows = current ? (JSON.parse(current) as unknown[]) : [];
rows.push(message);
files.set(p, JSON.stringify(rows));
return message;
}),
sendInboxMessage: vi.fn(
(teamName: string, message: Record<string, unknown>) => {
const member =
typeof message.member === 'string'
? message.member
: typeof message.to === 'string'
? message.to
: 'unknown';
const p = `/mock/teams/${teamName}/inboxes/${member}.json`;
const current = files.get(p);
const rows = current ? (JSON.parse(current) as unknown[]) : [];
rows.push(message);
files.set(p, JSON.stringify(rows));
return { deliveredToInbox: true, messageId: 'mock-id', message };
}
),
};
});
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
promises: { ...actual.promises, stat: hoisted.stat, readFile: hoisted.readFile },
};
});
vi.mock('../../../../src/main/services/team/atomicWrite', () => ({
atomicWriteAsync: hoisted.atomicWrite,
}));
vi.mock('../../../../src/main/services/team/fileLock', () => ({
withFileLock: async (_filePath: string, fn: () => Promise<unknown>) => await fn(),
}));
vi.mock('../../../../src/main/services/team/inboxLock', () => ({
withInboxLock: async (_filePath: string, fn: () => Promise<unknown>) => await fn(),
}));
vi.mock('../../../../src/main/utils/pathDecoder', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../src/main/utils/pathDecoder')>();
return { ...actual, getTeamsBasePath: () => '/mock/teams' };
});
vi.mock('../../../../src/main/utils/fsRead', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../src/main/utils/fsRead')>();
return {
...actual,
readFileUtf8WithTimeout: hoisted.readFile,
};
});
vi.mock('agent-teams-controller', () => ({
AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: [] as readonly string[],
createController: ({ teamName }: { teamName: string }) => ({
messages: {
appendSentMessage: (message: Record<string, unknown>) =>
hoisted.appendSentMessage(teamName, message),
sendMessage: (message: Record<string, unknown>) =>
hoisted.sendInboxMessage(teamName, message),
},
}),
protocols: {
buildActionModeProtocolText: (delegate: string) =>
`ACTION MODE PROTOCOL (mock, delegate: ${delegate})`,
buildProcessProtocolText: (teamName: string) =>
`BACKGROUND PROCESS REGISTRATION (mock for ${teamName})`,
},
}));
import type { TeamChangeEvent } from '@shared/types/team';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
function seedConfig(teamName: string): void {
hoisted.files.set(
`/mock/teams/${teamName}/config.json`,
JSON.stringify({
name: 'My Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
})
);
}
function seedLeadInbox(teamName: string, messages: unknown[]): void {
hoisted.files.set(`/mock/teams/${teamName}/inboxes/team-lead.json`, JSON.stringify(messages));
}
interface RunLike {
runId: string;
teamName: string;
provisioningComplete: boolean;
leadMsgSeq: number;
pendingToolCalls: { name: string; preview: string }[];
activeToolCalls: Map<string, unknown>;
pendingDirectCrossTeamSendRefresh: boolean;
lastLeadTextEmitMs: number;
leadRelayCapture: null;
silentUserDmForward:
| null
| { target: string; startedAt: string; mode: 'user_dm' | 'member_inbox_relay' };
suppressPostCompactReminderOutput?: boolean;
child: Record<string, unknown> | null;
processKilled: boolean;
cancelRequested: boolean;
provisioningOutputParts: string[];
request: { members: { name: string; role?: string }[] };
activeCrossTeamReplyHints?: Array<{ toTeam: string; conversationId: string }>;
pendingInboxRelayCandidates?: unknown[];
}
/**
* Attach a run to the service internals. `provisioningComplete` defaults to false
* (pre-ready) to test the early message pipeline.
*/
function attachRun(
service: TeamProvisioningService,
teamName: string,
opts?: { provisioningComplete?: boolean }
): RunLike {
const runId = 'run-1';
const run: RunLike = {
runId,
teamName,
provisioningComplete: opts?.provisioningComplete ?? false,
leadMsgSeq: 0,
pendingToolCalls: [],
activeToolCalls: new Map(),
pendingDirectCrossTeamSendRefresh: false,
lastLeadTextEmitMs: 0,
leadRelayCapture: null,
silentUserDmForward: null,
pendingInboxRelayCandidates: [],
child: { stdin: { writable: true, write: vi.fn(), end: vi.fn() } },
processKilled: false,
cancelRequested: false,
provisioningOutputParts: [],
request: { members: [{ name: 'team-lead', role: 'Team Lead' }] },
activeCrossTeamReplyHints: [],
};
(service as unknown as { aliveRunByTeam: Map<string, string> }).aliveRunByTeam.set(
teamName,
runId
);
(service as unknown as { runs: Map<string, unknown> }).runs.set(runId, run);
return run;
}
function callHandleStreamJsonMessage(
service: TeamProvisioningService,
run: RunLike,
msg: Record<string, unknown>
): void {
(service as unknown as { handleStreamJsonMessage: (r: unknown, m: unknown) => void })
.handleStreamJsonMessage(run, msg);
}
describe('TeamProvisioningService pre-ready live messages', () => {
beforeEach(() => {
hoisted.files.clear();
hoisted.appendSentMessage.mockClear();
hoisted.sendInboxMessage.mockClear();
});
it('pre-ready assistant text is added to liveLeadProcessMessages', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const run = attachRun(service, 'my-team', { provisioningComplete: false });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [{ type: 'text', text: 'Команда создана. Запускаю всех тиммейтов параллельно.' }],
});
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].text).toBe('Команда создана. Запускаю всех тиммейтов параллельно.');
expect(live[0].source).toBe('lead_process');
expect(live[0].messageId).toMatch(/^lead-turn-run-1-1$/);
// Also still in provisioningOutputParts for the banner
expect(run.provisioningOutputParts).toHaveLength(1);
});
it('emits lead-message event type (not inbox)', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
service.setTeamChangeEmitter(emitter);
const run = attachRun(service, 'my-team', { provisioningComplete: false });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [{ type: 'text', text: 'Launching teammates now.' }],
});
expect(emitter).toHaveBeenCalledTimes(1);
expect(emitter).toHaveBeenCalledWith(
expect.objectContaining({ type: 'lead-message', teamName: 'my-team' })
);
});
it('coalesces rapid emissions via LEAD_TEXT_EMIT_THROTTLE_MS', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
service.setTeamChangeEmitter(emitter);
const run = attachRun(service, 'my-team', { provisioningComplete: false });
// First message: should emit
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [{ type: 'text', text: 'Message 1' }],
});
expect(emitter).toHaveBeenCalledTimes(1);
// Second message immediately after: should be coalesced (not emitted again)
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [{ type: 'text', text: 'Message 2' }],
});
expect(emitter).toHaveBeenCalledTimes(1); // Still 1
// Messages are still cached though
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(2);
});
it('early live messages carry toolCalls and toolSummary', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const run = attachRun(service, 'my-team', { provisioningComplete: false });
// First: tool_use message (no text)
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'TeamCreate',
input: { team_name: 'super-team', description: 'test' },
},
],
});
// Then: text message — should pick up pending tool calls
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [{ type: 'text', text: 'Team created successfully.' }],
});
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].toolCalls).toBeDefined();
expect(live[0].toolCalls).toHaveLength(1);
expect(live[0].toolCalls![0].name).toBe('TeamCreate');
expect(live[0].toolSummary).toBeDefined();
});
it('provisioning-time SendMessage(to:user) is captured and persisted', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const run = attachRun(service, 'my-team', { provisioningComplete: false });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'user',
content: 'All teammates online!',
summary: 'Team ready',
},
},
],
});
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].to).toBe('user');
expect(live[0].text).toBe('All teammates online!');
expect(live[0].source).toBe('lead_process');
// Also persisted to sentMessages.json
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
});
it('keeps assistant thought text when SendMessage targets a teammate', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{ type: 'text', text: 'Forwarding the clarification request now.' },
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'team-lead',
content: 'Need clarification on #abcd1234',
summary: 'Clarification request',
},
},
],
});
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(2);
expect(live[0].to).toBeUndefined();
expect(live[0].text).toBe('Forwarding the clarification request now.');
expect(live[0].source).toBe('lead_process');
expect(live[1].to).toBe('team-lead');
expect(live[1].text).toBe('Need clarification on #abcd1234');
expect(live[1].source).toBe('lead_process');
// Non-user recipient → delivered to inbox, not sentMessages
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
});
it('suppresses duplicate assistant thought text when SendMessage targets user', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{ type: 'text', text: 'Task completed. Sending the summary now.' },
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'user',
content: 'Task completed successfully.',
summary: 'Done',
},
},
],
});
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].to).toBe('user');
expect(live[0].text).toBe('Task completed successfully.');
expect(live[0].source).toBe('lead_process');
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
});
it('post-ready path also uses the unified helper', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
service.setTeamChangeEmitter(emitter);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [{ type: 'text', text: 'Assigning tasks now.' }],
});
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].source).toBe('lead_process');
// Post-ready also emits lead-message
expect(emitter).toHaveBeenCalledWith(
expect.objectContaining({ type: 'lead-message', teamName: 'my-team' })
);
});
it('SendMessage(to:teammate) creates inbox row and emits inbox detail for recipient', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
service.setTeamChangeEmitter(emitter);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'alice',
content: 'Please check the migration.',
summary: 'Migration check',
},
},
],
});
// Delivered to recipient inbox, not sentMessages
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
expect(hoisted.sendInboxMessage).toHaveBeenCalledWith(
'my-team',
expect.objectContaining({ member: 'alice' })
);
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
// Emits inbox event for the specific recipient
expect(emitter).toHaveBeenCalledWith(
expect.objectContaining({
type: 'inbox',
teamName: 'my-team',
detail: 'inboxes/alice.json',
})
);
});
it('SendMessage(to:user) still persists to sentMessages.json', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
service.setTeamChangeEmitter(emitter);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'user',
content: 'Task completed!',
summary: 'Done',
},
},
],
});
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
expect(emitter).toHaveBeenCalledWith(
expect.objectContaining({
type: 'inbox',
teamName: 'my-team',
detail: 'sentMessages.json',
})
);
});
it('upgrades qualified SendMessage recipients into cross-team sends', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-1' }));
service.setTeamChangeEmitter(emitter);
service.setCrossTeamSender(crossTeamSender);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
run.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-123' }];
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'team-best.user',
content: 'Привет!',
summary: 'Ответ',
},
},
],
});
await vi.waitFor(() => {
expect(crossTeamSender).toHaveBeenCalledTimes(1);
});
expect(crossTeamSender).toHaveBeenCalledWith(
expect.objectContaining({
fromTeam: 'my-team',
fromMember: 'team-lead',
toTeam: 'team-best',
text: 'Привет!',
conversationId: 'conv-123',
replyToConversationId: 'conv-123',
})
);
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].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('Привет!');
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
});
it('upgrades pseudo cross-team recipients into cross-team sends', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-2' }));
service.setCrossTeamSender(crossTeamSender);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'cross-team:team-best',
content: 'Привет команде!',
summary: 'Приветствие',
},
},
],
});
await vi.waitFor(() => {
expect(crossTeamSender).toHaveBeenCalledTimes(1);
});
expect(crossTeamSender).toHaveBeenCalledWith(
expect.objectContaining({
fromTeam: 'my-team',
fromMember: 'team-lead',
toTeam: 'team-best',
text: 'Привет команде!',
})
);
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('refreshes sentMessages history after direct MCP cross_team_send succeeds', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
service.setTeamChangeEmitter(emitter);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'mcp__agent-teams__cross_team_send',
input: {
teamName: 'my-team',
toTeam: 'team-best',
text: 'Прямой вызов MCP.',
summary: 'Direct MCP send',
},
},
],
});
expect(run.pendingDirectCrossTeamSendRefresh).toBe(true);
callHandleStreamJsonMessage(service, run, {
type: 'result',
subtype: 'success',
});
expect(run.pendingDirectCrossTeamSendRefresh).toBe(false);
expect(emitter).toHaveBeenCalledWith(
expect.objectContaining({
type: 'inbox',
teamName: 'my-team',
detail: 'sentMessages.json',
})
);
});
it('marks native cross-team teammate-message deliveries as read and restores reply hints', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
seedLeadInbox('my-team', [
{
from: 'other-team.team-lead',
to: 'team-lead',
text: '<cross-team from="other-team.team-lead" depth="0" conversationId="conv-native-1" replyToConversationId="conv-native-1" />\nНативная доставка.',
timestamp: '2026-02-23T10:01:00.000Z',
read: false,
source: 'cross_team',
messageId: 'm-native-cross-team-1',
conversationId: 'conv-native-1',
replyToConversationId: 'conv-native-1',
},
]);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'user',
message: {
role: 'user',
content:
'<teammate-message teammate_id="other-team.team-lead" color="purple" summary="Cross-team reply"><cross-team from="other-team.team-lead" depth="0" conversationId="conv-native-1" replyToConversationId="conv-native-1" />\nНативная доставка.</teammate-message>',
},
});
await vi.waitFor(() => {
const updatedInbox = JSON.parse(
hoisted.files.get('/mock/teams/my-team/inboxes/team-lead.json') ?? '[]'
) as Array<{ read?: boolean }>;
expect(updatedInbox[0]?.read).toBe(true);
});
expect(run.activeCrossTeamReplyHints).toEqual([
{ toTeam: 'other-team', conversationId: 'conv-native-1' },
]);
});
it('suppresses native duplicate cross-team teammate-message after recent relay delivery', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const content =
'<cross-team from="other-team.team-lead" depth="0" conversationId="conv-native-dup" replyToConversationId="conv-native-dup" />\nПовторная доставка.';
seedLeadInbox('my-team', [
{
from: 'other-team.team-lead',
to: 'team-lead',
text: content,
timestamp: '2026-03-10T21:43:00.000Z',
read: false,
source: 'cross_team',
messageId: 'm-native-cross-team-dup',
conversationId: 'conv-native-dup',
replyToConversationId: 'conv-native-dup',
},
]);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
(service as any).rememberRecentCrossTeamLeadDeliveryMessageIds('my-team', [
'm-native-cross-team-dup',
]);
callHandleStreamJsonMessage(service, run, {
type: 'user',
message: {
role: 'user',
content: `<teammate-message teammate_id="other-team.team-lead" color="purple" summary="Cross-team reply">${content}</teammate-message>`,
},
});
await vi.waitFor(() => {
const updatedInbox = JSON.parse(
hoisted.files.get('/mock/teams/my-team/inboxes/team-lead.json') ?? '[]'
) as Array<{ read?: boolean }>;
expect(updatedInbox[0]?.read).toBe(true);
});
expect(run.activeCrossTeamReplyHints).toEqual([]);
});
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');
const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-legacy' }));
service.setCrossTeamSender(crossTeamSender);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
run.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-legacy' }];
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'team-best.user',
content:
'<cross-team from="my-team.team-lead" depth="0" conversationId="conv-legacy" replyToConversationId="conv-legacy" />\nПривет!',
summary: 'Ответ',
},
},
],
});
await vi.waitFor(() => {
expect(crossTeamSender).toHaveBeenCalledTimes(1);
});
expect(crossTeamSender).toHaveBeenCalledWith(
expect.objectContaining({
text: 'Привет!',
conversationId: 'conv-legacy',
replyToConversationId: 'conv-legacy',
})
);
});
it('does not push a duplicate live row when cross-team fallback deduplicates', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
const crossTeamSender = vi.fn(async () => ({
deliveredToInbox: true,
messageId: 'existing-cross-1',
deduplicated: true,
}));
service.setTeamChangeEmitter(emitter);
service.setCrossTeamSender(crossTeamSender);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'team-best.user',
content: 'Повтор без нового live row',
summary: 'Повтор',
},
},
],
});
await vi.waitFor(() => {
expect(crossTeamSender).toHaveBeenCalledTimes(1);
});
expect(service.getLiveLeadProcessMessages('my-team')).toHaveLength(0);
expect(emitter).not.toHaveBeenCalledWith(
expect.objectContaining({
type: 'lead-message',
teamName: 'my-team',
detail: 'cross-team-send',
})
);
});
it('does not upgrade dotted local teammate names into cross-team sends', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-1' }));
service.setCrossTeamSender(crossTeamSender);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
run.request.members.push({ name: 'ops.bot', role: 'Specialist' });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'ops.bot',
content: 'Please verify the rollout.',
summary: 'Verify rollout',
},
},
],
});
expect(crossTeamSender).not.toHaveBeenCalled();
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
expect(hoisted.sendInboxMessage).toHaveBeenCalledWith(
'my-team',
expect.objectContaining({ member: 'ops.bot' })
);
});
});