Files
Dragan Spiridonov a6202c39a3 chore(release): v3.9.1 (#395)
* feat: implement ADR-062 (StrongDM all 3 tiers) and ADR-087 (RuVector 14/14 capabilities)

ADR-062 StrongDM Software Factory — all 6 actions across 3 tiers:
- Tier 1: Loop detection wired to MCP protocol-server (blocks on strike 3+),
  token dashboard (pre-existing)
- Tier 2: Holdout testing (10% FNV-1a selection, filtered in test-execution),
  gate ratcheting (monotonic 5-pass/2%/95% cap, persisted to MemoryBackend)
- Tier 3: Progressive context (TF-IDF prediction, reduces availableFiles on spawn),
  meta-learning cycle (4 insight types, stored + published as domain events)

ADR-087 RuVector Advanced Capabilities — milestones 1-5:
- EWC++ recordOutcome + backgroundConsolidate wired to 3 domain coordinators
- R1: HDC fingerprinting (10K-bit hypervectors, wired to PatternStore.store())
- R3: Delta event sourcing (sparse encoding, wired to PatternStore.store())
- R13: Cognitive routing (predictive compression, wired to mailbox.send/receive)
- R14: Hyperbolic HNSW (Poincare ball, auto-indexed in PatternStore.store())

Also updates ADR statuses: 059, 060, 061 → Implemented (were already done),
062 → Implemented, 086 → In Progress, 087 → Implemented.

223 new tests across 11 test files, all passing. Zero regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: implement RVF cluster (ADR-065/066/067/071) with native benchmarks

Implements the RVF persistence cluster for persistent HNSW vector storage,
agent memory branching, and HNSW unification foundation.

Key changes:
- RvfPatternStore: IPatternStore backed by @ruvector/rvf-node native HNSW
  (1.2ms cold-start, 0.19ms search p50, verified with real native binary)
- AgentMemoryBranch: per-agent COW isolation via derive(), ingest-log merge,
  wired into Kernel.initialize() + DefaultAgentCoordinator
- HnswLegacyBridge: adapts old IHNSWIndex to unified IHnswIndexProvider,
  wired into PatternStore when useUnifiedHnsw flag enabled
- Factory routing: createPatternStore() returns RvfPatternStore when flag
  enabled + native available + data directory exists
- Dream engine: accepts external RVF adapter (no duplicate file handles),
  RVCOWBranchManager enhanced with recall validation check
- @ruvector/gnn bumped 0.1.19→0.1.25, 3 new feature flags added
- IPatternStore interface extracted for polymorphic pattern store usage
- 50 new tests (16 RvfPatternStore + 17 AgentMemoryBranch + 12 bridge + 5 benchmarks)
- 1188 tests pass, 0 regressions

ADR status: 066 Implemented, 067 Implemented, 065 Implemented,
069/071 In Progress (honest assessment after brutal-honesty review)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(init): resolve skills/agents paths from package root, not relative to bundle depth

esbuild code-splitting puts installer code into dist/cli/chunks/ (3 levels
deep) instead of dist/init/ (2 levels deep), so the old ../../ traversals
landed at dist/ instead of the package root — causing "Source directory not
found" errors for all global installs.

Replace brittle relative-path resolution in all 4 installers with a shared
findPackageRoot() that walks up to the nearest package.json with
name=agentic-qe. Prioritise assets/ paths (canonical npm source) over
.claude/ paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: complete RVF cluster Phases 2-3 (ADR-067/069/071/072) with devil's advocate fixes

Phase 2A (ADR-067): Agent memory branching enabled — useAgentMemoryBranching=true,
BRANCH_DISCARD witness action, recordIngest callback exposed to agents via config.

Phase 2B (ADR-069): Dream RVF COW — useRvfFork driven by isRVFPatternStoreEnabled(),
child adapter stored + temp files cleaned up on merge/discard, 13 tests.

Phase 2C (ADR-071): HNSW unification — useUnifiedHnsw=true, HNSWEmbeddingIndex routes
through HnswAdapter, UnifiedMemory vectorIndex uses UnifiedHnswIndex, HnswLegacyBridge
delegates to adapter string-ID APIs (no duplicate ID maps), addEmbeddingsBatch routed
correctly, saveIndex/loadIndex safe under unified mode.

Phase 3 (ADR-072): RVF primary migration scaffolding — RvfMigrationAdapter (5-stage
routing with write latency tracking), RvfConsistencyValidator (rolling divergence +
compaction), RvfStageGate (go/no-go with witness chain), rvfMigrationStage=1 default,
wired into shared-rvf-dual-writer at stage >= 2, 32 tests.

Cross-cutting fixes from devil's advocate review:
- Shared RVF adapter singleton eliminates dual file handles (kernel + dream engine)
- RvfPatternStore reports honest nativeAvailable=false + rvfInitError on failure
- ESM-safe dynamic import() replaces require() in dream-engine and branch-manager
- quality_assess defaults to src/ instead of cwd to prevent full-project scan hang
- discoverSourceFiles skips .agentic-qe/.claude/.cache dirs, adds maxFiles=5000 cap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: complete RVF cluster plan — Phase 2C decommission, Phase 3 integration, fix 7 pre-existing test failures

Phase 2C (ADR-071) — HNSW decommission:
- Remove RuvectorFlatIndex fallback from unified-memory.ts; always use UnifiedHnswIndex
- Hardcode isUnifiedHnswActive()=true in HNSWEmbeddingIndex (hnswlib-node legacy path is dead code)
- Create HnswShadowValidator for brute-force vs HnswAdapter divergence validation
- Benchmark alias (rvf-pattern-store.bench.ts) for plan compliance

Phase 3 (ADR-072) — RVF Primary Migration integration:
- RvfMigrationCoordinator: wires adapter + validator + gate with real SQLite/RVF handles
- MCP tools: migration_status, migration_check, migration_promote (persistence category)
- Kernel boot wiring: coordinator initializes when rvfMigrationStage >= 2
- RvfMigrationAdapter.setStage(): propagate promotions to routing logic
- Promote default rvfMigrationStage from 1 (hybrid) to 2 (dual-write-sqlite-primary)
- Integration test: 13 tests covering full lifecycle (stage 1→2→3→4), write/read routing,
  consistency checks, gate enforcement, fallback, metrics

Test fixes (7 pre-existing failures → 1 remaining):
- code-intelligence-metrics-wiring: spy console.info not console.log (logger routes to console.info)
- mcp-integration: disable ADR-062 loop detection (repeated identical calls are by design)
- dream-cycle-tool (2 tests): RvfPatternStore empty-query branch returns all patterns from SQLite
- coherence-integration (4 tests): RvfPatternStore vector search fallback computes brute-force
  cosine over SQLite embeddings when RVF adapter unavailable; getStats() falls back to SQLite count;
  auto-attach SQLitePatternStore in initialize() when not explicitly wired

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(release): bump version to v3.9.1

- Version bump in package.json and skills-manifest.json
- Fix "Dynamic require of X is not supported" in ESM bundles by adding
  createRequire banner to both CLI and MCP build scripts — every chunk
  now has a working require() for Node.js builtins
- Add CHANGELOG.md entry and release notes for v3.9.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): regenerate package-lock.json for npm ci compatibility

The lock file was out of sync with package.json — new transitive
dependencies added on main were missing. All 15 CI checks failed
at `npm ci` with "Missing from lock file" errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): pin Node to 24.13.0 and restore working lock file

Node 24.14.1 ships npm 11.11.0 which enforces stricter lock file
validation than 11.6.x (what 24.13.0 ships). The existing lock file
passes under 11.6.x but fails under 11.11.0 with "Missing from lock
file" for transitive dependencies of private-registry packages.

Pin all 5 workflow files to node-version: '24.13.0' and restore the
lock file from commit f9b76968 (last CI-passing state) with only the
version bump to 3.9.1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): regenerate package-lock.json with npm 10 for stable npm ci

npm 11 (Node 24) produces internally inconsistent lock files that npm ci
rejects — each npm install resolves transitive deps differently. Generated
with npm 10 (Node 22 LTS) which produces stable lock files compatible with
both npm 10 and npm 11.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:51:49 +02:00

598 lines
22 KiB
TypeScript

/**
* Unit tests for Agent Teams — MailboxService and AgentTeamsAdapter
* ADR-064: Inter-agent messaging, mailbox lifecycle, team management
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MailboxService, createMailboxService } from '../../src/coordination/agent-teams/mailbox.js';
import { AgentTeamsAdapter, createAgentTeamsAdapter } from '../../src/coordination/agent-teams/adapter.js';
import type { AgentMessage, DomainTeamConfig } from '../../src/coordination/agent-teams/types.js';
import * as cogModule from '../../src/integrations/ruvector/cognitive-routing.js';
// ============================================================================
// Helpers
// ============================================================================
/** Build a minimal AgentMessage for testing */
function makeMessage(overrides: Partial<AgentMessage> = {}): AgentMessage {
return {
id: overrides.id ?? `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
from: overrides.from ?? 'agent-sender',
to: overrides.to ?? 'agent-receiver',
domain: overrides.domain ?? 'test-generation',
type: overrides.type ?? 'finding',
payload: overrides.payload ?? { data: 'test-payload' },
timestamp: overrides.timestamp ?? Date.now(),
correlationId: overrides.correlationId,
replyTo: overrides.replyTo,
ttl: overrides.ttl,
};
}
/** Build a DomainTeamConfig for testing */
function makeTeamConfig(overrides: Partial<DomainTeamConfig> = {}): DomainTeamConfig {
return {
domain: overrides.domain ?? 'test-generation',
leadAgentId: overrides.leadAgentId ?? 'lead-1',
maxTeammates: overrides.maxTeammates ?? 5,
teammateIds: overrides.teammateIds ?? ['tm-1', 'tm-2'],
autoAssignEnabled: overrides.autoAssignEnabled ?? false,
};
}
// ============================================================================
// MailboxService Tests
// ============================================================================
describe('MailboxService', () => {
let svc: MailboxService;
beforeEach(() => {
svc = createMailboxService();
});
// ---------- Mailbox lifecycle ----------
it('should create a mailbox for an agent', () => {
const mb = svc.createMailbox('agent-1', 'test-generation');
expect(mb.agentId).toBe('agent-1');
expect(mb.domain).toBe('test-generation');
expect(mb.messages).toEqual([]);
expect(mb.unreadCount).toBe(0);
});
it('should return existing mailbox when creating duplicate', () => {
const mb1 = svc.createMailbox('agent-1', 'test-generation');
const mb2 = svc.createMailbox('agent-1', 'test-generation');
expect(mb1.agentId).toBe(mb2.agentId);
expect(svc.size).toBe(1);
});
it('should delete a mailbox and return true', () => {
svc.createMailbox('agent-1', 'test-generation');
expect(svc.deleteMailbox('agent-1')).toBe(true);
expect(svc.getMailbox('agent-1')).toBeUndefined();
expect(svc.size).toBe(0);
});
it('should return false when deleting non-existent mailbox', () => {
expect(svc.deleteMailbox('no-such-agent')).toBe(false);
});
it('should remove domain tracking when last agent in domain is deleted', () => {
svc.createMailbox('agent-1', 'test-generation');
svc.deleteMailbox('agent-1');
expect(svc.getAgentsInDomain('test-generation')).toEqual([]);
expect(svc.getDomains()).not.toContain('test-generation');
});
// ---------- Send / Receive ----------
it('should deliver a point-to-point message', () => {
svc.createMailbox('agent-receiver', 'test-generation');
const msg = makeMessage({ to: 'agent-receiver' });
svc.send(msg);
const received = svc.receive('agent-receiver');
expect(received).toHaveLength(1);
expect(received[0].id).toBe(msg.id);
});
it('should throw when sending to non-existent mailbox', () => {
const msg = makeMessage({ to: 'ghost-agent' });
expect(() => svc.send(msg)).toThrow(/No mailbox found/);
});
it('should route broadcast-addressed messages to broadcast()', () => {
svc.createMailbox('agent-1', 'test-generation');
svc.createMailbox('agent-2', 'test-generation');
const msg = makeMessage({ from: 'agent-1', to: 'broadcast', domain: 'test-generation' });
svc.send(msg);
// agent-2 gets the broadcast, agent-1 (sender) does not
expect(svc.receive('agent-2')).toHaveLength(1);
expect(svc.receive('agent-1')).toHaveLength(0);
});
// ---------- Broadcast ----------
it('should broadcast to all agents in domain except sender', () => {
svc.createMailbox('a1', 'security');
svc.createMailbox('a2', 'security');
svc.createMailbox('a3', 'security');
const msg = makeMessage({ from: 'a1', domain: 'security' });
svc.broadcast('security', msg);
expect(svc.peek('a1')).toBe(0); // sender excluded
expect(svc.peek('a2')).toBe(1);
expect(svc.peek('a3')).toBe(1);
});
it('should do nothing when broadcasting to unknown domain', () => {
const msg = makeMessage({ from: 'x', domain: 'no-domain' });
// Should not throw
svc.broadcast('no-domain', msg);
});
// ---------- Receive with options ----------
it('should mark all as read after receive', () => {
svc.createMailbox('r', 'dom');
svc.send(makeMessage({ to: 'r' }));
svc.send(makeMessage({ to: 'r' }));
expect(svc.receive('r')).toHaveLength(2);
expect(svc.receive('r')).toHaveLength(0); // already read
});
it('should filter messages by type', () => {
svc.createMailbox('r', 'dom');
svc.send(makeMessage({ to: 'r', type: 'alert' }));
svc.send(makeMessage({ to: 'r', type: 'finding' }));
svc.send(makeMessage({ to: 'r', type: 'alert' }));
const alerts = svc.receive('r', { type: 'alert' });
expect(alerts).toHaveLength(2);
expect(alerts.every(m => m.type === 'alert')).toBe(true);
});
// ---------- TTL / Expiration ----------
it('should filter expired messages on receive', () => {
svc.createMailbox('r', 'dom');
const expired = makeMessage({ to: 'r', ttl: 1, timestamp: Date.now() - 1000 });
const valid = makeMessage({ to: 'r', ttl: 60_000 });
svc.send(expired);
svc.send(valid);
const msgs = svc.receive('r');
expect(msgs).toHaveLength(1);
expect(msgs[0].id).toBe(valid.id);
});
// ---------- Peek ----------
it('should return unread count without marking as read', () => {
svc.createMailbox('r', 'dom');
svc.send(makeMessage({ to: 'r' }));
svc.send(makeMessage({ to: 'r' }));
expect(svc.peek('r')).toBe(2);
// Still unread after peek
expect(svc.peek('r')).toBe(2);
// Now consume
svc.receive('r');
expect(svc.peek('r')).toBe(0);
});
it('should return 0 for non-existent mailbox peek', () => {
expect(svc.peek('no-agent')).toBe(0);
});
// ---------- Cleanup ----------
it('should remove expired messages during cleanup', () => {
svc.createMailbox('r', 'dom');
const oldMsg = makeMessage({ to: 'r', timestamp: Date.now() - 120_000 });
svc.send(oldMsg);
svc.send(makeMessage({ to: 'r' })); // fresh
const removed = svc.cleanup(60_000); // max age 60s
expect(removed).toBe(1);
});
// ---------- Event callbacks ----------
it('should fire message handler when message is delivered', () => {
svc.createMailbox('r', 'dom');
const handler = vi.fn();
svc.onMessage('r', handler);
const msg = makeMessage({ to: 'r' });
svc.send(msg);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(msg);
});
it('should allow unsubscribing message handler', () => {
svc.createMailbox('r', 'dom');
const handler = vi.fn();
const unsub = svc.onMessage('r', handler);
unsub();
svc.send(makeMessage({ to: 'r' }));
expect(handler).not.toHaveBeenCalled();
});
it('should fire broadcast handler on domain broadcast', () => {
svc.createMailbox('a1', 'sec');
const handler = vi.fn();
svc.onBroadcast('sec', handler);
const msg = makeMessage({ from: 'ext', domain: 'sec' });
svc.broadcast('sec', msg);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith('sec', msg);
});
it('should not break delivery when handler throws', () => {
svc.createMailbox('r', 'dom');
svc.onMessage('r', () => { throw new Error('boom'); });
const msg = makeMessage({ to: 'r' });
// Should not throw
expect(() => svc.send(msg)).not.toThrow();
expect(svc.peek('r')).toBe(1);
});
});
// ============================================================================
// AgentTeamsAdapter Tests
// ============================================================================
describe('AgentTeamsAdapter', () => {
let adapter: AgentTeamsAdapter;
beforeEach(() => {
vi.useFakeTimers();
adapter = createAgentTeamsAdapter({ cleanupIntervalMs: 0 }); // disable background cleanup for tests
});
afterEach(() => {
adapter.shutdown();
vi.useRealTimers();
});
// ---------- Team management ----------
it('should create a team and register all members', () => {
adapter.createTeam(makeTeamConfig());
expect(adapter.isRegistered('lead-1')).toBe(true);
expect(adapter.isRegistered('tm-1')).toBe(true);
expect(adapter.isRegistered('tm-2')).toBe(true);
});
it('should throw when creating duplicate team for same domain', () => {
adapter.createTeam(makeTeamConfig());
expect(() => adapter.createTeam(makeTeamConfig())).toThrow(/Team already exists/);
});
it('should return team status with idle agents', () => {
adapter.createTeam(makeTeamConfig());
const status = adapter.getTeamStatus('test-generation');
expect(status).toBeDefined();
expect(status!.domain).toBe('test-generation');
expect(status!.activeAgentCount).toBe(3); // lead + 2 teammates
expect(status!.idleAgentIds).toHaveLength(3); // all idle, no messages
expect(status!.totalUnreadMessages).toBe(0);
});
it('should return undefined for non-existent team status', () => {
expect(adapter.getTeamStatus('no-domain')).toBeUndefined();
});
// ---------- Agent registration ----------
it('should register an agent and track its domain', () => {
adapter.registerAgent('solo-1', 'coverage-analysis');
expect(adapter.isRegistered('solo-1')).toBe(true);
expect(adapter.getAgentDomain('solo-1')).toBe('coverage-analysis');
});
it('should not double-register the same agent', () => {
adapter.registerAgent('solo-1', 'coverage-analysis');
adapter.registerAgent('solo-1', 'coverage-analysis');
expect(adapter.getRegisteredAgents().filter(id => id === 'solo-1')).toHaveLength(1);
});
it('should unregister an agent and remove its mailbox', () => {
adapter.registerAgent('solo-1', 'coverage-analysis');
expect(adapter.unregisterAgent('solo-1')).toBe(true);
expect(adapter.isRegistered('solo-1')).toBe(false);
});
it('should return false when unregistering unknown agent', () => {
expect(adapter.unregisterAgent('no-agent')).toBe(false);
});
it('should auto-assign agent to team when autoAssignEnabled', () => {
adapter.createTeam(makeTeamConfig({ autoAssignEnabled: true, teammateIds: [] }));
adapter.registerAgent('new-agent', 'test-generation');
const status = adapter.getTeamStatus('test-generation');
expect(status!.teammateIds).toContain('new-agent');
});
// ---------- Messaging via adapter ----------
it('should send a message between registered agents', () => {
adapter.registerAgent('sender', 'dom');
adapter.registerAgent('receiver', 'dom');
const msg = adapter.sendMessage('sender', 'receiver', 'finding', { info: 'data' });
expect(msg.from).toBe('sender');
expect(msg.to).toBe('receiver');
expect(adapter.getUnreadCount('receiver')).toBe(1);
});
it('should throw when sender is not registered', () => {
adapter.registerAgent('receiver', 'dom');
expect(() => adapter.sendMessage('ghost', 'receiver', 'alert', {})).toThrow(/Unknown sender/);
});
it('should throw when recipient is not registered', () => {
adapter.registerAgent('sender', 'dom');
expect(() => adapter.sendMessage('sender', 'ghost', 'alert', {})).toThrow(/Unknown recipient/);
});
// ---------- Broadcast via adapter ----------
it('should broadcast to all agents in domain', () => {
adapter.registerAgent('a1', 'dom');
adapter.registerAgent('a2', 'dom');
adapter.registerAgent('a3', 'dom');
adapter.broadcast('dom', 'alert', { msg: 'heads-up' }, { from: 'a1' });
expect(adapter.getUnreadCount('a1')).toBe(0); // sender excluded
expect(adapter.getUnreadCount('a2')).toBe(1);
expect(adapter.getUnreadCount('a3')).toBe(1);
});
it('should allow system broadcast without registered sender', () => {
adapter.registerAgent('a1', 'dom');
const msg = adapter.broadcast('dom', 'alert', { msg: 'system-alert' });
expect(msg.from).toBe('__system__');
expect(adapter.getUnreadCount('a1')).toBe(1);
});
// ---------- Idle agents ----------
it('should return all agents as idle when no messages pending', () => {
adapter.registerAgent('a1', 'dom');
adapter.registerAgent('a2', 'dom');
const idle = adapter.getIdleAgents();
expect(idle).toContain('a1');
expect(idle).toContain('a2');
});
it('should exclude agents with unread messages from idle list', () => {
adapter.registerAgent('a1', 'dom');
adapter.registerAgent('a2', 'dom');
adapter.sendMessage('a1', 'a2', 'finding', {});
const idle = adapter.getIdleAgents();
expect(idle).toContain('a1');
expect(idle).not.toContain('a2');
});
it('should filter idle agents by domain', () => {
adapter.registerAgent('a1', 'dom-a');
adapter.registerAgent('a2', 'dom-b');
const idle = adapter.getIdleAgents('dom-a');
expect(idle).toContain('a1');
expect(idle).not.toContain('a2');
});
// ---------- Subscription callbacks ----------
it('should fire subscription handler on message delivery', () => {
adapter.registerAgent('sender', 'dom');
adapter.registerAgent('receiver', 'dom');
const handler = vi.fn();
adapter.onMessage('receiver', handler);
adapter.sendMessage('sender', 'receiver', 'finding', { x: 1 });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].type).toBe('finding');
});
it('should allow unsubscribing from messages', () => {
adapter.registerAgent('sender', 'dom');
adapter.registerAgent('receiver', 'dom');
const handler = vi.fn();
const unsub = adapter.onMessage('receiver', handler);
unsub();
adapter.sendMessage('sender', 'receiver', 'finding', {});
expect(handler).not.toHaveBeenCalled();
});
// ---------- Initialize / Shutdown lifecycle ----------
it('should initialize and start cleanup timer', () => {
const adp = createAgentTeamsAdapter({ cleanupIntervalMs: 5000 });
adp.initialize();
// Second init is a no-op
adp.initialize();
adp.shutdown();
});
it('should clear all state on shutdown', () => {
adapter.registerAgent('a1', 'dom');
adapter.shutdown();
expect(adapter.isRegistered('a1')).toBe(false);
expect(adapter.getRegisteredAgents()).toHaveLength(0);
});
});
// ============================================================================
// R13: Cognitive Routing Compression Integration
// ============================================================================
describe('MailboxService — cognitive routing compression', () => {
let svc: MailboxService;
let enabledSpy: ReturnType<typeof vi.spyOn>;
let createSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
svc = createMailboxService();
svc.createMailbox('sender', 'dom');
svc.createMailbox('receiver', 'dom');
});
afterEach(() => {
enabledSpy?.mockRestore();
createSpy?.mockRestore();
});
/**
* When cognitive routing is OFF (default), messages pass through
* unchanged — no compression, no decompression.
*/
it('should pass messages through unchanged when routing is disabled', () => {
enabledSpy = vi.spyOn(cogModule, 'isCognitiveRoutingEnabled').mockReturnValue(false);
const payload = { status: 'running', count: 42, details: 'some data' };
const msg = makeMessage({ to: 'receiver', payload });
svc.send(msg);
const received = svc.receive('receiver');
expect(received).toHaveLength(1);
expect(received[0].payload).toEqual(payload);
expect(svc.getCognitiveRoutingStats()).toBeNull();
});
/**
* When cognitive routing is ON and delta compression helps, the stored
* message should use a smaller compressed payload, and receive() should
* reconstruct the original payload transparently.
*/
it('should compress on send and decompress on receive when routing is enabled', () => {
enabledSpy = vi.spyOn(cogModule, 'isCognitiveRoutingEnabled').mockReturnValue(true);
const router = new cogModule.CognitiveRouter();
createSpy = vi.spyOn(cogModule, 'createCognitiveRouter').mockReturnValue(router);
// First message seeds the predictor — no compression expected
const basePayload = {
status: 'running', count: 1, agent: 'tester',
details: 'long repeated content that stays the same across messages',
};
const msg1 = makeMessage({ id: 'msg-1', to: 'receiver', payload: basePayload });
svc.send(msg1);
svc.receive('receiver'); // drain
// Second message differs only slightly — delta compression should kick in
const updatedPayload = {
...basePayload,
count: 2, // only this field changes
};
const msg2 = makeMessage({ id: 'msg-2', to: 'receiver', payload: updatedPayload });
svc.send(msg2);
// Verify the stored message has compressed payload (internal check via snapshot)
const snapshot = svc.getMailbox('receiver');
expect(snapshot).toBeDefined();
const storedMsg = snapshot!.messages.find(m => m.id === 'msg-2');
expect(storedMsg).toBeDefined();
// The stored payload should be a delta envelope, not the original
const storedPayload = storedMsg!.payload as Record<string, unknown>;
expect(storedPayload.__delta).toBe(true);
expect(storedPayload.__changes).toBeDefined();
// But receive() should decompress and return the original payload
const received = svc.receive('receiver');
expect(received).toHaveLength(1);
expect(received[0].payload).toEqual(updatedPayload);
// Stats should show real savings
const stats = svc.getCognitiveRoutingStats();
expect(stats).not.toBeNull();
expect(stats!.totalMessages).toBeGreaterThanOrEqual(2);
expect(stats!.compressedMessages).toBeGreaterThanOrEqual(1);
expect(stats!.bandwidthSavedBytes).toBeGreaterThan(0);
});
/**
* When the router throws, the original message is stored and delivered
* unchanged — no data loss.
*/
it('should fall back to original message when router.send() throws', () => {
enabledSpy = vi.spyOn(cogModule, 'isCognitiveRoutingEnabled').mockReturnValue(true);
const router = new cogModule.CognitiveRouter();
createSpy = vi.spyOn(cogModule, 'createCognitiveRouter').mockReturnValue(router);
vi.spyOn(router, 'send').mockImplementation(() => { throw new Error('boom'); });
const payload = { key: 'value' };
const msg = makeMessage({ to: 'receiver', payload });
svc.send(msg);
const received = svc.receive('receiver');
expect(received).toHaveLength(1);
expect(received[0].payload).toEqual(payload);
});
/**
* When compression is available but the delta is not smaller than the
* original (e.g., entirely new payload), the original is stored unchanged.
*/
it('should store original when compression does not save space', () => {
enabledSpy = vi.spyOn(cogModule, 'isCognitiveRoutingEnabled').mockReturnValue(true);
const router = new cogModule.CognitiveRouter();
createSpy = vi.spyOn(cogModule, 'createCognitiveRouter').mockReturnValue(router);
// First message — no prior prediction, so no compression
const payload = { x: 1, y: 2 };
const msg = makeMessage({ to: 'receiver', payload });
svc.send(msg);
const received = svc.receive('receiver');
expect(received).toHaveLength(1);
expect(received[0].payload).toEqual(payload);
});
/**
* Cleanup should remove compressed message tracking entries for expired messages.
*/
it('should clean up compressed message tracking on message expiration', () => {
enabledSpy = vi.spyOn(cogModule, 'isCognitiveRoutingEnabled').mockReturnValue(true);
const router = new cogModule.CognitiveRouter();
createSpy = vi.spyOn(cogModule, 'createCognitiveRouter').mockReturnValue(router);
const basePayload = {
status: 'running', count: 1,
details: 'long repeated content that stays the same across messages',
};
// Seed the predictor
svc.send(makeMessage({ id: 'seed', to: 'receiver', payload: basePayload }));
svc.receive('receiver');
// Send a compressible message with old timestamp
const oldMsg = makeMessage({
id: 'old-compressed',
to: 'receiver',
payload: { ...basePayload, count: 2 },
timestamp: Date.now() - 100_000,
});
svc.send(oldMsg);
// Cleanup with short maxAge — should remove the old message
const removed = svc.cleanup(1000);
expect(removed).toBeGreaterThanOrEqual(1);
});
});