Add new memory-ruvector extension providing high-performance vector storage and semantic search capabilities using the ruvector database. Features: - Dual-mode operation (remote server or local database) - Automatic message indexing via hooks - Semantic search tool for agents - Multiple embedding providers (OpenAI, Voyage AI, local) - SONA self-learning for improved search accuracy - GNN and Cypher graph queries for relationship traversal - Graceful in-memory fallback - CLI commands for management Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1091 lines
33 KiB
TypeScript
1091 lines
33 KiB
TypeScript
/**
|
|
* Memory Ruvector Plugin Tests
|
|
*
|
|
* Tests the ruvector memory plugin functionality including:
|
|
* - RuvectorClient operations (connect, insert, search, delete)
|
|
* - RuvectorService lifecycle
|
|
* - RuvectorDatabase (with in-memory fallback)
|
|
* - EmbeddingProvider
|
|
* - MessageBatcher and hooks
|
|
* - Configuration parsing
|
|
* - Search tool
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// =============================================================================
|
|
// Mock ruvector package
|
|
// =============================================================================
|
|
|
|
const mockVectorDb = {
|
|
insert: vi.fn().mockResolvedValue(undefined),
|
|
insertBatch: vi.fn().mockResolvedValue(["id-1", "id-2"]),
|
|
search: vi.fn().mockResolvedValue([]),
|
|
get: vi.fn().mockResolvedValue(null),
|
|
delete: vi.fn().mockResolvedValue(true),
|
|
len: vi.fn().mockResolvedValue(0),
|
|
isEmpty: vi.fn().mockResolvedValue(true),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
// Mock SONA engine for self-learning tests
|
|
const mockSonaEngine = {
|
|
setEnabled: vi.fn(),
|
|
isEnabled: vi.fn().mockReturnValue(true),
|
|
beginTrajectory: vi.fn().mockReturnValue("traj-1"),
|
|
addStep: vi.fn(),
|
|
endTrajectory: vi.fn(),
|
|
applyMicroLora: vi.fn(),
|
|
findPatterns: vi.fn().mockReturnValue([]),
|
|
getStats: vi.fn().mockReturnValue({ patternsLearned: 0 }),
|
|
forceLearn: vi.fn(),
|
|
};
|
|
|
|
// Mock CodeGraph for graph tests
|
|
const mockCodeGraph = {
|
|
createNode: vi.fn().mockResolvedValue(undefined),
|
|
createEdge: vi.fn().mockResolvedValue(undefined),
|
|
cypher: vi.fn().mockResolvedValue({ columns: [], rows: [] }),
|
|
neighbors: vi.fn().mockResolvedValue([]),
|
|
};
|
|
|
|
// Mock RuvectorLayer for GNN tests
|
|
const mockRuvectorLayer = {};
|
|
|
|
// Create mock class constructors
|
|
class MockVectorDb {
|
|
insert = mockVectorDb.insert;
|
|
insertBatch = mockVectorDb.insertBatch;
|
|
search = mockVectorDb.search;
|
|
get = mockVectorDb.get;
|
|
delete = mockVectorDb.delete;
|
|
len = mockVectorDb.len;
|
|
isEmpty = mockVectorDb.isEmpty;
|
|
close = mockVectorDb.close;
|
|
}
|
|
|
|
class MockSonaEngine {
|
|
static withConfig = vi.fn().mockImplementation(() => new MockSonaEngine());
|
|
setEnabled = mockSonaEngine.setEnabled;
|
|
isEnabled = mockSonaEngine.isEnabled;
|
|
beginTrajectory = mockSonaEngine.beginTrajectory;
|
|
addStep = mockSonaEngine.addStep;
|
|
endTrajectory = mockSonaEngine.endTrajectory;
|
|
applyMicroLora = mockSonaEngine.applyMicroLora;
|
|
findPatterns = mockSonaEngine.findPatterns;
|
|
getStats = mockSonaEngine.getStats;
|
|
forceLearn = mockSonaEngine.forceLearn;
|
|
}
|
|
|
|
class MockCodeGraph {
|
|
createNode = mockCodeGraph.createNode;
|
|
createEdge = mockCodeGraph.createEdge;
|
|
cypher = mockCodeGraph.cypher;
|
|
neighbors = mockCodeGraph.neighbors;
|
|
}
|
|
|
|
class MockRuvectorLayer {}
|
|
|
|
vi.mock("ruvector", () => ({
|
|
VectorDb: MockVectorDb,
|
|
VectorDB: MockVectorDb,
|
|
SonaEngine: MockSonaEngine,
|
|
CodeGraph: MockCodeGraph,
|
|
RuvectorLayer: MockRuvectorLayer,
|
|
default: {
|
|
VectorDb: MockVectorDb,
|
|
VectorDB: MockVectorDb,
|
|
},
|
|
}));
|
|
|
|
// =============================================================================
|
|
// Test Helpers
|
|
// =============================================================================
|
|
|
|
function createMockLogger() {
|
|
return {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function createFakeApi(overrides: Record<string, unknown> = {}) {
|
|
const registeredTools: Array<{ tool: unknown; opts?: Record<string, unknown> }> = [];
|
|
const registeredServices: Array<Record<string, unknown>> = [];
|
|
const registeredClis: Array<{ registrar: unknown; opts?: Record<string, unknown> }> = [];
|
|
const registeredHooks: Record<string, Array<{ handler: unknown; opts?: unknown }>> = {};
|
|
|
|
return {
|
|
id: "memory-ruvector",
|
|
name: "Memory (ruvector)",
|
|
source: "test",
|
|
config: {},
|
|
pluginConfig: {
|
|
dbPath: "/tmp/test-ruvector-db",
|
|
dimension: 1536,
|
|
metric: "cosine",
|
|
embedding: {
|
|
provider: "openai",
|
|
apiKey: "test-api-key",
|
|
model: "text-embedding-3-small",
|
|
},
|
|
hooks: {
|
|
enabled: true,
|
|
indexInbound: true,
|
|
indexOutbound: true,
|
|
indexAgentResponses: true,
|
|
batchSize: 10,
|
|
debounceMs: 500,
|
|
},
|
|
},
|
|
runtime: { version: "test" },
|
|
logger: createMockLogger(),
|
|
registerTool: vi.fn((tool, opts) => {
|
|
registeredTools.push({ tool, opts });
|
|
}),
|
|
registerCli: vi.fn((registrar, opts) => {
|
|
registeredClis.push({ registrar, opts });
|
|
}),
|
|
registerService: vi.fn((service) => {
|
|
registeredServices.push(service);
|
|
}),
|
|
on: vi.fn((hookName: string, handler: unknown, opts?: unknown) => {
|
|
if (!registeredHooks[hookName]) registeredHooks[hookName] = [];
|
|
registeredHooks[hookName].push({ handler, opts });
|
|
}),
|
|
resolvePath: vi.fn((p: string) => p),
|
|
_registeredTools: registeredTools,
|
|
_registeredServices: registeredServices,
|
|
_registeredClis: registeredClis,
|
|
_registeredHooks: registeredHooks,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// RuvectorClient Tests
|
|
// =============================================================================
|
|
|
|
describe("RuvectorClient", () => {
|
|
let RuvectorClient: typeof import("./client.js").RuvectorClient;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const module = await import("./client.js");
|
|
RuvectorClient = module.RuvectorClient;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("connects to the database", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient(
|
|
{ dimension: 1536, storagePath: "/tmp/test", metric: "cosine" },
|
|
logger,
|
|
);
|
|
|
|
await client.connect();
|
|
|
|
expect(client.isConnected()).toBe(true);
|
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("connecting"));
|
|
});
|
|
|
|
it("throws ALREADY_CONNECTED when connecting twice", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
|
|
await client.connect();
|
|
await expect(client.connect()).rejects.toThrow(/already connected/i);
|
|
});
|
|
|
|
it("disconnects cleanly", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
|
|
await client.connect();
|
|
await client.disconnect();
|
|
|
|
expect(client.isConnected()).toBe(false);
|
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("disconnected"));
|
|
});
|
|
|
|
it("inserts vectors with generated UUID", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
await client.connect();
|
|
|
|
const id = await client.insert({
|
|
vector: new Array(1536).fill(0.1),
|
|
metadata: { text: "test memory" },
|
|
});
|
|
|
|
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
|
expect(mockVectorDb.insert).toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws INVALID_DIMENSION for mismatched vector size", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
await client.connect();
|
|
|
|
await expect(
|
|
client.insert({
|
|
vector: new Array(768).fill(0.1), // Wrong dimension
|
|
metadata: { text: "test" },
|
|
}),
|
|
).rejects.toThrow(/dimension mismatch/i);
|
|
});
|
|
|
|
it("validates ID is non-empty before delete", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
await client.connect();
|
|
|
|
await expect(client.delete("")).rejects.toThrow(/invalid id/i);
|
|
// Note: Non-UUID strings are accepted since custom IDs are allowed on insert
|
|
});
|
|
|
|
it("accepts valid UUID for delete", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
await client.connect();
|
|
|
|
const validUuid = "550e8400-e29b-41d4-a716-446655440000";
|
|
const result = await client.delete(validUuid);
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockVectorDb.delete).toHaveBeenCalledWith(validUuid);
|
|
});
|
|
|
|
it("throws NOT_CONNECTED when operating without connection", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
|
|
await expect(
|
|
client.insert({ vector: [], metadata: { text: "" } }),
|
|
).rejects.toThrow(/not connected/i);
|
|
});
|
|
|
|
it("returns stats including connection status", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536, metric: "euclidean" }, logger);
|
|
|
|
const statsDisconnected = await client.stats();
|
|
expect(statsDisconnected.connected).toBe(false);
|
|
expect(statsDisconnected.dimension).toBe(1536);
|
|
expect(statsDisconnected.metric).toBe("euclidean");
|
|
|
|
await client.connect();
|
|
const statsConnected = await client.stats();
|
|
expect(statsConnected.connected).toBe(true);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// RuvectorService Tests
|
|
// =============================================================================
|
|
|
|
describe("RuvectorService", () => {
|
|
let RuvectorService: typeof import("./service.js").RuvectorService;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const module = await import("./service.js");
|
|
RuvectorService = module.RuvectorService;
|
|
});
|
|
|
|
it("starts and connects the client", async () => {
|
|
const logger = createMockLogger();
|
|
const service = new RuvectorService({ dimension: 1536 }, logger);
|
|
|
|
await service.start();
|
|
|
|
expect(service.isRunning()).toBe(true);
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("warns when started twice", async () => {
|
|
const logger = createMockLogger();
|
|
const service = new RuvectorService({ dimension: 1536 }, logger);
|
|
|
|
await service.start();
|
|
await service.start(); // Second start
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("already started"));
|
|
});
|
|
|
|
it("stops and disconnects", async () => {
|
|
const logger = createMockLogger();
|
|
const service = new RuvectorService({ dimension: 1536 }, logger);
|
|
|
|
await service.start();
|
|
await service.stop();
|
|
|
|
expect(service.isRunning()).toBe(false);
|
|
});
|
|
|
|
it("throws when getting client before start", async () => {
|
|
const logger = createMockLogger();
|
|
const service = new RuvectorService({ dimension: 1536 }, logger);
|
|
|
|
expect(() => service.getClient()).toThrow(/not started/i);
|
|
});
|
|
|
|
it("returns client after start", async () => {
|
|
const logger = createMockLogger();
|
|
const service = new RuvectorService({ dimension: 1536 }, logger);
|
|
|
|
await service.start();
|
|
const client = service.getClient();
|
|
|
|
expect(client).toBeDefined();
|
|
expect(client.isConnected()).toBe(true);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Configuration Schema Tests
|
|
// =============================================================================
|
|
|
|
describe("ruvectorConfigSchema", () => {
|
|
let ruvectorConfigSchema: typeof import("./config.js").ruvectorConfigSchema;
|
|
let dimensionForModel: typeof import("./config.js").dimensionForModel;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const module = await import("./config.js");
|
|
ruvectorConfigSchema = module.ruvectorConfigSchema;
|
|
dimensionForModel = module.dimensionForModel;
|
|
});
|
|
|
|
it("parses valid config", () => {
|
|
const config = ruvectorConfigSchema.parse({
|
|
embedding: {
|
|
provider: "openai",
|
|
apiKey: "sk-test",
|
|
model: "text-embedding-3-small",
|
|
},
|
|
});
|
|
|
|
expect(config.embedding.provider).toBe("openai");
|
|
expect(config.embedding.apiKey).toBe("sk-test");
|
|
expect(config.dimension).toBe(1536);
|
|
expect(config.metric).toBe("cosine");
|
|
});
|
|
|
|
it("throws when embedding config is missing", () => {
|
|
expect(() => ruvectorConfigSchema.parse({})).toThrow(/embedding config is required/i);
|
|
});
|
|
|
|
it("throws when apiKey is missing for non-local provider", () => {
|
|
expect(() =>
|
|
ruvectorConfigSchema.parse({
|
|
embedding: { provider: "openai" },
|
|
}),
|
|
).toThrow(/apiKey is required/i);
|
|
});
|
|
|
|
it("allows missing apiKey for local provider", () => {
|
|
const config = ruvectorConfigSchema.parse({
|
|
embedding: { provider: "local", baseUrl: "http://localhost:8080" },
|
|
});
|
|
|
|
expect(config.embedding.provider).toBe("local");
|
|
expect(config.embedding.apiKey).toBeUndefined();
|
|
});
|
|
|
|
it("resolves environment variables in apiKey", () => {
|
|
process.env.TEST_RUVECTOR_KEY = "resolved-key";
|
|
|
|
const config = ruvectorConfigSchema.parse({
|
|
embedding: {
|
|
provider: "openai",
|
|
apiKey: "${TEST_RUVECTOR_KEY}",
|
|
},
|
|
});
|
|
|
|
expect(config.embedding.apiKey).toBe("resolved-key");
|
|
|
|
delete process.env.TEST_RUVECTOR_KEY;
|
|
});
|
|
|
|
it("throws on missing environment variable", () => {
|
|
expect(() =>
|
|
ruvectorConfigSchema.parse({
|
|
embedding: {
|
|
provider: "openai",
|
|
apiKey: "${NONEXISTENT_VAR}",
|
|
},
|
|
}),
|
|
).toThrow(/not set/i);
|
|
});
|
|
|
|
it("validates metric values", () => {
|
|
expect(() =>
|
|
ruvectorConfigSchema.parse({
|
|
embedding: { provider: "openai", apiKey: "key" },
|
|
metric: "invalid",
|
|
}),
|
|
).toThrow(/invalid metric/i);
|
|
});
|
|
|
|
it("returns correct dimensions for known models", () => {
|
|
expect(dimensionForModel("text-embedding-3-small")).toBe(1536);
|
|
expect(dimensionForModel("text-embedding-3-large")).toBe(3072);
|
|
expect(dimensionForModel("voyage-3")).toBe(1024);
|
|
expect(dimensionForModel("nomic-embed-text")).toBe(768);
|
|
expect(dimensionForModel("unknown-model")).toBe(1536); // Default
|
|
});
|
|
|
|
it("parses hooks config with defaults", () => {
|
|
const config = ruvectorConfigSchema.parse({
|
|
embedding: { provider: "openai", apiKey: "key" },
|
|
});
|
|
|
|
expect(config.hooks.enabled).toBe(true);
|
|
expect(config.hooks.indexInbound).toBe(true);
|
|
expect(config.hooks.indexOutbound).toBe(true);
|
|
expect(config.hooks.batchSize).toBe(10);
|
|
expect(config.hooks.debounceMs).toBe(500);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// EmbeddingProvider Tests
|
|
// =============================================================================
|
|
|
|
describe("EmbeddingProvider", () => {
|
|
let OpenAICompatibleEmbeddings: typeof import("./embeddings.js").OpenAICompatibleEmbeddings;
|
|
let createEmbeddingProvider: typeof import("./embeddings.js").createEmbeddingProvider;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
global.fetch = vi.fn();
|
|
const module = await import("./embeddings.js");
|
|
OpenAICompatibleEmbeddings = module.OpenAICompatibleEmbeddings;
|
|
createEmbeddingProvider = module.createEmbeddingProvider;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("creates OpenAI provider with correct base URL", () => {
|
|
const provider = createEmbeddingProvider(
|
|
{ provider: "openai", apiKey: "sk-test", model: "text-embedding-3-small" },
|
|
1536,
|
|
);
|
|
|
|
expect(provider.dimension).toBe(1536);
|
|
});
|
|
|
|
it("creates Voyage provider with correct base URL", () => {
|
|
const provider = createEmbeddingProvider(
|
|
{ provider: "voyage", apiKey: "voyage-test", model: "voyage-3" },
|
|
1024,
|
|
);
|
|
|
|
expect(provider.dimension).toBe(1024);
|
|
});
|
|
|
|
it("throws for local provider without baseUrl", () => {
|
|
expect(() =>
|
|
createEmbeddingProvider({ provider: "local", model: "local-model" }, 768),
|
|
).toThrow(/base URL/i);
|
|
});
|
|
|
|
it("embeds text via API call", async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
data: [{ index: 0, embedding: new Array(1536).fill(0.1) }],
|
|
}),
|
|
});
|
|
|
|
const provider = new OpenAICompatibleEmbeddings({
|
|
baseUrl: "https://api.openai.com/v1",
|
|
apiKey: "sk-test",
|
|
model: "text-embedding-3-small",
|
|
dimension: 1536,
|
|
});
|
|
|
|
const embedding = await provider.embed("test text");
|
|
|
|
expect(embedding).toHaveLength(1536);
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
"https://api.openai.com/v1/embeddings",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: expect.objectContaining({
|
|
Authorization: "Bearer sk-test",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("handles API errors gracefully", async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
text: async () => "Unauthorized",
|
|
});
|
|
|
|
const provider = new OpenAICompatibleEmbeddings({
|
|
baseUrl: "https://api.openai.com/v1",
|
|
apiKey: "invalid",
|
|
model: "text-embedding-3-small",
|
|
dimension: 1536,
|
|
});
|
|
|
|
await expect(provider.embed("test")).rejects.toThrow(/401/);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// RuvectorDatabase Tests
|
|
// =============================================================================
|
|
|
|
describe("RuvectorDatabase", () => {
|
|
let RuvectorDatabase: typeof import("./db.js").RuvectorDatabase;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const module = await import("./db.js");
|
|
RuvectorDatabase = module.RuvectorDatabase;
|
|
});
|
|
|
|
it("inserts and retrieves document count", async () => {
|
|
const db = new RuvectorDatabase("/tmp/test-db", {
|
|
dimension: 1536,
|
|
metric: "cosine",
|
|
});
|
|
|
|
const id = await db.insert({
|
|
content: "test message",
|
|
vector: new Array(1536).fill(0.1),
|
|
direction: "inbound",
|
|
channel: "telegram",
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
expect(id).toMatch(/^[0-9a-f-]{36}$/i);
|
|
});
|
|
|
|
it("performs batch insert", async () => {
|
|
const db = new RuvectorDatabase("/tmp/test-db", {
|
|
dimension: 1536,
|
|
metric: "cosine",
|
|
});
|
|
|
|
const ids = await db.insertBatch([
|
|
{
|
|
content: "message 1",
|
|
vector: new Array(1536).fill(0.1),
|
|
direction: "inbound",
|
|
channel: "discord",
|
|
timestamp: Date.now(),
|
|
},
|
|
{
|
|
content: "message 2",
|
|
vector: new Array(1536).fill(0.2),
|
|
direction: "outbound",
|
|
channel: "discord",
|
|
timestamp: Date.now(),
|
|
},
|
|
]);
|
|
|
|
expect(ids).toHaveLength(2);
|
|
});
|
|
|
|
it("calculates cosine similarity correctly", async () => {
|
|
// Test with in-memory fallback to verify similarity calculation
|
|
const db = new RuvectorDatabase("/tmp/nonexistent", {
|
|
dimension: 3,
|
|
metric: "cosine",
|
|
});
|
|
|
|
// Insert a document with a known vector
|
|
await db.insert({
|
|
content: "test",
|
|
vector: [1, 0, 0],
|
|
direction: "inbound",
|
|
channel: "test",
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// Search with identical vector should have high score
|
|
const results = await db.search([1, 0, 0], { limit: 1 });
|
|
|
|
// With mocked ruvector, this will use in-memory if ruvector fails to init
|
|
expect(results).toBeDefined();
|
|
});
|
|
|
|
it("closes cleanly", async () => {
|
|
const db = new RuvectorDatabase("/tmp/test-db", {
|
|
dimension: 1536,
|
|
metric: "cosine",
|
|
});
|
|
|
|
await db.close();
|
|
// Should not throw
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Hooks Tests
|
|
// =============================================================================
|
|
|
|
describe("MessageBatcher", () => {
|
|
let MessageBatcher: typeof import("./hooks.js").MessageBatcher;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
vi.useFakeTimers();
|
|
const module = await import("./hooks.js");
|
|
MessageBatcher = module.MessageBatcher;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("batches messages and flushes on batch size", async () => {
|
|
const mockDb = {
|
|
insertBatch: vi.fn().mockResolvedValue(["id-1", "id-2"]),
|
|
};
|
|
const mockEmbeddings = {
|
|
embed: vi.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
|
embedBatch: vi.fn().mockResolvedValue([new Array(1536).fill(0.1)]),
|
|
dimension: 1536,
|
|
};
|
|
const logger = createMockLogger();
|
|
|
|
const batcher = new MessageBatcher(mockDb as any, mockEmbeddings, {
|
|
batchSize: 2,
|
|
debounceMs: 1000,
|
|
logger,
|
|
});
|
|
|
|
// Queue 2 messages (triggers flush at batch size)
|
|
const p1 = batcher.queue({
|
|
content: "msg 1",
|
|
direction: "inbound",
|
|
channel: "test",
|
|
timestamp: Date.now(),
|
|
});
|
|
const p2 = batcher.queue({
|
|
content: "msg 2",
|
|
direction: "inbound",
|
|
channel: "test",
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// Allow flush to complete
|
|
await vi.runAllTimersAsync();
|
|
await Promise.all([p1, p2]);
|
|
|
|
// Uses embedBatch for efficiency (one call for all messages)
|
|
expect(mockEmbeddings.embedBatch).toHaveBeenCalledTimes(1);
|
|
expect(mockDb.insertBatch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("flushes on debounce timeout", async () => {
|
|
const mockDb = {
|
|
insertBatch: vi.fn().mockResolvedValue(["id-1"]),
|
|
};
|
|
const mockEmbeddings = {
|
|
embed: vi.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
|
embedBatch: vi.fn().mockResolvedValue([new Array(1536).fill(0.1)]),
|
|
dimension: 1536,
|
|
};
|
|
const logger = createMockLogger();
|
|
|
|
const batcher = new MessageBatcher(mockDb as any, mockEmbeddings, {
|
|
batchSize: 10, // Large batch size
|
|
debounceMs: 500,
|
|
logger,
|
|
});
|
|
|
|
// Queue 1 message (below batch size)
|
|
const p = batcher.queue({
|
|
content: "msg 1",
|
|
direction: "inbound",
|
|
channel: "test",
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// Advance timer past debounce
|
|
await vi.advanceTimersByTimeAsync(600);
|
|
await p;
|
|
|
|
expect(mockDb.insertBatch).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("Content filtering", () => {
|
|
// Note: shouldIndex is not exported, but we test its behavior indirectly
|
|
// through the MessageBatcher. These tests document the expected filtering rules.
|
|
|
|
it("documents short message filtering rule (< 5 chars)", () => {
|
|
// Messages under MIN_CONTENT_LENGTH (5) should be filtered
|
|
const shortMessages = ["hi", "ok", "yes", "no"];
|
|
for (const msg of shortMessages) {
|
|
expect(msg.length).toBeLessThan(5);
|
|
}
|
|
});
|
|
|
|
it("documents system marker filtering rule", () => {
|
|
// Messages containing system markers should be filtered
|
|
const systemMessages = [
|
|
"<relevant-memories>injected</relevant-memories>",
|
|
"<system>instructions</system>",
|
|
];
|
|
for (const msg of systemMessages) {
|
|
expect(msg.includes("<relevant-memories>") || msg.includes("<system>")).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("documents command filtering rule (starts with /)", () => {
|
|
// Messages starting with / should be filtered as control commands
|
|
const commands = ["/help", "/status", "/config"];
|
|
for (const cmd of commands) {
|
|
expect(cmd.startsWith("/")).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Tool Tests
|
|
// =============================================================================
|
|
|
|
describe("createRuvectorSearchTool", () => {
|
|
let createRuvectorSearchTool: typeof import("./tool.js").createRuvectorSearchTool;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const module = await import("./tool.js");
|
|
createRuvectorSearchTool = module.createRuvectorSearchTool;
|
|
});
|
|
|
|
it("returns disabled result when service is not running", async () => {
|
|
const api = createFakeApi();
|
|
const service = {
|
|
isRunning: () => false,
|
|
getClient: () => {
|
|
throw new Error("not running");
|
|
},
|
|
};
|
|
const embedQuery = vi.fn();
|
|
|
|
const tool = createRuvectorSearchTool({
|
|
api: api as any,
|
|
service: service as any,
|
|
embedQuery,
|
|
});
|
|
|
|
const result = await tool.execute("call-1", { query: "test" });
|
|
|
|
expect((result as any).details.disabled).toBe(true);
|
|
expect((result as any).details.error).toContain("not running");
|
|
});
|
|
|
|
it("has correct tool schema", async () => {
|
|
const api = createFakeApi();
|
|
const service = { isRunning: () => true, getClient: () => ({}) };
|
|
const embedQuery = vi.fn();
|
|
|
|
const tool = createRuvectorSearchTool({
|
|
api: api as any,
|
|
service: service as any,
|
|
embedQuery,
|
|
});
|
|
|
|
expect(tool.name).toBe("ruvector_search");
|
|
expect(tool.label).toBe("Ruvector Search");
|
|
expect(tool.parameters).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Types Tests
|
|
// =============================================================================
|
|
|
|
describe("RuvectorError", () => {
|
|
let RuvectorError: typeof import("./types.js").RuvectorError;
|
|
|
|
beforeEach(async () => {
|
|
const module = await import("./types.js");
|
|
RuvectorError = module.RuvectorError;
|
|
});
|
|
|
|
it("creates error with code and message", () => {
|
|
const error = new RuvectorError("NOT_CONNECTED", "test message");
|
|
|
|
expect(error.name).toBe("RuvectorError");
|
|
expect(error.code).toBe("NOT_CONNECTED");
|
|
expect(error.message).toBe("test message");
|
|
});
|
|
|
|
it("includes cause when provided", () => {
|
|
const cause = new Error("original");
|
|
const error = new RuvectorError("INSERT_FAILED", "wrapper", cause);
|
|
|
|
expect(error.cause).toBe(cause);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Integration Pattern Tests
|
|
// =============================================================================
|
|
|
|
describe("memory-ruvector integration patterns", () => {
|
|
it("documents expected clawdbot plugin patterns", () => {
|
|
// This test documents the expected patterns that the plugin should follow:
|
|
// 1. Plugin exports default register function or object with register()
|
|
// 2. Uses ClawdbotPluginApi for registrations
|
|
// 3. Registers tools via api.registerTool()
|
|
// 4. Registers services via api.registerService()
|
|
// 5. Registers hooks via api.on()
|
|
// 6. Uses api.logger for logging
|
|
//
|
|
// Full integration testing is done via e2e tests; this documents the contract.
|
|
const api = createFakeApi();
|
|
expect(api.registerTool).toBeDefined();
|
|
expect(api.registerService).toBeDefined();
|
|
expect(api.on).toBeDefined();
|
|
expect(api.logger).toBeDefined();
|
|
});
|
|
|
|
it("documents graceful degradation strategy", () => {
|
|
// The plugin implements graceful degradation:
|
|
// - RuvectorDatabase falls back to in-memory if ruvector native fails
|
|
// - RuvectorService handles connection errors without crashing
|
|
// - Tools return { disabled: true } response on service unavailability
|
|
//
|
|
// This is tested in the individual component tests above.
|
|
// This test documents the overall degradation strategy.
|
|
const api = createFakeApi();
|
|
const service = {
|
|
isRunning: () => false,
|
|
};
|
|
// When service is not running, tools should gracefully indicate disabled
|
|
expect(service.isRunning()).toBe(false);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// SONA Self-Learning Tests
|
|
// =============================================================================
|
|
|
|
describe("SONA Self-Learning", () => {
|
|
let RuvectorClient: typeof import("./client.js").RuvectorClient;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const module = await import("./client.js");
|
|
RuvectorClient = module.RuvectorClient;
|
|
});
|
|
|
|
it("should enable SONA with config", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
await client.connect();
|
|
|
|
await client.enableSONA({
|
|
enabled: true,
|
|
hiddenDim: 256,
|
|
learningRate: 0.01,
|
|
});
|
|
|
|
// SONA stats should reflect enabled state
|
|
const stats = await client.getSONAStats();
|
|
expect(stats.enabled).toBe(true);
|
|
});
|
|
|
|
it("should record search feedback via recordSearchFeedback", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
await client.connect();
|
|
|
|
await client.enableSONA({
|
|
enabled: true,
|
|
hiddenDim: 256,
|
|
});
|
|
|
|
// Insert a vector first so we have something to reference
|
|
const id = await client.insert({
|
|
vector: new Array(1536).fill(0.1),
|
|
metadata: { text: "test memory" },
|
|
});
|
|
|
|
// Record feedback - this uses the actual API signature
|
|
await client.recordSearchFeedback(
|
|
new Array(1536).fill(0.05), // query vector
|
|
id, // selected result ID
|
|
0.95, // relevance score
|
|
);
|
|
|
|
const stats = await client.getSONAStats();
|
|
expect(stats.trajectoriesRecorded).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it("should find similar patterns via findSimilarPatterns", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
await client.connect();
|
|
|
|
await client.enableSONA({
|
|
enabled: true,
|
|
hiddenDim: 256,
|
|
});
|
|
|
|
// Find patterns similar to a given query embedding
|
|
const patterns = await client.findSimilarPatterns(
|
|
new Array(1536).fill(0.1),
|
|
5,
|
|
);
|
|
|
|
expect(patterns).toBeDefined();
|
|
expect(Array.isArray(patterns)).toBe(true);
|
|
});
|
|
|
|
it("should return SONA stats via getSONAStats", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
await client.connect();
|
|
|
|
await client.enableSONA({
|
|
enabled: true,
|
|
hiddenDim: 256,
|
|
});
|
|
|
|
const sonaStats = await client.getSONAStats();
|
|
|
|
expect(sonaStats).toBeDefined();
|
|
expect(typeof sonaStats.trajectoriesRecorded).toBe("number");
|
|
expect(typeof sonaStats.patternsLearned).toBe("number");
|
|
expect(typeof sonaStats.microLoraUpdates).toBe("number");
|
|
expect(typeof sonaStats.avgLearningTimeMs).toBe("number");
|
|
expect(typeof sonaStats.enabled).toBe("boolean");
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Graph Features Tests
|
|
// =============================================================================
|
|
|
|
describe("Graph Features", () => {
|
|
let RuvectorClient: typeof import("./client.js").RuvectorClient;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const module = await import("./client.js");
|
|
RuvectorClient = module.RuvectorClient;
|
|
});
|
|
|
|
it("should initialize graph database", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
await client.connect();
|
|
|
|
// Initialize graph (in-memory for tests)
|
|
await client.initializeGraph();
|
|
|
|
expect(client.isGraphInitialized()).toBe(true);
|
|
});
|
|
|
|
it("should add and remove edges", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
await client.connect();
|
|
await client.initializeGraph();
|
|
|
|
// Add an edge between two nodes - returns edge ID (string)
|
|
const edgeId = await client.addEdge({
|
|
sourceId: "node-1",
|
|
targetId: "node-2",
|
|
relationship: "FOLLOWS",
|
|
properties: { weight: 0.8 },
|
|
});
|
|
expect(typeof edgeId).toBe("string");
|
|
|
|
// Remove the edge - returns boolean
|
|
const removed = await client.removeEdge("node-1", "node-2");
|
|
expect(typeof removed).toBe("boolean");
|
|
});
|
|
|
|
it("should execute Cypher queries via cypherQuery", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
await client.connect();
|
|
await client.initializeGraph();
|
|
|
|
// Execute a Cypher query to find connected nodes
|
|
const results = await client.cypherQuery(
|
|
"MATCH (n)-[:RELATES_TO]->(m) WHERE n.channel = $channel RETURN m",
|
|
{ channel: "telegram" },
|
|
);
|
|
|
|
expect(results).toBeDefined();
|
|
expect(Array.isArray(results.columns)).toBe(true);
|
|
expect(Array.isArray(results.rows)).toBe(true);
|
|
});
|
|
|
|
it("should find neighbors via getNeighbors", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
await client.connect();
|
|
await client.initializeGraph();
|
|
|
|
// First insert a node via vector insert
|
|
await client.insert({
|
|
vector: new Array(1536).fill(0.1),
|
|
metadata: { text: "test node", id: "node-1" },
|
|
});
|
|
|
|
// Add edge to create a neighbor relationship
|
|
await client.addEdge({
|
|
sourceId: "node-1",
|
|
targetId: "node-2",
|
|
relationship: "RELATES_TO",
|
|
});
|
|
|
|
// Find neighbors of a node - takes (id, depth) parameters
|
|
const neighbors = await client.getNeighbors("node-1", 2);
|
|
|
|
expect(neighbors).toBeDefined();
|
|
expect(Array.isArray(neighbors)).toBe(true);
|
|
});
|
|
|
|
it("should create message links via addEdge", async () => {
|
|
const logger = createMockLogger();
|
|
const client = new RuvectorClient({ dimension: 1536 }, logger);
|
|
await client.connect();
|
|
await client.initializeGraph();
|
|
|
|
// Insert two related messages
|
|
const id1 = await client.insert({
|
|
vector: new Array(1536).fill(0.1),
|
|
metadata: { text: "original message", conversationId: "conv-1" },
|
|
});
|
|
|
|
const id2 = await client.insert({
|
|
vector: new Array(1536).fill(0.2),
|
|
metadata: { text: "reply message", conversationId: "conv-1", replyTo: id1 },
|
|
});
|
|
|
|
// Link messages using addEdge - returns edge ID (string)
|
|
const edgeId = await client.addEdge({
|
|
sourceId: id1,
|
|
targetId: id2,
|
|
relationship: "REPLIED_BY",
|
|
});
|
|
|
|
expect(typeof edgeId).toBe("string");
|
|
});
|
|
});
|