agent-ecosystem/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts
iliya 4e82102ceb feat: enhance team member resolution and activity components
- Introduced a new method to avoid duplicate member names in TeamMemberResolver, ensuring case-insensitive uniqueness.
- Updated ActivityItem and ActivityTimeline components to utilize local member names for improved recipient qualification checks.
- Added tests to validate the handling of dotted names and deduplication in cross-team messaging scenarios.
2026-03-10 01:16:01 +02:00

549 lines
18 KiB
TypeScript

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/utils/pathDecoder', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../src/main/utils/pathDecoder')>();
return { ...actual, getTeamsBasePath: () => '/mock/teams' };
});
vi.mock('agent-teams-controller', () => ({
createController: ({ teamName }: { teamName: string }) => ({
messages: {
appendSentMessage: (message: Record<string, unknown>) =>
hoisted.appendSentMessage(teamName, message),
sendMessage: (message: Record<string, unknown>) =>
hoisted.sendInboxMessage(teamName, message),
},
}),
}));
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' }],
})
);
}
interface RunLike {
runId: string;
teamName: string;
provisioningComplete: boolean;
leadMsgSeq: number;
pendingToolCalls: { name: string; preview: string }[];
lastLeadTextEmitMs: number;
leadRelayCapture: null;
silentUserDmForward: null;
suppressPostCompactReminderOutput?: boolean;
child: Record<string, unknown> | null;
processKilled: boolean;
cancelRequested: boolean;
provisioningOutputParts: string[];
request: { members: { name: string; role?: string }[] };
}
/**
* 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: [],
lastLeadTextEmitMs: 0,
leadRelayCapture: null,
silentUserDmForward: null,
child: { stdin: { writable: true, write: vi.fn(), end: vi.fn() } },
processKilled: false,
cancelRequested: false,
provisioningOutputParts: [],
request: { members: [{ name: 'team-lead', role: 'Team Lead' }] },
};
(service as unknown as { activeByTeam: Map<string, string> }).activeByTeam.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('captures SendMessage(to:team-lead) without rendering duplicate assistant thought text', () => {
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(1);
expect(live[0].to).toBe('team-lead');
expect(live[0].text).toBe('Need clarification on #abcd1234');
expect(live[0].source).toBe('lead_process');
// Non-user recipient → delivered to inbox, not sentMessages
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
});
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 });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'team-best.user',
content: '[Cross-team reply | conversation:conv-123] Привет!',
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].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('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' })
);
});
});