import { describe, it, expect, vi } from 'vitest' import { MessageBus } from '../src/team/messaging.js' import { Team } from '../src/team/team.js' import type { AgentConfig, TeamConfig } from '../src/types.js' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function agent(name: string): AgentConfig { return { name, model: 'test-model', systemPrompt: `You are ${name}.` } } function teamConfig(opts?: Partial): TeamConfig { return { name: 'test-team', agents: [agent('alice'), agent('bob')], ...opts, } } // =========================================================================== // MessageBus // =========================================================================== describe('MessageBus', () => { describe('send / getAll / getUnread', () => { it('delivers a point-to-point message', () => { const bus = new MessageBus() bus.send('alice', 'bob', 'hello') const msgs = bus.getAll('bob') expect(msgs).toHaveLength(1) expect(msgs[0]!.from).toBe('alice') expect(msgs[0]!.to).toBe('bob') expect(msgs[0]!.content).toBe('hello') }) it('does not deliver messages to sender', () => { const bus = new MessageBus() bus.send('alice', 'bob', 'hello') expect(bus.getAll('alice')).toHaveLength(0) }) it('tracks unread state', () => { const bus = new MessageBus() const msg = bus.send('alice', 'bob', 'hello') expect(bus.getUnread('bob')).toHaveLength(1) bus.markRead('bob', [msg.id]) expect(bus.getUnread('bob')).toHaveLength(0) // getAll still returns the message expect(bus.getAll('bob')).toHaveLength(1) }) it('markRead with empty array is a no-op', () => { const bus = new MessageBus() bus.markRead('bob', []) expect(bus.getUnread('bob')).toHaveLength(0) }) }) describe('broadcast', () => { it('delivers to all except sender', () => { const bus = new MessageBus() // Set up subscribers so the bus knows about agents bus.subscribe('alice', () => {}) bus.subscribe('bob', () => {}) bus.subscribe('carol', () => {}) bus.broadcast('alice', 'everyone listen') expect(bus.getAll('bob')).toHaveLength(1) expect(bus.getAll('carol')).toHaveLength(1) expect(bus.getAll('alice')).toHaveLength(0) // sender excluded }) it('broadcast message has to === "*"', () => { const bus = new MessageBus() const msg = bus.broadcast('alice', 'hi') expect(msg.to).toBe('*') }) }) describe('subscribe', () => { it('notifies subscriber on new direct message', () => { const bus = new MessageBus() const received: string[] = [] bus.subscribe('bob', (msg) => received.push(msg.content)) bus.send('alice', 'bob', 'ping') expect(received).toEqual(['ping']) }) it('notifies subscriber on broadcast', () => { const bus = new MessageBus() const received: string[] = [] bus.subscribe('bob', (msg) => received.push(msg.content)) bus.broadcast('alice', 'broadcast msg') expect(received).toEqual(['broadcast msg']) }) it('does not notify sender of own broadcast', () => { const bus = new MessageBus() const received: string[] = [] bus.subscribe('alice', (msg) => received.push(msg.content)) bus.broadcast('alice', 'my broadcast') expect(received).toEqual([]) }) it('unsubscribe stops notifications', () => { const bus = new MessageBus() const received: string[] = [] const unsub = bus.subscribe('bob', (msg) => received.push(msg.content)) bus.send('alice', 'bob', 'first') unsub() bus.send('alice', 'bob', 'second') expect(received).toEqual(['first']) }) }) describe('getConversation', () => { it('returns messages in both directions', () => { const bus = new MessageBus() bus.send('alice', 'bob', 'hello') bus.send('bob', 'alice', 'hi back') bus.send('alice', 'carol', 'unrelated') const convo = bus.getConversation('alice', 'bob') expect(convo).toHaveLength(2) expect(convo[0]!.content).toBe('hello') expect(convo[1]!.content).toBe('hi back') }) }) }) // =========================================================================== // Team // =========================================================================== describe('Team', () => { describe('agent roster', () => { it('returns all agents via getAgents()', () => { const team = new Team(teamConfig()) const agents = team.getAgents() expect(agents).toHaveLength(2) expect(agents.map(a => a.name)).toEqual(['alice', 'bob']) }) it('looks up agent by name', () => { const team = new Team(teamConfig()) expect(team.getAgent('alice')?.name).toBe('alice') expect(team.getAgent('nonexistent')).toBeUndefined() }) }) describe('messaging', () => { it('sends point-to-point messages and emits event', () => { const team = new Team(teamConfig()) const events: unknown[] = [] team.on('message', (d) => events.push(d)) team.sendMessage('alice', 'bob', 'hey') expect(team.getMessages('bob')).toHaveLength(1) expect(team.getMessages('bob')[0]!.content).toBe('hey') expect(events).toHaveLength(1) }) it('broadcasts and emits broadcast event', () => { const team = new Team(teamConfig()) const events: unknown[] = [] team.on('broadcast', (d) => events.push(d)) team.broadcast('alice', 'all hands') expect(events).toHaveLength(1) }) }) describe('task management', () => { it('adds and retrieves tasks', () => { const team = new Team(teamConfig()) const task = team.addTask({ title: 'Do something', description: 'Details here', status: 'pending', assignee: 'alice', }) expect(task.id).toBeDefined() expect(task.title).toBe('Do something') expect(team.getTasks()).toHaveLength(1) }) it('filters tasks by assignee', () => { const team = new Team(teamConfig()) team.addTask({ title: 't1', description: 'd', status: 'pending', assignee: 'alice' }) team.addTask({ title: 't2', description: 'd', status: 'pending', assignee: 'bob' }) expect(team.getTasksByAssignee('alice')).toHaveLength(1) expect(team.getTasksByAssignee('alice')[0]!.title).toBe('t1') }) it('updates a task', () => { const team = new Team(teamConfig()) const task = team.addTask({ title: 't1', description: 'd', status: 'pending' }) const updated = team.updateTask(task.id, { status: 'in_progress' }) expect(updated.status).toBe('in_progress') }) it('getNextTask prefers assigned tasks', () => { const team = new Team(teamConfig()) team.addTask({ title: 'unassigned', description: 'd', status: 'pending' }) team.addTask({ title: 'for alice', description: 'd', status: 'pending', assignee: 'alice' }) const next = team.getNextTask('alice') expect(next?.title).toBe('for alice') }) it('getNextTask falls back to unassigned', () => { const team = new Team(teamConfig()) team.addTask({ title: 'unassigned', description: 'd', status: 'pending' }) const next = team.getNextTask('alice') expect(next?.title).toBe('unassigned') }) it('getNextTask returns undefined when no tasks available', () => { const team = new Team(teamConfig()) expect(team.getNextTask('alice')).toBeUndefined() }) it('preserves non-default status on addTask', () => { const team = new Team(teamConfig()) const task = team.addTask({ title: 'blocked task', description: 'd', status: 'blocked', result: 'waiting on dep', }) expect(task.status).toBe('blocked') expect(task.result).toBe('waiting on dep') }) }) describe('shared memory', () => { it('returns undefined when sharedMemory is disabled', () => { const team = new Team(teamConfig({ sharedMemory: false })) expect(team.getSharedMemory()).toBeUndefined() expect(team.getSharedMemoryInstance()).toBeUndefined() }) it('returns a MemoryStore when sharedMemory is enabled', () => { const team = new Team(teamConfig({ sharedMemory: true })) const store = team.getSharedMemory() expect(store).toBeDefined() expect(typeof store!.get).toBe('function') expect(typeof store!.set).toBe('function') }) it('returns SharedMemory instance', () => { const team = new Team(teamConfig({ sharedMemory: true })) const mem = team.getSharedMemoryInstance() expect(mem).toBeDefined() expect(typeof mem!.write).toBe('function') expect(typeof mem!.getSummary).toBe('function') }) }) describe('events', () => { it('emits task:ready when a pending task becomes runnable', () => { const team = new Team(teamConfig()) const events: unknown[] = [] team.on('task:ready', (d) => events.push(d)) team.addTask({ title: 't1', description: 'd', status: 'pending' }) // task:ready is fired by the queue when a task with no deps is added expect(events.length).toBeGreaterThanOrEqual(1) }) it('emits custom events via emit()', () => { const team = new Team(teamConfig()) const received: unknown[] = [] team.on('custom:event', (d) => received.push(d)) team.emit('custom:event', { foo: 'bar' }) expect(received).toEqual([{ foo: 'bar' }]) }) it('unsubscribe works', () => { const team = new Team(teamConfig()) const received: unknown[] = [] const unsub = team.on('custom:event', (d) => received.push(d)) team.emit('custom:event', 'first') unsub() team.emit('custom:event', 'second') expect(received).toEqual(['first']) }) it('bridges task:complete and task:failed from the queue', () => { // These events fire via queue.complete()/queue.fail(), which happen // during orchestration. Team only exposes updateTask() which calls // queue.update() — no event is emitted. We verify the bridge is // wired correctly by checking that task:ready fires on addTask. const team = new Team(teamConfig()) const readyEvents: unknown[] = [] team.on('task:ready', (d) => readyEvents.push(d)) team.addTask({ title: 't1', description: 'd', status: 'pending' }) // task:ready fires because a pending task with no deps is immediately ready expect(readyEvents.length).toBeGreaterThanOrEqual(1) }) }) })