import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'fs'; import { CrossTeamService } from '@main/services/team/CrossTeamService'; import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, parseCrossTeamPrefix, } from '@shared/constants/crossTeam'; import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import type { TeamDataService } from '@main/services/team/TeamDataService'; import type { TeamInboxWriter } from '@main/services/team/TeamInboxWriter'; import type { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; 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; vi.mock('@shared/utils/logger', () => ({ createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }), })); function makeRequest(overrides: Partial = {}): CrossTeamSendRequest { return { fromTeam: 'team-a', fromMember: 'lead', toTeam: 'team-b', text: 'Hello from team-a', ...overrides, }; } function makeConfig(overrides: Partial = {}): TeamConfig { return { name: 'team-b', members: [{ name: 'team-lead', agentType: 'team-lead' }], ...overrides, }; } describe('CrossTeamService', () => { let service: CrossTeamService; let configReader: { getConfig: ReturnType }; let dataService: { getLeadMemberName: ReturnType; listTeams: ReturnType; }; let inboxWriter: { sendMessage: ReturnType }; let provisioning: { isTeamAlive: ReturnType; relayLeadInboxMessages: ReturnType; resolveCrossTeamReplyMetadata: ReturnType; registerPendingCrossTeamReplyExpectation: ReturnType; clearPendingCrossTeamReplyExpectation: ReturnType; }; beforeEach(() => { fs.rmSync(MOCK_TEAMS_BASE_PATH, { recursive: true, force: true }); configReader = { getConfig: vi.fn().mockResolvedValue(makeConfig()), }; dataService = { getLeadMemberName: vi.fn().mockResolvedValue('team-lead'), listTeams: vi.fn().mockResolvedValue([]), }; inboxWriter = { sendMessage: vi.fn().mockResolvedValue({ deliveredToInbox: true, messageId: 'mock-id' }), }; provisioning = { isTeamAlive: vi.fn().mockReturnValue(false), relayLeadInboxMessages: vi.fn().mockResolvedValue(0), resolveCrossTeamReplyMetadata: vi.fn().mockReturnValue(null), registerPendingCrossTeamReplyExpectation: vi.fn(), clearPendingCrossTeamReplyExpectation: vi.fn(), }; service = new CrossTeamService( configReader as unknown as TeamConfigReader, dataService as unknown as TeamDataService, inboxWriter as unknown as TeamInboxWriter, provisioning as unknown as TeamProvisioningService ); }); afterEach(() => { vi.restoreAllMocks(); fs.rmSync(MOCK_TEAMS_BASE_PATH, { recursive: true, force: true }); }); describe('send', () => { it('delivers message to inbox via inboxWriter', async () => { const result = await service.send(makeRequest()); expect(result.deliveredToInbox).toBe(true); expect(result.messageId).toBeDefined(); // 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'); expect(req.source).toBe(CROSS_TEAM_SOURCE); expect(req.from).toBe('team-a.team-lead'); expect(req.text).toContain('Hello from team-a'); const prefix = parseCrossTeamPrefix(req.text); expect(prefix?.from).toBe('team-a.team-lead'); expect(prefix?.chainDepth).toBe(0); expect(prefix?.conversationId).toBeTruthy(); }); it('injects a hidden action-mode block for the target lead only', async () => { await service.send(makeRequest({ actionMode: 'ask', text: 'Can you inspect this?' })); const [, req] = inboxWriter.sendMessage.mock.calls[0]; expect(req.text).toContain('TURN ACTION MODE: ASK'); expect(req.text).toContain('STRICTLY read-only conversation mode'); }); it('writes sender copy to sentMessages.json without touching the lead inbox', async () => { await service.send(makeRequest()); expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1); const sentMessagesPath = `${MOCK_TEAMS_BASE_PATH}/teams/team-a/sentMessages.json`; const raw = fs.readFileSync(sentMessagesPath, 'utf8'); const sentRows = JSON.parse(raw) as Array>; expect(sentRows).toHaveLength(1); expect(sentRows[0]?.from).toBe('team-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 () => { await service.send( makeRequest({ replyToConversationId: 'conv-123', text: 'Here is the answer', }) ); const [, req] = inboxWriter.sendMessage.mock.calls[0]; expect(req.conversationId).toBe('conv-123'); expect(req.replyToConversationId).toBe('conv-123'); }); it('auto-infers reply conversation metadata from provisioning hint when omitted', async () => { provisioning.resolveCrossTeamReplyMetadata.mockReturnValue({ conversationId: 'conv-auto', replyToConversationId: 'conv-auto', }); await service.send(makeRequest({ fromTeam: 'team-a', toTeam: 'team-b' })); const [, req] = inboxWriter.sendMessage.mock.calls[0]; expect(req.conversationId).toBe('conv-auto'); expect(req.replyToConversationId).toBe('conv-auto'); expect(provisioning.resolveCrossTeamReplyMetadata).toHaveBeenCalledWith('team-a', 'team-b'); }); it('does not ask provisioning for reply metadata when request already carries conversation ids', async () => { await service.send( makeRequest({ conversationId: 'conv-explicit', replyToConversationId: 'conv-explicit', }) ); expect(provisioning.resolveCrossTeamReplyMetadata).not.toHaveBeenCalled(); }); it('calls relayLeadInboxMessages when team is alive', async () => { provisioning.isTeamAlive.mockReturnValue(true); await service.send(makeRequest()); expect(provisioning.relayLeadInboxMessages).toHaveBeenCalledWith('team-b'); }); it('writes sender copy before triggering live relay', async () => { const order: string[] = []; inboxWriter.sendMessage.mockImplementation(async (teamName: string) => { order.push(`write:${teamName}`); return { deliveredToInbox: true, messageId: 'mock-id' }; }); provisioning.registerPendingCrossTeamReplyExpectation.mockImplementation(async () => { order.push('register:team-a->team-b'); }); provisioning.clearPendingCrossTeamReplyExpectation.mockImplementation(async () => { order.push('clear:team-a->team-b'); }); provisioning.isTeamAlive.mockReturnValue(true); provisioning.relayLeadInboxMessages.mockImplementation(async () => { order.push('relay:team-b'); return 0; }); await service.send(makeRequest()); expect(order).toEqual([ 'register:team-a->team-b', 'write:team-b', '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 () => { provisioning.isTeamAlive.mockReturnValue(false); await service.send(makeRequest()); expect(provisioning.relayLeadInboxMessages).not.toHaveBeenCalled(); }); it('gracefully handles relay failure', async () => { provisioning.isTeamAlive.mockReturnValue(true); provisioning.relayLeadInboxMessages.mockRejectedValue(new Error('relay fail')); const result = await service.send(makeRequest()); expect(result.deliveredToInbox).toBe(true); }); it('rejects self-send', async () => { await expect(service.send(makeRequest({ fromTeam: 'team-a', toTeam: 'team-a' }))).rejects.toThrow( 'same team' ); }); it('rejects invalid team names', async () => { await expect(service.send(makeRequest({ fromTeam: '../evil' }))).rejects.toThrow('Invalid fromTeam'); await expect(service.send(makeRequest({ toTeam: 'UPPER' }))).rejects.toThrow('Invalid toTeam'); }); it('rejects empty text', async () => { await expect(service.send(makeRequest({ text: '' }))).rejects.toThrow('text is required'); await expect(service.send(makeRequest({ text: ' ' }))).rejects.toThrow('text is required'); }); it('rejects when target not found', async () => { configReader.getConfig.mockImplementation(async (teamName: string) => teamName === 'team-b' ? null : makeConfig() ); await expect(service.send(makeRequest())).rejects.toThrow('Target team not found'); }); it('rejects when target is deleted', async () => { configReader.getConfig.mockImplementation(async (teamName: string) => teamName === 'to-be-deleted' ? makeConfig({ name: 'to-be-deleted', deletedAt: '2024-01-01T00:00:00Z' }) : makeConfig() ); await expect(service.send(makeRequest({ toTeam: 'to-be-deleted' }))).rejects.toThrow( 'Target team not found' ); }); it('rejects unknown source fromMember', async () => { await expect(service.send(makeRequest({ fromMember: 'researcher' }))).rejects.toThrow( 'Unknown fromMember' ); }); it('rejects when source is deleted', async () => { configReader.getConfig.mockImplementation(async (teamName: string) => teamName === 'deleted-source' ? makeConfig({ name: 'deleted-source', deletedAt: '2024-01-01T00:00:00Z' }) : makeConfig() ); await expect(service.send(makeRequest({ fromTeam: 'deleted-source' }))).rejects.toThrow( 'Source team not found' ); }); it('rejects excessive chain depth', async () => { await expect(service.send(makeRequest({ chainDepth: 5 }))).rejects.toThrow('chain depth'); }); it('rejects rate limit exceeded', async () => { for (let i = 0; i < 10; i++) { await service.send(makeRequest({ toTeam: `team-${String.fromCharCode(98 + i)}` })); configReader.getConfig.mockResolvedValue( makeConfig({ name: `team-${String.fromCharCode(99 + i)}` }) ); } configReader.getConfig.mockResolvedValue(makeConfig({ name: 'team-z' })); await expect(service.send(makeRequest({ toTeam: 'team-z' }))).rejects.toThrow('rate limit'); }); it('uses "team-lead" as fallback when getLeadMemberName returns null', async () => { dataService.getLeadMemberName.mockResolvedValue(null); await service.send(makeRequest()); const [, req] = inboxWriter.sendMessage.mock.calls[0]; expect(req.member).toBe('team-lead'); }); it('uses from format "team.member"', async () => { configReader.getConfig.mockImplementation(async (teamName: string) => teamName === 'alpha' ? makeConfig({ name: 'alpha', members: [{ name: 'researcher' }] }) : makeConfig() ); await service.send(makeRequest({ fromTeam: 'alpha', fromMember: 'researcher' })); const [, req] = inboxWriter.sendMessage.mock.calls[0]; expect(req.from).toBe('alpha.researcher'); }); it('works with null provisioning', async () => { const svc = new CrossTeamService( configReader as unknown as TeamConfigReader, dataService as unknown as TeamDataService, inboxWriter as unknown as TeamInboxWriter, null ); const result = await svc.send(makeRequest()); expect(result.deliveredToInbox).toBe(true); }); it('deduplicates recent equivalent requests and reuses messageId', async () => { const request = makeRequest({ fromTeam: 'team-a-dedupe', toTeam: 'team-b-dedupe', text: 'Please review this contract', summary: ' Review request ', }); configReader.getConfig.mockResolvedValue(makeConfig({ name: 'team-b-dedupe' })); const first = await service.send(request); const second = await service.send({ ...request, text: 'please review this contract', summary: 'review request', }); expect(second.deduplicated).toBe(true); expect(second.messageId).toBe(first.messageId); expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(1); }); }); describe('listAvailableTargets', () => { it('returns empty when team summary listing fails', async () => { dataService.listTeams.mockRejectedValue(new Error('ENOENT')); const result = await service.listAvailableTargets(); expect(result).toEqual([]); }); it('uses team summaries instead of verified config reads for target discovery', async () => { dataService.listTeams.mockResolvedValue([ { teamName: 'team-a', displayName: 'Team A', description: '', memberCount: 1, members: [], }, { teamName: 'team-b', displayName: 'Team B', description: 'Target team', color: 'blue', memberCount: 1, members: [{ name: 'alice', color: '#abcdef' }], leadName: 'captain', leadColor: '#123456', }, { teamName: 'deleted-team', displayName: 'Deleted', description: '', memberCount: 0, members: [], deletedAt: '2026-05-01T00:00:00.000Z', }, { teamName: 'draft-team', displayName: 'Draft', description: '', memberCount: 0, members: [], pendingCreate: true, }, ]); provisioning.isTeamAlive.mockImplementation((teamName: string) => teamName === 'team-b'); const result = await service.listAvailableTargets('team-a'); expect(configReader.getConfig).not.toHaveBeenCalled(); expect(result).toEqual([ { teamName: 'team-b', displayName: 'Team B', description: 'Target team', color: 'blue', leadName: 'captain', leadColor: '#123456', isOnline: true, }, ]); }); }); describe('getOutbox', () => { it('returns empty for non-existent outbox', async () => { const result = await service.getOutbox('team-a'); expect(result).toEqual([]); }); }); });