Autonomous message routing between Agent Teams via MCP with cascade protection. Inbox-first canonical delivery with cross-process file lock (O_CREAT|O_EXCL) and best-effort relay for online teams. - CascadeGuard: rate limit (10/min), chain depth (max 5), pair cooldown (3s) - FileLock: cross-process safe via kernel-level atomic lock files - CrossTeamService: validate → cascade → file-lock → inbox write → relay → outbox - Unified lead resolver with members.meta.json normalization (trim+dedup) - 3 MCP tools: cross_team_send, cross_team_list_targets, cross_team_get_outbox - Controller module with sync file lock, same protocol as main - IPC adapter with 3 Electron handlers - 64 new tests across 8 test files
94 lines
2.5 KiB
TypeScript
94 lines
2.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { CascadeGuard } from '@main/services/team/CascadeGuard';
|
|
|
|
describe('CascadeGuard', () => {
|
|
let guard: CascadeGuard;
|
|
|
|
beforeEach(() => {
|
|
guard = new CascadeGuard();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('rate limit', () => {
|
|
it('allows up to 10 messages per minute', () => {
|
|
for (let i = 0; i < 10; i++) {
|
|
guard.check('team-a', `team-${i}`, 0);
|
|
guard.record('team-a', `team-${i}`);
|
|
}
|
|
// 11th should fail
|
|
expect(() => guard.check('team-a', 'team-x', 0)).toThrow('rate limit');
|
|
});
|
|
|
|
it('resets after window expires', () => {
|
|
vi.useFakeTimers();
|
|
for (let i = 0; i < 10; i++) {
|
|
guard.check('team-a', `team-${i}`, 0);
|
|
guard.record('team-a', `team-${i}`);
|
|
}
|
|
|
|
// Advance 61 seconds
|
|
vi.advanceTimersByTime(61_000);
|
|
|
|
// Should succeed now
|
|
expect(() => guard.check('team-a', 'team-new', 0)).not.toThrow();
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe('chain depth', () => {
|
|
it('allows depth 0 through 4', () => {
|
|
for (let d = 0; d < 5; d++) {
|
|
expect(() => guard.check('team-a', 'team-b', d)).not.toThrow();
|
|
}
|
|
});
|
|
|
|
it('rejects depth >= 5', () => {
|
|
expect(() => guard.check('team-a', 'team-b', 5)).toThrow('chain depth');
|
|
expect(() => guard.check('team-a', 'team-b', 10)).toThrow('chain depth');
|
|
});
|
|
});
|
|
|
|
describe('pair cooldown', () => {
|
|
it('rejects same pair within 3s', () => {
|
|
guard.check('team-a', 'team-b', 0);
|
|
guard.record('team-a', 'team-b');
|
|
|
|
expect(() => guard.check('team-a', 'team-b', 0)).toThrow('cooldown');
|
|
});
|
|
|
|
it('allows same pair after 3s', () => {
|
|
vi.useFakeTimers();
|
|
guard.check('team-a', 'team-b', 0);
|
|
guard.record('team-a', 'team-b');
|
|
|
|
vi.advanceTimersByTime(3_001);
|
|
|
|
expect(() => guard.check('team-a', 'team-b', 0)).not.toThrow();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('allows different pairs simultaneously', () => {
|
|
guard.check('team-a', 'team-b', 0);
|
|
guard.record('team-a', 'team-b');
|
|
|
|
expect(() => guard.check('team-a', 'team-c', 0)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('reset', () => {
|
|
it('clears all state', () => {
|
|
for (let i = 0; i < 10; i++) {
|
|
guard.check('team-a', `team-${i}`, 0);
|
|
guard.record('team-a', `team-${i}`);
|
|
}
|
|
|
|
guard.reset();
|
|
|
|
expect(() => guard.check('team-a', 'team-0', 0)).not.toThrow();
|
|
});
|
|
});
|
|
});
|