const fs = require('fs'); const os = require('os'); const path = require('path'); const { createController } = require('../src/index.js'); const { CROSS_TEAM_SOURCE, CROSS_TEAM_TAG_NAME } = require('../src/internal/crossTeamProtocol.js'); describe('crossTeam module', () => { const tempDirs = []; function makeClaudeDir(teams = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crossteam-test-')); tempDirs.push(dir); for (const [teamName, config] of Object.entries(teams)) { const teamDir = path.join(dir, 'teams', teamName); const taskDir = path.join(dir, 'tasks', teamName); fs.mkdirSync(teamDir, { recursive: true }); fs.mkdirSync(taskDir, { recursive: true }); fs.mkdirSync(path.join(teamDir, 'inboxes'), { recursive: true }); fs.writeFileSync( path.join(teamDir, 'config.json'), JSON.stringify(config, null, 2) ); } return dir; } afterEach(() => { // Reset cascade guard between tests const cascadeGuard = require('../src/internal/cascadeGuard.js'); cascadeGuard.reset(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); describe('sendCrossTeamMessage', () => { it('delivers message to target team inbox', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); const result = controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', fromMember: 'lead', text: 'Hello from team-a', summary: 'Test message', }); expect(result.deliveredToInbox).toBe(true); expect(result.messageId).toBeDefined(); // Verify inbox was written const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'); const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); expect(inbox).toHaveLength(1); expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE); expect(inbox[0].from).toBe('team-a.team-lead'); expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.team-lead" depth="0"`); expect(inbox[0].conversationId).toBeTruthy(); expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`); }); it('records outbox entry', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', text: 'Hello', }); const outbox = controller.crossTeam.getCrossTeamOutbox(); expect(outbox).toHaveLength(1); expect(outbox[0].toTeam).toBe('team-b'); expect(outbox[0].conversationId).toBeTruthy(); const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json'); const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8')); expect(sentMessages).toHaveLength(1); expect(sentMessages[0].from).toBe('team-lead'); expect(sentMessages[0].to).toBe('team-b.team-lead'); expect(sentMessages[0].text).toBe('Hello'); expect(sentMessages[0].source).toBe('cross_team_sent'); expect(sentMessages[0].messageId).toBe(outbox[0].messageId); }); it('preserves taskRefs in target inbox, sender copy and outbox', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, }); const taskRefs = [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }]; const controller = createController({ teamName: 'team-a', claudeDir }); controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', text: 'Please review the linked task', taskRefs, }); const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'); const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); expect(inbox[0].taskRefs).toEqual(taskRefs); const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json'); const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8')); expect(sentMessages[0].taskRefs).toEqual(taskRefs); const outbox = controller.crossTeam.getCrossTeamOutbox(); expect(outbox[0].taskRefs).toEqual(taskRefs); }); it('rejects unknown source fromMember', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); expect(() => controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', fromMember: 'ghost', text: 'Hello from nowhere', }) ).toThrow('Unknown cross-team sender'); }); it('preserves reply conversation metadata for explicit replies', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', text: 'Answering the open question', replyToConversationId: 'conv-123', }); const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'); const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); expect(inbox[0].conversationId).toBe('conv-123'); expect(inbox[0].replyToConversationId).toBe('conv-123'); expect(inbox[0].text).toContain('conversationId="conv-123"'); expect(inbox[0].text).toContain('replyToConversationId="conv-123"'); }); it('deduplicates the same recent cross-team request', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); const first = controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', fromMember: 'lead', text: 'Please review the API contract', summary: 'Review request', }); const second = controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', fromMember: 'lead', text: 'Please review the API contract', summary: ' Review request ', }); expect(second.deliveredToInbox).toBe(true); expect(second.deduplicated).toBe(true); expect(second.messageId).toBe(first.messageId); const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'); const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); expect(inbox).toHaveLength(1); const outbox = controller.crossTeam.getCrossTeamOutbox(); expect(outbox).toHaveLength(1); const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json'); const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8')); expect(sentMessages).toHaveLength(1); }); it('allows resending after dedupe window expires', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); const originalNow = Date.now; let now = originalNow(); Date.now = () => now; try { const first = controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', text: 'Need a decision on the schema', summary: 'Schema decision', }); now += 6 * 60 * 1000; const second = controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', text: 'Need a decision on the schema', summary: 'Schema decision', }); expect(second.deduplicated).toBeUndefined(); expect(second.messageId).not.toBe(first.messageId); const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'); const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); expect(inbox).toHaveLength(2); const updatedOutbox = controller.crossTeam.getCrossTeamOutbox(); expect(updatedOutbox).toHaveLength(2); } finally { Date.now = originalNow; } }); it('rejects self-send', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); expect(() => controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-a', text: 'Self', }) ).toThrow('same team'); }); it('rejects when target not found', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); expect(() => controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-nonexistent', text: 'Hello', }) ).toThrow('Target team not found'); }); it('rejects when target is deleted', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [{ name: 'team-lead', agentType: 'team-lead' }], deletedAt: '2024-01-01T00:00:00Z', }, }); const controller = createController({ teamName: 'team-a', claudeDir }); expect(() => controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', text: 'Hello', }) ).toThrow('Target team not found'); }); it('rejects excessive chain depth', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); expect(() => controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', text: 'Hello', chainDepth: 5, }) ).toThrow('chain depth'); }); }); describe('resolveTargetLead', () => { it('resolves lead by agentType', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'alpha-lead', agentType: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [{ name: 'beta-lead', agentType: 'team-lead' }], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', text: 'Hello', }); const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'beta-lead.json'); expect(fs.existsSync(inboxPath)).toBe(true); }); it('resolves supported lead agent types before tech-lead role text', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [ { name: 'alice', role: 'tech lead' }, { name: 'olivia', agentType: 'lead' }, ], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', text: 'Hello', }); expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'olivia.json'))).toBe(true); expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false); }); it('resolves orchestrator lead from members.meta.json', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [], }, }); const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json'); fs.writeFileSync( metaPath, JSON.stringify({ members: [ { name: 'alice', role: 'tech lead' }, { name: 'orla', agentType: 'orchestrator' }, ], }) ); const controller = createController({ teamName: 'team-a', claudeDir }); controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', text: 'Hello', }); expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'orla.json'))).toBe(true); expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false); }); it('rejects phantom source teams before delivery or outbox writes', () => { const claudeDir = makeClaudeDir({ 'team-b': { name: 'team-b', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); expect(() => controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', text: 'Hello from nowhere', }) ).toThrow('Source team not found: team-a'); expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-a'))).toBe(false); expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false); }); it('rejects unknown cross-team senders', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [{ name: 'team-lead', agentType: 'team-lead' }], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); expect(() => controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', fromMember: 'alicce', text: 'Hello', }) ).toThrow('Unknown cross-team sender: alicce'); expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false); }); it('resolves lead by name fallback', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [{ name: 'team-lead' }], }, }); const controller = createController({ teamName: 'team-a', claudeDir }); controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', text: 'Hello', }); const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'); expect(fs.existsSync(inboxPath)).toBe(true); }); it('resolves lead from members.meta.json with normalization', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'team-a', members: [{ name: 'team-lead' }], }, 'team-b': { name: 'team-b', members: [], }, }); // Write meta with dirty data (leading spaces, duplicates) const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json'); fs.writeFileSync( metaPath, JSON.stringify({ members: [ { name: ' meta-lead ', agentType: 'team-lead' }, { name: ' meta-lead ', agentType: 'team-lead' }, { name: 'worker', agentType: 'worker' }, ], }) ); const controller = createController({ teamName: 'team-a', claudeDir }); controller.crossTeam.sendCrossTeamMessage({ toTeam: 'team-b', text: 'Hello', }); const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'meta-lead.json'); expect(fs.existsSync(inboxPath)).toBe(true); }); }); describe('listCrossTeamTargets', () => { it('lists valid teams excluding current', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'Team A' }, 'team-b': { name: 'Team B', description: 'B desc' }, 'team-c': { name: 'Team C', deletedAt: '2024-01-01' }, }); const controller = createController({ teamName: 'team-a', claudeDir }); const targets = controller.crossTeam.listCrossTeamTargets(); expect(targets).toHaveLength(1); expect(targets[0].teamName).toBe('team-b'); expect(targets[0].displayName).toBe('Team B'); expect(targets[0].description).toBe('B desc'); }); }); describe('getCrossTeamOutbox', () => { it('returns empty for non-existent outbox', () => { const claudeDir = makeClaudeDir({ 'team-a': { name: 'Team A' }, }); const controller = createController({ teamName: 'team-a', claudeDir }); const outbox = controller.crossTeam.getCrossTeamOutbox(); expect(outbox).toEqual([]); }); }); });