Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
This commit is contained in:
490
npm/packages/ruvbot/tests/unit/domain/agent.test.ts
Normal file
490
npm/packages/ruvbot/tests/unit/domain/agent.test.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Agent Domain Entity - Unit Tests
|
||||
*
|
||||
* Tests for Agent lifecycle, state management, and behavior
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createAgent, createAgents, type Agent, type AgentConfig } from '../../factories';
|
||||
|
||||
// Agent Entity Types (would be imported from src/domain/agent.ts)
|
||||
interface AgentState {
|
||||
id: string;
|
||||
name: string;
|
||||
type: Agent['type'];
|
||||
status: Agent['status'];
|
||||
capabilities: string[];
|
||||
config: AgentConfig;
|
||||
currentTask?: string;
|
||||
metrics: AgentMetrics;
|
||||
}
|
||||
|
||||
interface AgentMetrics {
|
||||
tasksCompleted: number;
|
||||
averageLatency: number;
|
||||
errorCount: number;
|
||||
lastActiveAt: Date | null;
|
||||
}
|
||||
|
||||
// Mock Agent class for testing
|
||||
class AgentEntity {
|
||||
private state: AgentState;
|
||||
private eventLog: Array<{ type: string; payload: unknown; timestamp: Date }> = [];
|
||||
|
||||
constructor(initialState: Partial<AgentState>) {
|
||||
this.state = {
|
||||
id: initialState.id || `agent-${Date.now()}`,
|
||||
name: initialState.name || 'Unnamed Agent',
|
||||
type: initialState.type || 'coder',
|
||||
status: initialState.status || 'idle',
|
||||
capabilities: initialState.capabilities || [],
|
||||
config: initialState.config || {
|
||||
model: 'claude-sonnet-4',
|
||||
temperature: 0.7,
|
||||
maxTokens: 4096
|
||||
},
|
||||
currentTask: initialState.currentTask,
|
||||
metrics: initialState.metrics || {
|
||||
tasksCompleted: 0,
|
||||
averageLatency: 0,
|
||||
errorCount: 0,
|
||||
lastActiveAt: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.state.id;
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
return this.state.name;
|
||||
}
|
||||
|
||||
getType(): Agent['type'] {
|
||||
return this.state.type;
|
||||
}
|
||||
|
||||
getStatus(): Agent['status'] {
|
||||
return this.state.status;
|
||||
}
|
||||
|
||||
getCapabilities(): string[] {
|
||||
return [...this.state.capabilities];
|
||||
}
|
||||
|
||||
getConfig(): AgentConfig {
|
||||
return { ...this.state.config };
|
||||
}
|
||||
|
||||
getMetrics(): AgentMetrics {
|
||||
return { ...this.state.metrics };
|
||||
}
|
||||
|
||||
getCurrentTask(): string | undefined {
|
||||
return this.state.currentTask;
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.state.status === 'idle';
|
||||
}
|
||||
|
||||
hasCapability(capability: string): boolean {
|
||||
return this.state.capabilities.includes(capability);
|
||||
}
|
||||
|
||||
async assignTask(taskId: string): Promise<void> {
|
||||
if (this.state.status !== 'idle') {
|
||||
throw new Error(`Agent ${this.state.id} is not available (status: ${this.state.status})`);
|
||||
}
|
||||
|
||||
this.state.status = 'busy';
|
||||
this.state.currentTask = taskId;
|
||||
this.state.metrics.lastActiveAt = new Date();
|
||||
this.logEvent('task_assigned', { taskId });
|
||||
}
|
||||
|
||||
async completeTask(result: { success: boolean; latency: number }): Promise<void> {
|
||||
if (this.state.status !== 'busy') {
|
||||
throw new Error(`Agent ${this.state.id} has no active task`);
|
||||
}
|
||||
|
||||
const taskId = this.state.currentTask;
|
||||
this.state.status = 'idle';
|
||||
this.state.currentTask = undefined;
|
||||
this.state.metrics.tasksCompleted++;
|
||||
|
||||
// Update average latency
|
||||
const totalLatency = this.state.metrics.averageLatency * (this.state.metrics.tasksCompleted - 1);
|
||||
this.state.metrics.averageLatency = (totalLatency + result.latency) / this.state.metrics.tasksCompleted;
|
||||
|
||||
if (!result.success) {
|
||||
this.state.metrics.errorCount++;
|
||||
}
|
||||
|
||||
this.logEvent('task_completed', { taskId, result });
|
||||
}
|
||||
|
||||
async failTask(error: Error): Promise<void> {
|
||||
if (this.state.status !== 'busy') {
|
||||
throw new Error(`Agent ${this.state.id} has no active task`);
|
||||
}
|
||||
|
||||
const taskId = this.state.currentTask;
|
||||
this.state.status = 'error';
|
||||
this.state.currentTask = undefined;
|
||||
this.state.metrics.errorCount++;
|
||||
|
||||
this.logEvent('task_failed', { taskId, error: error.message });
|
||||
}
|
||||
|
||||
async recover(): Promise<void> {
|
||||
if (this.state.status !== 'error') {
|
||||
throw new Error(`Agent ${this.state.id} is not in error state`);
|
||||
}
|
||||
|
||||
this.state.status = 'idle';
|
||||
this.logEvent('recovered', {});
|
||||
}
|
||||
|
||||
async terminate(): Promise<void> {
|
||||
this.state.status = 'terminated';
|
||||
this.state.currentTask = undefined;
|
||||
this.logEvent('terminated', {});
|
||||
}
|
||||
|
||||
updateConfig(config: Partial<AgentConfig>): void {
|
||||
this.state.config = { ...this.state.config, ...config };
|
||||
this.logEvent('config_updated', { config });
|
||||
}
|
||||
|
||||
addCapability(capability: string): void {
|
||||
if (!this.state.capabilities.includes(capability)) {
|
||||
this.state.capabilities.push(capability);
|
||||
this.logEvent('capability_added', { capability });
|
||||
}
|
||||
}
|
||||
|
||||
removeCapability(capability: string): void {
|
||||
const index = this.state.capabilities.indexOf(capability);
|
||||
if (index !== -1) {
|
||||
this.state.capabilities.splice(index, 1);
|
||||
this.logEvent('capability_removed', { capability });
|
||||
}
|
||||
}
|
||||
|
||||
getEventLog(): Array<{ type: string; payload: unknown; timestamp: Date }> {
|
||||
return [...this.eventLog];
|
||||
}
|
||||
|
||||
toJSON(): AgentState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
private logEvent(type: string, payload: unknown): void {
|
||||
this.eventLog.push({ type, payload, timestamp: new Date() });
|
||||
}
|
||||
}
|
||||
|
||||
// Tests
|
||||
describe('Agent Domain Entity', () => {
|
||||
describe('Construction', () => {
|
||||
it('should create agent with default values', () => {
|
||||
const agent = new AgentEntity({});
|
||||
|
||||
expect(agent.getId()).toBeDefined();
|
||||
expect(agent.getName()).toBe('Unnamed Agent');
|
||||
expect(agent.getType()).toBe('coder');
|
||||
expect(agent.getStatus()).toBe('idle');
|
||||
expect(agent.getCapabilities()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create agent with provided values', () => {
|
||||
const agent = new AgentEntity({
|
||||
id: 'test-agent',
|
||||
name: 'Test Agent',
|
||||
type: 'researcher',
|
||||
capabilities: ['web-search', 'analysis']
|
||||
});
|
||||
|
||||
expect(agent.getId()).toBe('test-agent');
|
||||
expect(agent.getName()).toBe('Test Agent');
|
||||
expect(agent.getType()).toBe('researcher');
|
||||
expect(agent.getCapabilities()).toEqual(['web-search', 'analysis']);
|
||||
});
|
||||
|
||||
it('should initialize metrics correctly', () => {
|
||||
const agent = new AgentEntity({});
|
||||
const metrics = agent.getMetrics();
|
||||
|
||||
expect(metrics.tasksCompleted).toBe(0);
|
||||
expect(metrics.averageLatency).toBe(0);
|
||||
expect(metrics.errorCount).toBe(0);
|
||||
expect(metrics.lastActiveAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Availability', () => {
|
||||
it('should be available when idle', () => {
|
||||
const agent = new AgentEntity({ status: 'idle' });
|
||||
expect(agent.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be available when busy', () => {
|
||||
const agent = new AgentEntity({ status: 'busy' });
|
||||
expect(agent.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be available when in error state', () => {
|
||||
const agent = new AgentEntity({ status: 'error' });
|
||||
expect(agent.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be available when terminated', () => {
|
||||
const agent = new AgentEntity({ status: 'terminated' });
|
||||
expect(agent.isAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Capabilities', () => {
|
||||
it('should check for capability correctly', () => {
|
||||
const agent = new AgentEntity({
|
||||
capabilities: ['code-generation', 'code-review']
|
||||
});
|
||||
|
||||
expect(agent.hasCapability('code-generation')).toBe(true);
|
||||
expect(agent.hasCapability('code-review')).toBe(true);
|
||||
expect(agent.hasCapability('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('should add capability', () => {
|
||||
const agent = new AgentEntity({ capabilities: [] });
|
||||
|
||||
agent.addCapability('new-capability');
|
||||
|
||||
expect(agent.hasCapability('new-capability')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not duplicate capability', () => {
|
||||
const agent = new AgentEntity({ capabilities: ['existing'] });
|
||||
|
||||
agent.addCapability('existing');
|
||||
|
||||
expect(agent.getCapabilities()).toEqual(['existing']);
|
||||
});
|
||||
|
||||
it('should remove capability', () => {
|
||||
const agent = new AgentEntity({ capabilities: ['to-remove', 'to-keep'] });
|
||||
|
||||
agent.removeCapability('to-remove');
|
||||
|
||||
expect(agent.hasCapability('to-remove')).toBe(false);
|
||||
expect(agent.hasCapability('to-keep')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Lifecycle', () => {
|
||||
it('should assign task to idle agent', async () => {
|
||||
const agent = new AgentEntity({ status: 'idle' });
|
||||
|
||||
await agent.assignTask('task-001');
|
||||
|
||||
expect(agent.getStatus()).toBe('busy');
|
||||
expect(agent.getCurrentTask()).toBe('task-001');
|
||||
expect(agent.getMetrics().lastActiveAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error when assigning task to busy agent', async () => {
|
||||
const agent = new AgentEntity({ status: 'busy', currentTask: 'existing-task' });
|
||||
|
||||
await expect(agent.assignTask('new-task')).rejects.toThrow('not available');
|
||||
});
|
||||
|
||||
it('should complete task successfully', async () => {
|
||||
const agent = new AgentEntity({ status: 'busy', currentTask: 'task-001' });
|
||||
|
||||
await agent.completeTask({ success: true, latency: 100 });
|
||||
|
||||
expect(agent.getStatus()).toBe('idle');
|
||||
expect(agent.getCurrentTask()).toBeUndefined();
|
||||
expect(agent.getMetrics().tasksCompleted).toBe(1);
|
||||
expect(agent.getMetrics().averageLatency).toBe(100);
|
||||
});
|
||||
|
||||
it('should track error count on failed completion', async () => {
|
||||
const agent = new AgentEntity({ status: 'busy', currentTask: 'task-001' });
|
||||
|
||||
await agent.completeTask({ success: false, latency: 50 });
|
||||
|
||||
expect(agent.getMetrics().errorCount).toBe(1);
|
||||
expect(agent.getMetrics().tasksCompleted).toBe(1);
|
||||
});
|
||||
|
||||
it('should calculate average latency correctly', async () => {
|
||||
const agent = new AgentEntity({ status: 'idle' });
|
||||
|
||||
// First task
|
||||
await agent.assignTask('task-1');
|
||||
await agent.completeTask({ success: true, latency: 100 });
|
||||
|
||||
// Second task
|
||||
await agent.assignTask('task-2');
|
||||
await agent.completeTask({ success: true, latency: 200 });
|
||||
|
||||
expect(agent.getMetrics().averageLatency).toBe(150);
|
||||
});
|
||||
|
||||
it('should fail task and enter error state', async () => {
|
||||
const agent = new AgentEntity({ status: 'busy', currentTask: 'task-001' });
|
||||
|
||||
await agent.failTask(new Error('Task execution failed'));
|
||||
|
||||
expect(agent.getStatus()).toBe('error');
|
||||
expect(agent.getCurrentTask()).toBeUndefined();
|
||||
expect(agent.getMetrics().errorCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw error when completing non-existent task', async () => {
|
||||
const agent = new AgentEntity({ status: 'idle' });
|
||||
|
||||
await expect(agent.completeTask({ success: true, latency: 100 }))
|
||||
.rejects.toThrow('no active task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Recovery', () => {
|
||||
it('should recover from error state', async () => {
|
||||
const agent = new AgentEntity({ status: 'error' });
|
||||
|
||||
await agent.recover();
|
||||
|
||||
expect(agent.getStatus()).toBe('idle');
|
||||
expect(agent.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error when recovering from non-error state', async () => {
|
||||
const agent = new AgentEntity({ status: 'idle' });
|
||||
|
||||
await expect(agent.recover()).rejects.toThrow('not in error state');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Termination', () => {
|
||||
it('should terminate agent', async () => {
|
||||
const agent = new AgentEntity({ status: 'idle' });
|
||||
|
||||
await agent.terminate();
|
||||
|
||||
expect(agent.getStatus()).toBe('terminated');
|
||||
expect(agent.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('should terminate busy agent and clear task', async () => {
|
||||
const agent = new AgentEntity({ status: 'busy', currentTask: 'task-001' });
|
||||
|
||||
await agent.terminate();
|
||||
|
||||
expect(agent.getStatus()).toBe('terminated');
|
||||
expect(agent.getCurrentTask()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration', () => {
|
||||
it('should update config partially', () => {
|
||||
const agent = new AgentEntity({
|
||||
config: {
|
||||
model: 'claude-sonnet-4',
|
||||
temperature: 0.7,
|
||||
maxTokens: 4096
|
||||
}
|
||||
});
|
||||
|
||||
agent.updateConfig({ temperature: 0.5 });
|
||||
|
||||
const config = agent.getConfig();
|
||||
expect(config.temperature).toBe(0.5);
|
||||
expect(config.model).toBe('claude-sonnet-4');
|
||||
expect(config.maxTokens).toBe(4096);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Logging', () => {
|
||||
it('should log events during lifecycle', async () => {
|
||||
const agent = new AgentEntity({ status: 'idle' });
|
||||
|
||||
await agent.assignTask('task-001');
|
||||
await agent.completeTask({ success: true, latency: 100 });
|
||||
|
||||
const events = agent.getEventLog();
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].type).toBe('task_assigned');
|
||||
expect(events[1].type).toBe('task_completed');
|
||||
});
|
||||
|
||||
it('should log configuration changes', () => {
|
||||
const agent = new AgentEntity({});
|
||||
|
||||
agent.updateConfig({ temperature: 0.5 });
|
||||
agent.addCapability('new-cap');
|
||||
|
||||
const events = agent.getEventLog();
|
||||
expect(events.some(e => e.type === 'config_updated')).toBe(true);
|
||||
expect(events.some(e => e.type === 'capability_added')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Serialization', () => {
|
||||
it('should serialize to JSON', () => {
|
||||
const agent = new AgentEntity({
|
||||
id: 'test-agent',
|
||||
name: 'Test Agent',
|
||||
type: 'coder',
|
||||
capabilities: ['code-generation']
|
||||
});
|
||||
|
||||
const json = agent.toJSON();
|
||||
|
||||
expect(json.id).toBe('test-agent');
|
||||
expect(json.name).toBe('Test Agent');
|
||||
expect(json.type).toBe('coder');
|
||||
expect(json.capabilities).toEqual(['code-generation']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Factory Integration', () => {
|
||||
it('should create agent from factory data', () => {
|
||||
const factoryAgent = createAgent({
|
||||
name: 'Factory Agent',
|
||||
type: 'tester',
|
||||
capabilities: ['test-generation']
|
||||
});
|
||||
|
||||
const agent = new AgentEntity({
|
||||
id: factoryAgent.id,
|
||||
name: factoryAgent.name,
|
||||
type: factoryAgent.type,
|
||||
capabilities: factoryAgent.capabilities,
|
||||
config: factoryAgent.config
|
||||
});
|
||||
|
||||
expect(agent.getId()).toBe(factoryAgent.id);
|
||||
expect(agent.getName()).toBe('Factory Agent');
|
||||
expect(agent.getType()).toBe('tester');
|
||||
});
|
||||
|
||||
it('should create multiple agents from factory', () => {
|
||||
const agents = createAgents(5);
|
||||
|
||||
const agentEntities = agents.map(a => new AgentEntity({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
type: a.type
|
||||
}));
|
||||
|
||||
expect(agentEntities).toHaveLength(5);
|
||||
agentEntities.forEach((agent, i) => {
|
||||
expect(agent.getName()).toBe(`Agent ${i + 1}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
710
npm/packages/ruvbot/tests/unit/domain/memory.test.ts
Normal file
710
npm/packages/ruvbot/tests/unit/domain/memory.test.ts
Normal file
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* Memory Domain Entity - Unit Tests
|
||||
*
|
||||
* Tests for Memory storage, retrieval, and vector operations
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createMemory, createVectorMemory, type Memory, type MemoryMetadata } from '../../factories';
|
||||
|
||||
// Memory Entity Types
|
||||
interface MemoryEntry {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
sessionId: string | null;
|
||||
type: 'short-term' | 'long-term' | 'vector' | 'episodic';
|
||||
key: string;
|
||||
value: unknown;
|
||||
embedding: Float32Array | null;
|
||||
metadata: MemoryEntryMetadata;
|
||||
}
|
||||
|
||||
interface MemoryEntryMetadata {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
expiresAt: Date | null;
|
||||
accessCount: number;
|
||||
importance: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface VectorSearchResult {
|
||||
entry: MemoryEntry;
|
||||
score: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
// Mock Memory Store class for testing
|
||||
class MemoryStore {
|
||||
private entries: Map<string, MemoryEntry> = new Map();
|
||||
private indexByKey: Map<string, Set<string>> = new Map();
|
||||
private indexByTenant: Map<string, Set<string>> = new Map();
|
||||
private indexBySession: Map<string, Set<string>> = new Map();
|
||||
private readonly dimension: number;
|
||||
|
||||
constructor(dimension: number = 384) {
|
||||
this.dimension = dimension;
|
||||
}
|
||||
|
||||
async set(entry: Omit<MemoryEntry, 'metadata'> & { metadata?: Partial<MemoryEntryMetadata> }): Promise<MemoryEntry> {
|
||||
const fullEntry: MemoryEntry = {
|
||||
...entry,
|
||||
metadata: {
|
||||
createdAt: entry.metadata?.createdAt || new Date(),
|
||||
updatedAt: new Date(),
|
||||
expiresAt: entry.metadata?.expiresAt || null,
|
||||
accessCount: entry.metadata?.accessCount || 0,
|
||||
importance: entry.metadata?.importance || 0.5,
|
||||
tags: entry.metadata?.tags || []
|
||||
}
|
||||
};
|
||||
|
||||
// Validate embedding dimension
|
||||
if (fullEntry.embedding && fullEntry.embedding.length !== this.dimension) {
|
||||
throw new Error(`Embedding dimension mismatch: expected ${this.dimension}, got ${fullEntry.embedding.length}`);
|
||||
}
|
||||
|
||||
this.entries.set(entry.id, fullEntry);
|
||||
this.updateIndexes(fullEntry);
|
||||
|
||||
return fullEntry;
|
||||
}
|
||||
|
||||
async get(id: string): Promise<MemoryEntry | null> {
|
||||
const entry = this.entries.get(id);
|
||||
if (entry) {
|
||||
entry.metadata.accessCount++;
|
||||
entry.metadata.updatedAt = new Date();
|
||||
}
|
||||
return entry || null;
|
||||
}
|
||||
|
||||
async getByKey(key: string, tenantId: string): Promise<MemoryEntry | null> {
|
||||
const ids = this.indexByKey.get(key);
|
||||
if (!ids) return null;
|
||||
|
||||
for (const id of ids) {
|
||||
const entry = this.entries.get(id);
|
||||
if (entry && entry.tenantId === tenantId) {
|
||||
entry.metadata.accessCount++;
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry) return false;
|
||||
|
||||
this.removeFromIndexes(entry);
|
||||
return this.entries.delete(id);
|
||||
}
|
||||
|
||||
async deleteByKey(key: string, tenantId: string): Promise<boolean> {
|
||||
const entry = await this.getByKey(key, tenantId);
|
||||
if (!entry) return false;
|
||||
return this.delete(entry.id);
|
||||
}
|
||||
|
||||
async listByTenant(tenantId: string, limit: number = 100): Promise<MemoryEntry[]> {
|
||||
const ids = this.indexByTenant.get(tenantId);
|
||||
if (!ids) return [];
|
||||
|
||||
const entries: MemoryEntry[] = [];
|
||||
for (const id of ids) {
|
||||
const entry = this.entries.get(id);
|
||||
if (entry) entries.push(entry);
|
||||
if (entries.length >= limit) break;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async listBySession(sessionId: string, limit: number = 100): Promise<MemoryEntry[]> {
|
||||
const ids = this.indexBySession.get(sessionId);
|
||||
if (!ids) return [];
|
||||
|
||||
const entries: MemoryEntry[] = [];
|
||||
for (const id of ids) {
|
||||
const entry = this.entries.get(id);
|
||||
if (entry) entries.push(entry);
|
||||
if (entries.length >= limit) break;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async search(query: Float32Array, tenantId: string, topK: number = 10): Promise<VectorSearchResult[]> {
|
||||
if (query.length !== this.dimension) {
|
||||
throw new Error(`Query dimension mismatch: expected ${this.dimension}, got ${query.length}`);
|
||||
}
|
||||
|
||||
const results: VectorSearchResult[] = [];
|
||||
const tenantIds = this.indexByTenant.get(tenantId);
|
||||
if (!tenantIds) return [];
|
||||
|
||||
for (const id of tenantIds) {
|
||||
const entry = this.entries.get(id);
|
||||
if (entry?.embedding) {
|
||||
const score = this.cosineSimilarity(query, entry.embedding);
|
||||
results.push({
|
||||
entry,
|
||||
score,
|
||||
distance: 1 - score
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, topK);
|
||||
}
|
||||
|
||||
async expire(): Promise<number> {
|
||||
const now = new Date();
|
||||
let expiredCount = 0;
|
||||
|
||||
for (const [id, entry] of this.entries) {
|
||||
if (entry.metadata.expiresAt && entry.metadata.expiresAt < now) {
|
||||
this.delete(id);
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return expiredCount;
|
||||
}
|
||||
|
||||
async clear(tenantId?: string): Promise<number> {
|
||||
if (tenantId) {
|
||||
const ids = this.indexByTenant.get(tenantId);
|
||||
if (!ids) return 0;
|
||||
|
||||
let deletedCount = 0;
|
||||
for (const id of Array.from(ids)) {
|
||||
if (this.delete(id)) deletedCount++;
|
||||
}
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
const count = this.entries.size;
|
||||
this.entries.clear();
|
||||
this.indexByKey.clear();
|
||||
this.indexByTenant.clear();
|
||||
this.indexBySession.clear();
|
||||
return count;
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
sizeByTenant(tenantId: string): number {
|
||||
return this.indexByTenant.get(tenantId)?.size || 0;
|
||||
}
|
||||
|
||||
private updateIndexes(entry: MemoryEntry): void {
|
||||
// Key index
|
||||
let keySet = this.indexByKey.get(entry.key);
|
||||
if (!keySet) {
|
||||
keySet = new Set();
|
||||
this.indexByKey.set(entry.key, keySet);
|
||||
}
|
||||
keySet.add(entry.id);
|
||||
|
||||
// Tenant index
|
||||
let tenantSet = this.indexByTenant.get(entry.tenantId);
|
||||
if (!tenantSet) {
|
||||
tenantSet = new Set();
|
||||
this.indexByTenant.set(entry.tenantId, tenantSet);
|
||||
}
|
||||
tenantSet.add(entry.id);
|
||||
|
||||
// Session index
|
||||
if (entry.sessionId) {
|
||||
let sessionSet = this.indexBySession.get(entry.sessionId);
|
||||
if (!sessionSet) {
|
||||
sessionSet = new Set();
|
||||
this.indexBySession.set(entry.sessionId, sessionSet);
|
||||
}
|
||||
sessionSet.add(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
private removeFromIndexes(entry: MemoryEntry): void {
|
||||
this.indexByKey.get(entry.key)?.delete(entry.id);
|
||||
this.indexByTenant.get(entry.tenantId)?.delete(entry.id);
|
||||
if (entry.sessionId) {
|
||||
this.indexBySession.get(entry.sessionId)?.delete(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
private cosineSimilarity(a: Float32Array, b: Float32Array): number {
|
||||
let dotProduct = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dotProduct += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
return denominator === 0 ? 0 : dotProduct / denominator;
|
||||
}
|
||||
}
|
||||
|
||||
// Tests
|
||||
describe('Memory Store', () => {
|
||||
let store: MemoryStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new MemoryStore(384);
|
||||
});
|
||||
|
||||
describe('Basic Operations', () => {
|
||||
it('should set and get memory entry', async () => {
|
||||
const entry = await store.set({
|
||||
id: 'mem-001',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'long-term',
|
||||
key: 'test-key',
|
||||
value: { data: 'test' },
|
||||
embedding: null
|
||||
});
|
||||
|
||||
const retrieved = await store.get('mem-001');
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.id).toBe('mem-001');
|
||||
expect(retrieved?.value).toEqual({ data: 'test' });
|
||||
});
|
||||
|
||||
it('should return null for non-existent entry', async () => {
|
||||
const entry = await store.get('non-existent');
|
||||
expect(entry).toBeNull();
|
||||
});
|
||||
|
||||
it('should increment access count on get', async () => {
|
||||
await store.set({
|
||||
id: 'mem-001',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'short-term',
|
||||
key: 'test',
|
||||
value: 'test',
|
||||
embedding: null
|
||||
});
|
||||
|
||||
await store.get('mem-001');
|
||||
await store.get('mem-001');
|
||||
const entry = await store.get('mem-001');
|
||||
|
||||
expect(entry?.metadata.accessCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should delete entry', async () => {
|
||||
await store.set({
|
||||
id: 'mem-001',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'short-term',
|
||||
key: 'test',
|
||||
value: 'test',
|
||||
embedding: null
|
||||
});
|
||||
|
||||
const deleted = await store.delete('mem-001');
|
||||
const entry = await store.get('mem-001');
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(entry).toBeNull();
|
||||
});
|
||||
|
||||
it('should return false when deleting non-existent entry', async () => {
|
||||
const deleted = await store.delete('non-existent');
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Key-based Operations', () => {
|
||||
it('should get entry by key and tenant', async () => {
|
||||
await store.set({
|
||||
id: 'mem-001',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'long-term',
|
||||
key: 'unique-key',
|
||||
value: 'value1',
|
||||
embedding: null
|
||||
});
|
||||
|
||||
await store.set({
|
||||
id: 'mem-002',
|
||||
tenantId: 'tenant-002',
|
||||
sessionId: null,
|
||||
type: 'long-term',
|
||||
key: 'unique-key',
|
||||
value: 'value2',
|
||||
embedding: null
|
||||
});
|
||||
|
||||
const entry1 = await store.getByKey('unique-key', 'tenant-001');
|
||||
const entry2 = await store.getByKey('unique-key', 'tenant-002');
|
||||
|
||||
expect(entry1?.value).toBe('value1');
|
||||
expect(entry2?.value).toBe('value2');
|
||||
});
|
||||
|
||||
it('should delete by key', async () => {
|
||||
await store.set({
|
||||
id: 'mem-001',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'long-term',
|
||||
key: 'to-delete',
|
||||
value: 'test',
|
||||
embedding: null
|
||||
});
|
||||
|
||||
const deleted = await store.deleteByKey('to-delete', 'tenant-001');
|
||||
const entry = await store.getByKey('to-delete', 'tenant-001');
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(entry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Listing Operations', () => {
|
||||
beforeEach(async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await store.set({
|
||||
id: `mem-${i}`,
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: 'session-001',
|
||||
type: 'short-term',
|
||||
key: `key-${i}`,
|
||||
value: `value-${i}`,
|
||||
embedding: null
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 5; i < 8; i++) {
|
||||
await store.set({
|
||||
id: `mem-${i}`,
|
||||
tenantId: 'tenant-002',
|
||||
sessionId: 'session-002',
|
||||
type: 'short-term',
|
||||
key: `key-${i}`,
|
||||
value: `value-${i}`,
|
||||
embedding: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should list entries by tenant', async () => {
|
||||
const entries = await store.listByTenant('tenant-001');
|
||||
expect(entries).toHaveLength(5);
|
||||
entries.forEach(e => expect(e.tenantId).toBe('tenant-001'));
|
||||
});
|
||||
|
||||
it('should list entries by session', async () => {
|
||||
const entries = await store.listBySession('session-001');
|
||||
expect(entries).toHaveLength(5);
|
||||
entries.forEach(e => expect(e.sessionId).toBe('session-001'));
|
||||
});
|
||||
|
||||
it('should respect limit parameter', async () => {
|
||||
const entries = await store.listByTenant('tenant-001', 3);
|
||||
expect(entries).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return empty array for unknown tenant', async () => {
|
||||
const entries = await store.listByTenant('unknown');
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vector Operations', () => {
|
||||
const createRandomEmbedding = (dim: number): Float32Array => {
|
||||
const arr = new Float32Array(dim);
|
||||
let norm = 0;
|
||||
for (let i = 0; i < dim; i++) {
|
||||
arr[i] = Math.random() - 0.5;
|
||||
norm += arr[i] * arr[i];
|
||||
}
|
||||
norm = Math.sqrt(norm);
|
||||
for (let i = 0; i < dim; i++) {
|
||||
arr[i] /= norm;
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
|
||||
it('should search by vector similarity', async () => {
|
||||
const embedding1 = createRandomEmbedding(384);
|
||||
const embedding2 = createRandomEmbedding(384);
|
||||
const embedding3 = createRandomEmbedding(384);
|
||||
|
||||
await store.set({
|
||||
id: 'vec-1',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'vector',
|
||||
key: 'doc-1',
|
||||
value: { text: 'Document 1' },
|
||||
embedding: embedding1
|
||||
});
|
||||
|
||||
await store.set({
|
||||
id: 'vec-2',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'vector',
|
||||
key: 'doc-2',
|
||||
value: { text: 'Document 2' },
|
||||
embedding: embedding2
|
||||
});
|
||||
|
||||
await store.set({
|
||||
id: 'vec-3',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'vector',
|
||||
key: 'doc-3',
|
||||
value: { text: 'Document 3' },
|
||||
embedding: embedding3
|
||||
});
|
||||
|
||||
const results = await store.search(embedding1, 'tenant-001', 2);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].entry.id).toBe('vec-1'); // Most similar to itself
|
||||
expect(results[0].score).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it('should throw error for dimension mismatch on set', async () => {
|
||||
const wrongDimensionEmbedding = new Float32Array(256);
|
||||
|
||||
await expect(store.set({
|
||||
id: 'vec-wrong',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'vector',
|
||||
key: 'wrong',
|
||||
value: {},
|
||||
embedding: wrongDimensionEmbedding
|
||||
})).rejects.toThrow('dimension mismatch');
|
||||
});
|
||||
|
||||
it('should throw error for dimension mismatch on search', async () => {
|
||||
const wrongDimensionQuery = new Float32Array(256);
|
||||
|
||||
await expect(store.search(wrongDimensionQuery, 'tenant-001'))
|
||||
.rejects.toThrow('dimension mismatch');
|
||||
});
|
||||
|
||||
it('should only search within tenant', async () => {
|
||||
const embedding = createRandomEmbedding(384);
|
||||
|
||||
await store.set({
|
||||
id: 'vec-1',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'vector',
|
||||
key: 'doc-1',
|
||||
value: {},
|
||||
embedding
|
||||
});
|
||||
|
||||
await store.set({
|
||||
id: 'vec-2',
|
||||
tenantId: 'tenant-002',
|
||||
sessionId: null,
|
||||
type: 'vector',
|
||||
key: 'doc-2',
|
||||
value: {},
|
||||
embedding
|
||||
});
|
||||
|
||||
const results = await store.search(embedding, 'tenant-001');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].entry.tenantId).toBe('tenant-001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Expiration', () => {
|
||||
it('should expire entries', async () => {
|
||||
const pastDate = new Date(Date.now() - 1000);
|
||||
const futureDate = new Date(Date.now() + 100000);
|
||||
|
||||
await store.set({
|
||||
id: 'expired',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'short-term',
|
||||
key: 'expired',
|
||||
value: 'test',
|
||||
embedding: null,
|
||||
metadata: { expiresAt: pastDate }
|
||||
});
|
||||
|
||||
await store.set({
|
||||
id: 'not-expired',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'short-term',
|
||||
key: 'not-expired',
|
||||
value: 'test',
|
||||
embedding: null,
|
||||
metadata: { expiresAt: futureDate }
|
||||
});
|
||||
|
||||
const expiredCount = await store.expire();
|
||||
|
||||
expect(expiredCount).toBe(1);
|
||||
expect(await store.get('expired')).toBeNull();
|
||||
expect(await store.get('not-expired')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Clear Operations', () => {
|
||||
beforeEach(async () => {
|
||||
await store.set({
|
||||
id: 'mem-1',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'short-term',
|
||||
key: 'key-1',
|
||||
value: 'test',
|
||||
embedding: null
|
||||
});
|
||||
|
||||
await store.set({
|
||||
id: 'mem-2',
|
||||
tenantId: 'tenant-002',
|
||||
sessionId: null,
|
||||
type: 'short-term',
|
||||
key: 'key-2',
|
||||
value: 'test',
|
||||
embedding: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear all entries', async () => {
|
||||
const cleared = await store.clear();
|
||||
|
||||
expect(cleared).toBe(2);
|
||||
expect(store.size()).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear entries by tenant', async () => {
|
||||
const cleared = await store.clear('tenant-001');
|
||||
|
||||
expect(cleared).toBe(1);
|
||||
expect(store.sizeByTenant('tenant-001')).toBe(0);
|
||||
expect(store.sizeByTenant('tenant-002')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Size Operations', () => {
|
||||
it('should return total size', async () => {
|
||||
await store.set({
|
||||
id: 'mem-1',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'short-term',
|
||||
key: 'k1',
|
||||
value: 'v1',
|
||||
embedding: null
|
||||
});
|
||||
|
||||
await store.set({
|
||||
id: 'mem-2',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'short-term',
|
||||
key: 'k2',
|
||||
value: 'v2',
|
||||
embedding: null
|
||||
});
|
||||
|
||||
expect(store.size()).toBe(2);
|
||||
});
|
||||
|
||||
it('should return size by tenant', async () => {
|
||||
await store.set({
|
||||
id: 'mem-1',
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: null,
|
||||
type: 'short-term',
|
||||
key: 'k1',
|
||||
value: 'v1',
|
||||
embedding: null
|
||||
});
|
||||
|
||||
await store.set({
|
||||
id: 'mem-2',
|
||||
tenantId: 'tenant-002',
|
||||
sessionId: null,
|
||||
type: 'short-term',
|
||||
key: 'k2',
|
||||
value: 'v2',
|
||||
embedding: null
|
||||
});
|
||||
|
||||
expect(store.sizeByTenant('tenant-001')).toBe(1);
|
||||
expect(store.sizeByTenant('tenant-002')).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Factory Integration', () => {
|
||||
let store: MemoryStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new MemoryStore(384);
|
||||
});
|
||||
|
||||
it('should create memory from factory data', async () => {
|
||||
const factoryMemory = createMemory({
|
||||
key: 'factory-key',
|
||||
value: { factory: 'data' },
|
||||
type: 'long-term'
|
||||
});
|
||||
|
||||
const entry = await store.set({
|
||||
id: factoryMemory.id,
|
||||
tenantId: factoryMemory.tenantId,
|
||||
sessionId: factoryMemory.sessionId,
|
||||
type: factoryMemory.type,
|
||||
key: factoryMemory.key,
|
||||
value: factoryMemory.value,
|
||||
embedding: factoryMemory.embedding
|
||||
});
|
||||
|
||||
expect(entry.key).toBe('factory-key');
|
||||
expect(entry.type).toBe('long-term');
|
||||
});
|
||||
|
||||
it('should create vector memory from factory data', async () => {
|
||||
const factoryMemory = createVectorMemory(384, {
|
||||
key: 'vector-key',
|
||||
value: { text: 'Test document' }
|
||||
});
|
||||
|
||||
const entry = await store.set({
|
||||
id: factoryMemory.id,
|
||||
tenantId: factoryMemory.tenantId,
|
||||
sessionId: factoryMemory.sessionId,
|
||||
type: factoryMemory.type,
|
||||
key: factoryMemory.key,
|
||||
value: factoryMemory.value,
|
||||
embedding: factoryMemory.embedding
|
||||
});
|
||||
|
||||
expect(entry.embedding).not.toBeNull();
|
||||
expect(entry.embedding?.length).toBe(384);
|
||||
expect(entry.type).toBe('vector');
|
||||
});
|
||||
});
|
||||
680
npm/packages/ruvbot/tests/unit/domain/session.test.ts
Normal file
680
npm/packages/ruvbot/tests/unit/domain/session.test.ts
Normal file
@@ -0,0 +1,680 @@
|
||||
/**
|
||||
* Session Domain Entity - Unit Tests
|
||||
*
|
||||
* Tests for Session lifecycle, context management, and conversation handling
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createSession, createSessionWithHistory, type Session, type ConversationMessage } from '../../factories';
|
||||
|
||||
// Session Entity Types
|
||||
interface SessionState {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
channelId: string;
|
||||
threadTs: string;
|
||||
status: 'active' | 'paused' | 'completed' | 'error';
|
||||
context: SessionContext;
|
||||
metadata: SessionMetadata;
|
||||
}
|
||||
|
||||
interface SessionContext {
|
||||
conversationHistory: ConversationMessage[];
|
||||
workingDirectory: string;
|
||||
activeAgents: string[];
|
||||
variables: Map<string, unknown>;
|
||||
artifacts: Map<string, Artifact>;
|
||||
}
|
||||
|
||||
interface Artifact {
|
||||
id: string;
|
||||
type: 'code' | 'file' | 'image' | 'document';
|
||||
content: unknown;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface SessionMetadata {
|
||||
createdAt: Date;
|
||||
lastActiveAt: Date;
|
||||
messageCount: number;
|
||||
tokenUsage: number;
|
||||
estimatedCost: number;
|
||||
}
|
||||
|
||||
// Mock Session class for testing
|
||||
class SessionEntity {
|
||||
private state: SessionState;
|
||||
private eventLog: Array<{ type: string; payload: unknown; timestamp: Date }> = [];
|
||||
private readonly maxHistoryLength = 100;
|
||||
|
||||
constructor(initialState: Partial<SessionState>) {
|
||||
this.state = {
|
||||
id: initialState.id || `session-${Date.now()}`,
|
||||
tenantId: initialState.tenantId || 'default-tenant',
|
||||
userId: initialState.userId || 'unknown-user',
|
||||
channelId: initialState.channelId || 'unknown-channel',
|
||||
threadTs: initialState.threadTs || `${Date.now()}.000000`,
|
||||
status: initialState.status || 'active',
|
||||
context: {
|
||||
conversationHistory: initialState.context?.conversationHistory || [],
|
||||
workingDirectory: initialState.context?.workingDirectory || '/workspace',
|
||||
activeAgents: initialState.context?.activeAgents || [],
|
||||
variables: new Map(Object.entries(initialState.context?.variables || {})),
|
||||
artifacts: new Map()
|
||||
},
|
||||
metadata: {
|
||||
createdAt: initialState.metadata?.createdAt || new Date(),
|
||||
lastActiveAt: initialState.metadata?.lastActiveAt || new Date(),
|
||||
messageCount: initialState.metadata?.messageCount || 0,
|
||||
tokenUsage: initialState.metadata?.tokenUsage || 0,
|
||||
estimatedCost: initialState.metadata?.estimatedCost || 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.state.id;
|
||||
}
|
||||
|
||||
getTenantId(): string {
|
||||
return this.state.tenantId;
|
||||
}
|
||||
|
||||
getUserId(): string {
|
||||
return this.state.userId;
|
||||
}
|
||||
|
||||
getChannelId(): string {
|
||||
return this.state.channelId;
|
||||
}
|
||||
|
||||
getThreadTs(): string {
|
||||
return this.state.threadTs;
|
||||
}
|
||||
|
||||
getStatus(): SessionState['status'] {
|
||||
return this.state.status;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.state.status === 'active';
|
||||
}
|
||||
|
||||
getConversationHistory(): ConversationMessage[] {
|
||||
return [...this.state.context.conversationHistory];
|
||||
}
|
||||
|
||||
getActiveAgents(): string[] {
|
||||
return [...this.state.context.activeAgents];
|
||||
}
|
||||
|
||||
getWorkingDirectory(): string {
|
||||
return this.state.context.workingDirectory;
|
||||
}
|
||||
|
||||
getVariable(key: string): unknown {
|
||||
return this.state.context.variables.get(key);
|
||||
}
|
||||
|
||||
getMetadata(): SessionMetadata {
|
||||
return { ...this.state.metadata };
|
||||
}
|
||||
|
||||
async addMessage(message: Omit<ConversationMessage, 'timestamp'>): Promise<void> {
|
||||
if (this.state.status !== 'active') {
|
||||
throw new Error(`Cannot add message to ${this.state.status} session`);
|
||||
}
|
||||
|
||||
const fullMessage: ConversationMessage = {
|
||||
...message,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
this.state.context.conversationHistory.push(fullMessage);
|
||||
this.state.metadata.messageCount++;
|
||||
this.state.metadata.lastActiveAt = new Date();
|
||||
|
||||
// Trim history if too long
|
||||
if (this.state.context.conversationHistory.length > this.maxHistoryLength) {
|
||||
this.state.context.conversationHistory.shift();
|
||||
}
|
||||
|
||||
this.logEvent('message_added', { role: message.role });
|
||||
}
|
||||
|
||||
async addUserMessage(content: string): Promise<void> {
|
||||
await this.addMessage({ role: 'user', content });
|
||||
}
|
||||
|
||||
async addAssistantMessage(content: string, agentId?: string): Promise<void> {
|
||||
await this.addMessage({ role: 'assistant', content, agentId });
|
||||
}
|
||||
|
||||
async addSystemMessage(content: string): Promise<void> {
|
||||
await this.addMessage({ role: 'system', content });
|
||||
}
|
||||
|
||||
getLastMessage(): ConversationMessage | undefined {
|
||||
const history = this.state.context.conversationHistory;
|
||||
return history.length > 0 ? history[history.length - 1] : undefined;
|
||||
}
|
||||
|
||||
getMessageCount(): number {
|
||||
return this.state.metadata.messageCount;
|
||||
}
|
||||
|
||||
async attachAgent(agentId: string): Promise<void> {
|
||||
if (!this.state.context.activeAgents.includes(agentId)) {
|
||||
this.state.context.activeAgents.push(agentId);
|
||||
this.logEvent('agent_attached', { agentId });
|
||||
}
|
||||
}
|
||||
|
||||
async detachAgent(agentId: string): Promise<void> {
|
||||
const index = this.state.context.activeAgents.indexOf(agentId);
|
||||
if (index !== -1) {
|
||||
this.state.context.activeAgents.splice(index, 1);
|
||||
this.logEvent('agent_detached', { agentId });
|
||||
}
|
||||
}
|
||||
|
||||
setVariable(key: string, value: unknown): void {
|
||||
this.state.context.variables.set(key, value);
|
||||
this.logEvent('variable_set', { key });
|
||||
}
|
||||
|
||||
deleteVariable(key: string): boolean {
|
||||
const deleted = this.state.context.variables.delete(key);
|
||||
if (deleted) {
|
||||
this.logEvent('variable_deleted', { key });
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
setWorkingDirectory(path: string): void {
|
||||
this.state.context.workingDirectory = path;
|
||||
this.logEvent('working_directory_changed', { path });
|
||||
}
|
||||
|
||||
addArtifact(artifact: Omit<Artifact, 'createdAt'>): void {
|
||||
const fullArtifact: Artifact = {
|
||||
...artifact,
|
||||
createdAt: new Date()
|
||||
};
|
||||
this.state.context.artifacts.set(artifact.id, fullArtifact);
|
||||
this.logEvent('artifact_added', { artifactId: artifact.id, type: artifact.type });
|
||||
}
|
||||
|
||||
getArtifact(id: string): Artifact | undefined {
|
||||
return this.state.context.artifacts.get(id);
|
||||
}
|
||||
|
||||
listArtifacts(): Artifact[] {
|
||||
return Array.from(this.state.context.artifacts.values());
|
||||
}
|
||||
|
||||
updateTokenUsage(tokens: number, cost: number): void {
|
||||
this.state.metadata.tokenUsage += tokens;
|
||||
this.state.metadata.estimatedCost += cost;
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
if (this.state.status !== 'active') {
|
||||
throw new Error(`Cannot pause ${this.state.status} session`);
|
||||
}
|
||||
this.state.status = 'paused';
|
||||
this.logEvent('paused', {});
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
if (this.state.status !== 'paused') {
|
||||
throw new Error(`Cannot resume ${this.state.status} session`);
|
||||
}
|
||||
this.state.status = 'active';
|
||||
this.state.metadata.lastActiveAt = new Date();
|
||||
this.logEvent('resumed', {});
|
||||
}
|
||||
|
||||
async complete(): Promise<void> {
|
||||
if (this.state.status === 'completed') {
|
||||
return; // Already completed
|
||||
}
|
||||
this.state.status = 'completed';
|
||||
this.state.context.activeAgents = [];
|
||||
this.logEvent('completed', {});
|
||||
}
|
||||
|
||||
async fail(error: Error): Promise<void> {
|
||||
this.state.status = 'error';
|
||||
this.logEvent('failed', { error: error.message });
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
this.state.context.conversationHistory = [];
|
||||
this.state.metadata.messageCount = 0;
|
||||
this.logEvent('history_cleared', {});
|
||||
}
|
||||
|
||||
getEventLog(): Array<{ type: string; payload: unknown; timestamp: Date }> {
|
||||
return [...this.eventLog];
|
||||
}
|
||||
|
||||
toJSON(): SessionState {
|
||||
return {
|
||||
...this.state,
|
||||
context: {
|
||||
...this.state.context,
|
||||
variables: Object.fromEntries(this.state.context.variables) as unknown as Map<string, unknown>,
|
||||
artifacts: Object.fromEntries(this.state.context.artifacts) as unknown as Map<string, Artifact>
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private logEvent(type: string, payload: unknown): void {
|
||||
this.eventLog.push({ type, payload, timestamp: new Date() });
|
||||
}
|
||||
}
|
||||
|
||||
// Tests
|
||||
describe('Session Domain Entity', () => {
|
||||
describe('Construction', () => {
|
||||
it('should create session with default values', () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
expect(session.getId()).toBeDefined();
|
||||
expect(session.getStatus()).toBe('active');
|
||||
expect(session.getConversationHistory()).toEqual([]);
|
||||
expect(session.getActiveAgents()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create session with provided values', () => {
|
||||
const session = new SessionEntity({
|
||||
id: 'session-001',
|
||||
tenantId: 'tenant-001',
|
||||
userId: 'user-001',
|
||||
channelId: 'C12345',
|
||||
threadTs: '1234567890.123456'
|
||||
});
|
||||
|
||||
expect(session.getId()).toBe('session-001');
|
||||
expect(session.getTenantId()).toBe('tenant-001');
|
||||
expect(session.getUserId()).toBe('user-001');
|
||||
expect(session.getChannelId()).toBe('C12345');
|
||||
expect(session.getThreadTs()).toBe('1234567890.123456');
|
||||
});
|
||||
|
||||
it('should initialize metadata correctly', () => {
|
||||
const session = new SessionEntity({});
|
||||
const metadata = session.getMetadata();
|
||||
|
||||
expect(metadata.messageCount).toBe(0);
|
||||
expect(metadata.tokenUsage).toBe(0);
|
||||
expect(metadata.estimatedCost).toBe(0);
|
||||
expect(metadata.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Management', () => {
|
||||
it('should be active by default', () => {
|
||||
const session = new SessionEntity({});
|
||||
expect(session.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should pause active session', async () => {
|
||||
const session = new SessionEntity({ status: 'active' });
|
||||
|
||||
await session.pause();
|
||||
|
||||
expect(session.getStatus()).toBe('paused');
|
||||
expect(session.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should resume paused session', async () => {
|
||||
const session = new SessionEntity({ status: 'paused' });
|
||||
|
||||
await session.resume();
|
||||
|
||||
expect(session.getStatus()).toBe('active');
|
||||
expect(session.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should complete session', async () => {
|
||||
const session = new SessionEntity({ status: 'active' });
|
||||
await session.attachAgent('agent-001');
|
||||
|
||||
await session.complete();
|
||||
|
||||
expect(session.getStatus()).toBe('completed');
|
||||
expect(session.getActiveAgents()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should fail session', async () => {
|
||||
const session = new SessionEntity({ status: 'active' });
|
||||
|
||||
await session.fail(new Error('Something went wrong'));
|
||||
|
||||
expect(session.getStatus()).toBe('error');
|
||||
});
|
||||
|
||||
it('should throw when pausing non-active session', async () => {
|
||||
const session = new SessionEntity({ status: 'paused' });
|
||||
|
||||
await expect(session.pause()).rejects.toThrow('Cannot pause');
|
||||
});
|
||||
|
||||
it('should throw when resuming non-paused session', async () => {
|
||||
const session = new SessionEntity({ status: 'active' });
|
||||
|
||||
await expect(session.resume()).rejects.toThrow('Cannot resume');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conversation History', () => {
|
||||
it('should add user message', async () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
await session.addUserMessage('Hello!');
|
||||
|
||||
const history = session.getConversationHistory();
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].role).toBe('user');
|
||||
expect(history[0].content).toBe('Hello!');
|
||||
});
|
||||
|
||||
it('should add assistant message', async () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
await session.addAssistantMessage('Hi there!', 'agent-001');
|
||||
|
||||
const history = session.getConversationHistory();
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].role).toBe('assistant');
|
||||
expect(history[0].agentId).toBe('agent-001');
|
||||
});
|
||||
|
||||
it('should add system message', async () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
await session.addSystemMessage('System initialized');
|
||||
|
||||
const history = session.getConversationHistory();
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].role).toBe('system');
|
||||
});
|
||||
|
||||
it('should get last message', async () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
await session.addUserMessage('First');
|
||||
await session.addUserMessage('Second');
|
||||
await session.addAssistantMessage('Third');
|
||||
|
||||
const lastMessage = session.getLastMessage();
|
||||
expect(lastMessage?.content).toBe('Third');
|
||||
expect(lastMessage?.role).toBe('assistant');
|
||||
});
|
||||
|
||||
it('should return undefined for empty history', () => {
|
||||
const session = new SessionEntity({});
|
||||
expect(session.getLastMessage()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should increment message count', async () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
await session.addUserMessage('Message 1');
|
||||
await session.addAssistantMessage('Message 2');
|
||||
|
||||
expect(session.getMessageCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('should update last active time on message', async () => {
|
||||
const session = new SessionEntity({});
|
||||
const before = session.getMetadata().lastActiveAt;
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await session.addUserMessage('Test');
|
||||
|
||||
const after = session.getMetadata().lastActiveAt;
|
||||
expect(after.getTime()).toBeGreaterThan(before.getTime());
|
||||
});
|
||||
|
||||
it('should throw when adding message to non-active session', async () => {
|
||||
const session = new SessionEntity({ status: 'completed' });
|
||||
|
||||
await expect(session.addUserMessage('Test'))
|
||||
.rejects.toThrow('Cannot add message');
|
||||
});
|
||||
|
||||
it('should clear history', async () => {
|
||||
const session = new SessionEntity({});
|
||||
await session.addUserMessage('Test 1');
|
||||
await session.addUserMessage('Test 2');
|
||||
|
||||
session.clearHistory();
|
||||
|
||||
expect(session.getConversationHistory()).toHaveLength(0);
|
||||
expect(session.getMessageCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Management', () => {
|
||||
it('should attach agent', async () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
await session.attachAgent('agent-001');
|
||||
|
||||
expect(session.getActiveAgents()).toContain('agent-001');
|
||||
});
|
||||
|
||||
it('should not duplicate attached agent', async () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
await session.attachAgent('agent-001');
|
||||
await session.attachAgent('agent-001');
|
||||
|
||||
expect(session.getActiveAgents()).toEqual(['agent-001']);
|
||||
});
|
||||
|
||||
it('should detach agent', async () => {
|
||||
const session = new SessionEntity({});
|
||||
await session.attachAgent('agent-001');
|
||||
await session.attachAgent('agent-002');
|
||||
|
||||
await session.detachAgent('agent-001');
|
||||
|
||||
expect(session.getActiveAgents()).toEqual(['agent-002']);
|
||||
});
|
||||
|
||||
it('should handle detaching non-existent agent gracefully', async () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
await expect(session.detachAgent('non-existent')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Variables', () => {
|
||||
it('should set and get variable', () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
session.setVariable('key', 'value');
|
||||
|
||||
expect(session.getVariable('key')).toBe('value');
|
||||
});
|
||||
|
||||
it('should handle complex variable values', () => {
|
||||
const session = new SessionEntity({});
|
||||
const complexValue = { nested: { data: [1, 2, 3] } };
|
||||
|
||||
session.setVariable('complex', complexValue);
|
||||
|
||||
expect(session.getVariable('complex')).toEqual(complexValue);
|
||||
});
|
||||
|
||||
it('should delete variable', () => {
|
||||
const session = new SessionEntity({});
|
||||
session.setVariable('toDelete', 'value');
|
||||
|
||||
const deleted = session.deleteVariable('toDelete');
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(session.getVariable('toDelete')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false when deleting non-existent variable', () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
const deleted = session.deleteVariable('nonExistent');
|
||||
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Working Directory', () => {
|
||||
it('should get default working directory', () => {
|
||||
const session = new SessionEntity({});
|
||||
expect(session.getWorkingDirectory()).toBe('/workspace');
|
||||
});
|
||||
|
||||
it('should set working directory', () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
session.setWorkingDirectory('/new/path');
|
||||
|
||||
expect(session.getWorkingDirectory()).toBe('/new/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Artifacts', () => {
|
||||
it('should add artifact', () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
session.addArtifact({
|
||||
id: 'artifact-001',
|
||||
type: 'code',
|
||||
content: 'console.log("Hello")'
|
||||
});
|
||||
|
||||
const artifact = session.getArtifact('artifact-001');
|
||||
expect(artifact).toBeDefined();
|
||||
expect(artifact?.type).toBe('code');
|
||||
expect(artifact?.content).toBe('console.log("Hello")');
|
||||
});
|
||||
|
||||
it('should list all artifacts', () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
session.addArtifact({ id: 'a1', type: 'code', content: 'code' });
|
||||
session.addArtifact({ id: 'a2', type: 'file', content: 'file' });
|
||||
|
||||
const artifacts = session.listArtifacts();
|
||||
expect(artifacts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent artifact', () => {
|
||||
const session = new SessionEntity({});
|
||||
expect(session.getArtifact('non-existent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Usage', () => {
|
||||
it('should update token usage', () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
session.updateTokenUsage(1000, 0.01);
|
||||
|
||||
const metadata = session.getMetadata();
|
||||
expect(metadata.tokenUsage).toBe(1000);
|
||||
expect(metadata.estimatedCost).toBe(0.01);
|
||||
});
|
||||
|
||||
it('should accumulate token usage', () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
session.updateTokenUsage(500, 0.005);
|
||||
session.updateTokenUsage(500, 0.005);
|
||||
|
||||
const metadata = session.getMetadata();
|
||||
expect(metadata.tokenUsage).toBe(1000);
|
||||
expect(metadata.estimatedCost).toBeCloseTo(0.01, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Logging', () => {
|
||||
it('should log events during lifecycle', async () => {
|
||||
const session = new SessionEntity({});
|
||||
|
||||
await session.addUserMessage('Hello');
|
||||
await session.attachAgent('agent-001');
|
||||
await session.pause();
|
||||
await session.resume();
|
||||
|
||||
const events = session.getEventLog();
|
||||
expect(events.length).toBeGreaterThanOrEqual(4);
|
||||
expect(events.some(e => e.type === 'message_added')).toBe(true);
|
||||
expect(events.some(e => e.type === 'agent_attached')).toBe(true);
|
||||
expect(events.some(e => e.type === 'paused')).toBe(true);
|
||||
expect(events.some(e => e.type === 'resumed')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Serialization', () => {
|
||||
it('should serialize to JSON', async () => {
|
||||
const session = new SessionEntity({
|
||||
id: 'session-001',
|
||||
tenantId: 'tenant-001'
|
||||
});
|
||||
await session.addUserMessage('Test');
|
||||
session.setVariable('key', 'value');
|
||||
|
||||
const json = session.toJSON();
|
||||
|
||||
expect(json.id).toBe('session-001');
|
||||
expect(json.tenantId).toBe('tenant-001');
|
||||
expect(json.context.conversationHistory).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Factory Integration', () => {
|
||||
it('should create session from factory data', () => {
|
||||
const factorySession = createSession({
|
||||
tenantId: 'tenant-factory',
|
||||
userId: 'user-factory'
|
||||
});
|
||||
|
||||
const session = new SessionEntity({
|
||||
id: factorySession.id,
|
||||
tenantId: factorySession.tenantId,
|
||||
userId: factorySession.userId,
|
||||
channelId: factorySession.channelId,
|
||||
threadTs: factorySession.threadTs,
|
||||
context: {
|
||||
conversationHistory: factorySession.context.conversationHistory,
|
||||
workingDirectory: factorySession.context.workingDirectory,
|
||||
activeAgents: factorySession.context.activeAgents
|
||||
}
|
||||
});
|
||||
|
||||
expect(session.getTenantId()).toBe('tenant-factory');
|
||||
expect(session.getUserId()).toBe('user-factory');
|
||||
});
|
||||
|
||||
it('should create session with history from factory', () => {
|
||||
const factorySession = createSessionWithHistory(5);
|
||||
|
||||
const session = new SessionEntity({
|
||||
id: factorySession.id,
|
||||
context: {
|
||||
conversationHistory: factorySession.context.conversationHistory
|
||||
},
|
||||
metadata: {
|
||||
messageCount: factorySession.metadata.messageCount
|
||||
}
|
||||
});
|
||||
|
||||
expect(session.getConversationHistory()).toHaveLength(5);
|
||||
expect(session.getMessageCount()).toBe(5);
|
||||
});
|
||||
});
|
||||
762
npm/packages/ruvbot/tests/unit/domain/skill.test.ts
Normal file
762
npm/packages/ruvbot/tests/unit/domain/skill.test.ts
Normal file
@@ -0,0 +1,762 @@
|
||||
/**
|
||||
* Skill Domain Entity - Unit Tests
|
||||
*
|
||||
* Tests for Skill registration, execution, and validation
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createSkill, type Skill } from '../../factories';
|
||||
|
||||
// Skill Types
|
||||
interface SkillDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
inputSchema: JSONSchema;
|
||||
outputSchema: JSONSchema;
|
||||
executor: string;
|
||||
timeout: number;
|
||||
retries: number;
|
||||
metadata: SkillMetadata;
|
||||
}
|
||||
|
||||
interface JSONSchema {
|
||||
type: string;
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean;
|
||||
}
|
||||
|
||||
interface SkillMetadata {
|
||||
author: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
usageCount: number;
|
||||
averageLatency: number;
|
||||
successRate: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface SkillExecutionContext {
|
||||
tenantId: string;
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface SkillExecutionResult {
|
||||
success: boolean;
|
||||
output: unknown;
|
||||
error?: string;
|
||||
latency: number;
|
||||
tokensUsed?: number;
|
||||
}
|
||||
|
||||
// Mock Skill Registry class for testing
|
||||
class SkillRegistry {
|
||||
private skills: Map<string, SkillDefinition> = new Map();
|
||||
private executors: Map<string, (input: unknown, context: SkillExecutionContext) => Promise<unknown>> = new Map();
|
||||
|
||||
async register(skill: Omit<SkillDefinition, 'metadata'> & { metadata?: Partial<SkillMetadata> }): Promise<SkillDefinition> {
|
||||
if (this.skills.has(skill.id)) {
|
||||
throw new Error(`Skill ${skill.id} is already registered`);
|
||||
}
|
||||
|
||||
this.validateSchema(skill.inputSchema);
|
||||
this.validateSchema(skill.outputSchema);
|
||||
|
||||
const fullSkill: SkillDefinition = {
|
||||
...skill,
|
||||
metadata: {
|
||||
author: skill.metadata?.author || 'unknown',
|
||||
createdAt: skill.metadata?.createdAt || new Date(),
|
||||
updatedAt: new Date(),
|
||||
usageCount: skill.metadata?.usageCount || 0,
|
||||
averageLatency: skill.metadata?.averageLatency || 0,
|
||||
successRate: skill.metadata?.successRate || 1,
|
||||
tags: skill.metadata?.tags || []
|
||||
}
|
||||
};
|
||||
|
||||
this.skills.set(skill.id, fullSkill);
|
||||
return fullSkill;
|
||||
}
|
||||
|
||||
async unregister(skillId: string): Promise<boolean> {
|
||||
return this.skills.delete(skillId);
|
||||
}
|
||||
|
||||
async get(skillId: string): Promise<SkillDefinition | null> {
|
||||
return this.skills.get(skillId) || null;
|
||||
}
|
||||
|
||||
async getByName(name: string, version?: string): Promise<SkillDefinition | null> {
|
||||
for (const skill of this.skills.values()) {
|
||||
if (skill.name === name) {
|
||||
if (!version || skill.version === version) {
|
||||
return skill;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async list(tags?: string[]): Promise<SkillDefinition[]> {
|
||||
let skills = Array.from(this.skills.values());
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
skills = skills.filter(s =>
|
||||
tags.some(tag => s.metadata.tags.includes(tag))
|
||||
);
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
async listByExecutorType(type: string): Promise<SkillDefinition[]> {
|
||||
return Array.from(this.skills.values()).filter(s =>
|
||||
s.executor.startsWith(type)
|
||||
);
|
||||
}
|
||||
|
||||
registerExecutor(
|
||||
pattern: string,
|
||||
executor: (input: unknown, context: SkillExecutionContext) => Promise<unknown>
|
||||
): void {
|
||||
this.executors.set(pattern, executor);
|
||||
}
|
||||
|
||||
async execute(
|
||||
skillId: string,
|
||||
input: unknown,
|
||||
context: SkillExecutionContext
|
||||
): Promise<SkillExecutionResult> {
|
||||
const skill = await this.get(skillId);
|
||||
if (!skill) {
|
||||
return {
|
||||
success: false,
|
||||
output: null,
|
||||
error: `Skill ${skillId} not found`,
|
||||
latency: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Validate input
|
||||
const validationError = this.validateInput(input, skill.inputSchema);
|
||||
if (validationError) {
|
||||
return {
|
||||
success: false,
|
||||
output: null,
|
||||
error: validationError,
|
||||
latency: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Find executor
|
||||
const executor = this.findExecutor(skill.executor);
|
||||
if (!executor) {
|
||||
return {
|
||||
success: false,
|
||||
output: null,
|
||||
error: `No executor found for ${skill.executor}`,
|
||||
latency: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Execute with timeout
|
||||
const startTime = performance.now();
|
||||
const timeout = context.timeout || skill.timeout;
|
||||
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
executor(input, context),
|
||||
this.createTimeout(timeout)
|
||||
]);
|
||||
|
||||
// Use performance.now() for sub-millisecond precision, ensure minimum 0.001ms
|
||||
const latency = Math.max(performance.now() - startTime, 0.001);
|
||||
|
||||
// Update metrics
|
||||
this.updateMetrics(skill, true, latency);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: result,
|
||||
latency
|
||||
};
|
||||
} catch (error) {
|
||||
const latency = Math.max(performance.now() - startTime, 0.001);
|
||||
|
||||
// Update metrics
|
||||
this.updateMetrics(skill, false, latency);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: null,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
latency
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async executeWithRetry(
|
||||
skillId: string,
|
||||
input: unknown,
|
||||
context: SkillExecutionContext,
|
||||
maxRetries?: number
|
||||
): Promise<SkillExecutionResult> {
|
||||
const skill = await this.get(skillId);
|
||||
const retries = maxRetries ?? skill?.retries ?? 0;
|
||||
|
||||
let lastResult: SkillExecutionResult | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const result = await this.execute(skillId, input, context);
|
||||
|
||||
if (result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
lastResult = result;
|
||||
|
||||
// Exponential backoff
|
||||
if (attempt < retries) {
|
||||
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100));
|
||||
}
|
||||
}
|
||||
|
||||
return lastResult!;
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.skills.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.skills.clear();
|
||||
}
|
||||
|
||||
private validateSchema(schema: JSONSchema): void {
|
||||
if (!schema.type) {
|
||||
throw new Error('Schema must have a type');
|
||||
}
|
||||
|
||||
const validTypes = ['object', 'array', 'string', 'number', 'boolean', 'null'];
|
||||
if (!validTypes.includes(schema.type)) {
|
||||
throw new Error(`Invalid schema type: ${schema.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateInput(input: unknown, schema: JSONSchema): string | null {
|
||||
if (schema.type === 'object') {
|
||||
if (typeof input !== 'object' || input === null) {
|
||||
return 'Input must be an object';
|
||||
}
|
||||
|
||||
const inputObj = input as Record<string, unknown>;
|
||||
|
||||
// Check required fields
|
||||
if (schema.required) {
|
||||
for (const field of schema.required) {
|
||||
if (!(field in inputObj)) {
|
||||
return `Missing required field: ${field}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate property types if defined
|
||||
if (schema.properties) {
|
||||
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
||||
if (key in inputObj) {
|
||||
const propError = this.validateProperty(inputObj[key], propSchema as JSONSchema);
|
||||
if (propError) {
|
||||
return `Invalid ${key}: ${propError}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private validateProperty(value: unknown, schema: JSONSchema): string | null {
|
||||
const type = schema.type;
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
if (typeof value !== 'string') return 'must be a string';
|
||||
break;
|
||||
case 'number':
|
||||
if (typeof value !== 'number') return 'must be a number';
|
||||
break;
|
||||
case 'boolean':
|
||||
if (typeof value !== 'boolean') return 'must be a boolean';
|
||||
break;
|
||||
case 'array':
|
||||
if (!Array.isArray(value)) return 'must be an array';
|
||||
break;
|
||||
case 'object':
|
||||
if (typeof value !== 'object' || value === null) return 'must be an object';
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private findExecutor(
|
||||
executorUri: string
|
||||
): ((input: unknown, context: SkillExecutionContext) => Promise<unknown>) | null {
|
||||
for (const [pattern, executor] of this.executors) {
|
||||
if (executorUri.startsWith(pattern)) {
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async createTimeout(ms: number): Promise<never> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Skill execution timed out')), ms);
|
||||
});
|
||||
}
|
||||
|
||||
private updateMetrics(skill: SkillDefinition, success: boolean, latency: number): void {
|
||||
const previousCount = skill.metadata.usageCount;
|
||||
const totalExecutions = previousCount + 1;
|
||||
const totalLatency = skill.metadata.averageLatency * previousCount + latency;
|
||||
|
||||
// Calculate success count from previous executions
|
||||
const previousSuccessCount = skill.metadata.successRate * previousCount;
|
||||
const newSuccessCount = success ? previousSuccessCount + 1 : previousSuccessCount;
|
||||
|
||||
skill.metadata.usageCount = totalExecutions;
|
||||
skill.metadata.averageLatency = totalLatency / totalExecutions;
|
||||
skill.metadata.successRate = newSuccessCount / totalExecutions;
|
||||
skill.metadata.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// Tests
|
||||
describe('Skill Registry', () => {
|
||||
let registry: SkillRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new SkillRegistry();
|
||||
});
|
||||
|
||||
describe('Registration', () => {
|
||||
it('should register a skill', async () => {
|
||||
const skill = await registry.register({
|
||||
id: 'skill-001',
|
||||
name: 'test-skill',
|
||||
version: '1.0.0',
|
||||
description: 'A test skill',
|
||||
inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
|
||||
outputSchema: { type: 'object', properties: { output: { type: 'string' } } },
|
||||
executor: 'native://test',
|
||||
timeout: 30000,
|
||||
retries: 3
|
||||
});
|
||||
|
||||
expect(skill.id).toBe('skill-001');
|
||||
expect(skill.name).toBe('test-skill');
|
||||
expect(skill.metadata.usageCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should throw error when registering duplicate skill', async () => {
|
||||
await registry.register({
|
||||
id: 'skill-001',
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
description: 'Test',
|
||||
inputSchema: { type: 'object' },
|
||||
outputSchema: { type: 'object' },
|
||||
executor: 'native://test',
|
||||
timeout: 30000,
|
||||
retries: 0
|
||||
});
|
||||
|
||||
await expect(registry.register({
|
||||
id: 'skill-001',
|
||||
name: 'duplicate',
|
||||
version: '1.0.0',
|
||||
description: 'Duplicate',
|
||||
inputSchema: { type: 'object' },
|
||||
outputSchema: { type: 'object' },
|
||||
executor: 'native://test',
|
||||
timeout: 30000,
|
||||
retries: 0
|
||||
})).rejects.toThrow('already registered');
|
||||
});
|
||||
|
||||
it('should throw error for invalid schema type', async () => {
|
||||
await expect(registry.register({
|
||||
id: 'skill-001',
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
description: 'Test',
|
||||
inputSchema: { type: 'invalid' as any },
|
||||
outputSchema: { type: 'object' },
|
||||
executor: 'native://test',
|
||||
timeout: 30000,
|
||||
retries: 0
|
||||
})).rejects.toThrow('Invalid schema type');
|
||||
});
|
||||
|
||||
it('should unregister skill', async () => {
|
||||
await registry.register({
|
||||
id: 'skill-001',
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
description: 'Test',
|
||||
inputSchema: { type: 'object' },
|
||||
outputSchema: { type: 'object' },
|
||||
executor: 'native://test',
|
||||
timeout: 30000,
|
||||
retries: 0
|
||||
});
|
||||
|
||||
const result = await registry.unregister('skill-001');
|
||||
const skill = await registry.get('skill-001');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(skill).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Retrieval', () => {
|
||||
beforeEach(async () => {
|
||||
await registry.register({
|
||||
id: 'skill-001',
|
||||
name: 'code-gen',
|
||||
version: '1.0.0',
|
||||
description: 'Generate code',
|
||||
inputSchema: { type: 'object' },
|
||||
outputSchema: { type: 'object' },
|
||||
executor: 'wasm://code-gen',
|
||||
timeout: 30000,
|
||||
retries: 0,
|
||||
metadata: { tags: ['code', 'generation'] }
|
||||
});
|
||||
|
||||
await registry.register({
|
||||
id: 'skill-002',
|
||||
name: 'code-gen',
|
||||
version: '2.0.0',
|
||||
description: 'Generate code v2',
|
||||
inputSchema: { type: 'object' },
|
||||
outputSchema: { type: 'object' },
|
||||
executor: 'wasm://code-gen-v2',
|
||||
timeout: 30000,
|
||||
retries: 0,
|
||||
metadata: { tags: ['code', 'generation', 'v2'] }
|
||||
});
|
||||
|
||||
await registry.register({
|
||||
id: 'skill-003',
|
||||
name: 'test-gen',
|
||||
version: '1.0.0',
|
||||
description: 'Generate tests',
|
||||
inputSchema: { type: 'object' },
|
||||
outputSchema: { type: 'object' },
|
||||
executor: 'native://test-gen',
|
||||
timeout: 60000,
|
||||
retries: 2,
|
||||
metadata: { tags: ['testing', 'generation'] }
|
||||
});
|
||||
});
|
||||
|
||||
it('should get skill by ID', async () => {
|
||||
const skill = await registry.get('skill-001');
|
||||
expect(skill?.name).toBe('code-gen');
|
||||
});
|
||||
|
||||
it('should get skill by name', async () => {
|
||||
const skill = await registry.getByName('code-gen');
|
||||
expect(skill).not.toBeNull();
|
||||
expect(skill?.name).toBe('code-gen');
|
||||
});
|
||||
|
||||
it('should get skill by name and version', async () => {
|
||||
const skill = await registry.getByName('code-gen', '2.0.0');
|
||||
expect(skill?.id).toBe('skill-002');
|
||||
});
|
||||
|
||||
it('should list all skills', async () => {
|
||||
const skills = await registry.list();
|
||||
expect(skills).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should list skills by tag', async () => {
|
||||
const skills = await registry.list(['testing']);
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe('test-gen');
|
||||
});
|
||||
|
||||
it('should list skills by executor type', async () => {
|
||||
const wasmSkills = await registry.listByExecutorType('wasm://');
|
||||
const nativeSkills = await registry.listByExecutorType('native://');
|
||||
|
||||
expect(wasmSkills).toHaveLength(2);
|
||||
expect(nativeSkills).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Execution', () => {
|
||||
const context: SkillExecutionContext = {
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: 'session-001',
|
||||
agentId: 'agent-001'
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await registry.register({
|
||||
id: 'skill-001',
|
||||
name: 'echo',
|
||||
version: '1.0.0',
|
||||
description: 'Echo input',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { message: { type: 'string' } },
|
||||
required: ['message']
|
||||
},
|
||||
outputSchema: { type: 'object' },
|
||||
executor: 'native://echo',
|
||||
timeout: 5000,
|
||||
retries: 2
|
||||
});
|
||||
|
||||
registry.registerExecutor('native://echo', async (input) => {
|
||||
return { echoed: (input as any).message };
|
||||
});
|
||||
});
|
||||
|
||||
it('should execute skill successfully', async () => {
|
||||
const result = await registry.execute(
|
||||
'skill-001',
|
||||
{ message: 'Hello' },
|
||||
context
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toEqual({ echoed: 'Hello' });
|
||||
expect(result.latency).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should fail for non-existent skill', async () => {
|
||||
const result = await registry.execute(
|
||||
'non-existent',
|
||||
{},
|
||||
context
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should validate required input fields', async () => {
|
||||
const result = await registry.execute(
|
||||
'skill-001',
|
||||
{},
|
||||
context
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Missing required field');
|
||||
});
|
||||
|
||||
it('should fail without executor', async () => {
|
||||
await registry.register({
|
||||
id: 'skill-no-executor',
|
||||
name: 'no-executor',
|
||||
version: '1.0.0',
|
||||
description: 'No executor',
|
||||
inputSchema: { type: 'object' },
|
||||
outputSchema: { type: 'object' },
|
||||
executor: 'unknown://test',
|
||||
timeout: 5000,
|
||||
retries: 0
|
||||
});
|
||||
|
||||
const result = await registry.execute(
|
||||
'skill-no-executor',
|
||||
{},
|
||||
context
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No executor found');
|
||||
});
|
||||
|
||||
it('should handle execution errors', async () => {
|
||||
await registry.register({
|
||||
id: 'skill-error',
|
||||
name: 'error',
|
||||
version: '1.0.0',
|
||||
description: 'Throws error',
|
||||
inputSchema: { type: 'object' },
|
||||
outputSchema: { type: 'object' },
|
||||
executor: 'native://error',
|
||||
timeout: 5000,
|
||||
retries: 0
|
||||
});
|
||||
|
||||
registry.registerExecutor('native://error', async () => {
|
||||
throw new Error('Execution failed');
|
||||
});
|
||||
|
||||
const result = await registry.execute(
|
||||
'skill-error',
|
||||
{},
|
||||
context
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Execution failed');
|
||||
});
|
||||
|
||||
it('should update metrics after execution', async () => {
|
||||
await registry.execute(
|
||||
'skill-001',
|
||||
{ message: 'test' },
|
||||
context
|
||||
);
|
||||
|
||||
const skill = await registry.get('skill-001');
|
||||
expect(skill?.metadata.usageCount).toBe(1);
|
||||
expect(skill?.metadata.averageLatency).toBeGreaterThan(0);
|
||||
expect(skill?.metadata.successRate).toBe(1);
|
||||
});
|
||||
|
||||
it('should update success rate on failure', async () => {
|
||||
await registry.register({
|
||||
id: 'skill-flaky',
|
||||
name: 'flaky',
|
||||
version: '1.0.0',
|
||||
description: 'Flaky skill',
|
||||
inputSchema: { type: 'object' },
|
||||
outputSchema: { type: 'object' },
|
||||
executor: 'native://flaky',
|
||||
timeout: 5000,
|
||||
retries: 0
|
||||
});
|
||||
|
||||
let callCount = 0;
|
||||
registry.registerExecutor('native://flaky', async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error('First call fails');
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// First call fails
|
||||
await registry.execute('skill-flaky', {}, context);
|
||||
|
||||
// Second call succeeds
|
||||
await registry.execute('skill-flaky', {}, context);
|
||||
|
||||
const skill = await registry.get('skill-flaky');
|
||||
expect(skill?.metadata.usageCount).toBe(2);
|
||||
expect(skill?.metadata.successRate).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Retry Mechanism', () => {
|
||||
const context: SkillExecutionContext = {
|
||||
tenantId: 'tenant-001',
|
||||
sessionId: 'session-001',
|
||||
agentId: 'agent-001'
|
||||
};
|
||||
|
||||
it('should retry failed executions', async () => {
|
||||
await registry.register({
|
||||
id: 'skill-retry',
|
||||
name: 'retry',
|
||||
version: '1.0.0',
|
||||
description: 'Retry skill',
|
||||
inputSchema: { type: 'object' },
|
||||
outputSchema: { type: 'object' },
|
||||
executor: 'native://retry',
|
||||
timeout: 5000,
|
||||
retries: 2
|
||||
});
|
||||
|
||||
let attempts = 0;
|
||||
registry.registerExecutor('native://retry', async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error(`Attempt ${attempts} failed`);
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
const result = await registry.executeWithRetry(
|
||||
'skill-retry',
|
||||
{},
|
||||
context
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
it('should fail after max retries', async () => {
|
||||
await registry.register({
|
||||
id: 'skill-always-fail',
|
||||
name: 'always-fail',
|
||||
version: '1.0.0',
|
||||
description: 'Always fails',
|
||||
inputSchema: { type: 'object' },
|
||||
outputSchema: { type: 'object' },
|
||||
executor: 'native://always-fail',
|
||||
timeout: 5000,
|
||||
retries: 2
|
||||
});
|
||||
|
||||
registry.registerExecutor('native://always-fail', async () => {
|
||||
throw new Error('Always fails');
|
||||
});
|
||||
|
||||
const result = await registry.executeWithRetry(
|
||||
'skill-always-fail',
|
||||
{},
|
||||
context
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Skill Factory Integration', () => {
|
||||
let registry: SkillRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new SkillRegistry();
|
||||
});
|
||||
|
||||
it('should register skill from factory data', async () => {
|
||||
const factorySkill = createSkill({
|
||||
name: 'factory-skill',
|
||||
description: 'Created from factory'
|
||||
});
|
||||
|
||||
const skill = await registry.register({
|
||||
id: factorySkill.id,
|
||||
name: factorySkill.name,
|
||||
version: factorySkill.version,
|
||||
description: factorySkill.description,
|
||||
inputSchema: factorySkill.inputSchema as any,
|
||||
outputSchema: factorySkill.outputSchema as any,
|
||||
executor: factorySkill.executor,
|
||||
timeout: factorySkill.timeout,
|
||||
retries: 0
|
||||
});
|
||||
|
||||
expect(skill.name).toBe('factory-skill');
|
||||
expect(skill.description).toBe('Created from factory');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user