chore: end to end test coverage for ts sdk (#4357)
This commit is contained in:
@@ -190,7 +190,10 @@ describe("ConfigManager", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: { model: "nomic-embed-text-v1.5", embedding_dims: 768 } as any,
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
embedding_dims: 768,
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
@@ -313,7 +316,11 @@ describe("ConfigManager", () => {
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { host: "192.168.200.12", port: 6333, checkCompatibility: false },
|
||||
config: {
|
||||
host: "192.168.200.12",
|
||||
port: 6333,
|
||||
checkCompatibility: false,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
@@ -326,7 +333,9 @@ describe("ConfigManager", () => {
|
||||
|
||||
expect(cfg.embedder.provider).toBe("lmstudio");
|
||||
expect(cfg.embedder.config.baseURL).toBe("http://192.168.200.83:1234/v1");
|
||||
expect(cfg.embedder.config.model).toBe("text-embedding-gte-qwen2-1.5b-instruct");
|
||||
expect(cfg.embedder.config.model).toBe(
|
||||
"text-embedding-gte-qwen2-1.5b-instruct",
|
||||
);
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(1536);
|
||||
|
||||
expect(cfg.llm.provider).toBe("lmstudio");
|
||||
@@ -409,12 +418,18 @@ describe("Memory – LM Studio end-to-end flow", () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" },
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: { model: "meta-llama-3.1-70b", baseURL: "http://localhost:1234/v1" },
|
||||
config: {
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
@@ -423,11 +438,17 @@ describe("Memory – LM Studio end-to-end flow", () => {
|
||||
|
||||
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({ model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" }),
|
||||
expect.objectContaining({
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
}),
|
||||
);
|
||||
expect(mockLlmFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({ model: "meta-llama-3.1-70b", baseURL: "http://localhost:1234/v1" }),
|
||||
expect.objectContaining({
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -435,10 +456,16 @@ describe("Memory – LM Studio end-to-end flow", () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" },
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: { provider: "lmstudio", config: { baseURL: "http://localhost:1234/v1" } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: { baseURL: "http://localhost:1234/v1" },
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
@@ -462,7 +489,10 @@ describe("Memory – LM Studio end-to-end flow", () => {
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: { model: "openai/gpt-oss-20b", lmstudio_base_url: "http://192.168.200.83:1234/v1" } as any,
|
||||
config: {
|
||||
model: "openai/gpt-oss-20b",
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
@@ -471,32 +501,57 @@ describe("Memory – LM Studio end-to-end flow", () => {
|
||||
|
||||
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({ model: "text-embedding-gte-qwen2-1.5b-instruct", baseURL: "http://192.168.200.83:1234/v1" }),
|
||||
expect.objectContaining({
|
||||
model: "text-embedding-gte-qwen2-1.5b-instruct",
|
||||
baseURL: "http://192.168.200.83:1234/v1",
|
||||
}),
|
||||
);
|
||||
expect(mockLlmFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({ model: "openai/gpt-oss-20b", baseURL: "http://192.168.200.83:1234/v1" }),
|
||||
expect.objectContaining({
|
||||
model: "openai/gpt-oss-20b",
|
||||
baseURL: "http://192.168.200.83:1234/v1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("search flow works with lmstudio embedder", async () => {
|
||||
mockVStore.search.mockResolvedValueOnce([{
|
||||
id: "mem-1",
|
||||
payload: { data: "User likes hiking", user_id: "u1", hash: "abc123", created_at: "2026-01-01" },
|
||||
score: 0.95,
|
||||
}]);
|
||||
mockVStore.search.mockResolvedValueOnce([
|
||||
{
|
||||
id: "mem-1",
|
||||
payload: {
|
||||
data: "User likes hiking",
|
||||
user_id: "u1",
|
||||
hash: "abc123",
|
||||
created_at: "2026-01-01",
|
||||
},
|
||||
score: 0.95,
|
||||
},
|
||||
]);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1", embeddingDims: 768 },
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
embeddingDims: 768,
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 768 },
|
||||
},
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: { baseURL: "http://localhost:1234/v1" },
|
||||
},
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test", dimension: 768 } },
|
||||
llm: { provider: "lmstudio", config: { baseURL: "http://localhost:1234/v1" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
const result = await mem.search("What does the user like?", { userId: "u1" });
|
||||
const result = await mem.search("What does the user like?", {
|
||||
userId: "u1",
|
||||
});
|
||||
|
||||
expect(mockEmbedder.embed).toHaveBeenCalledWith("What does the user like?");
|
||||
expect(mockVStore.search).toHaveBeenCalled();
|
||||
@@ -505,19 +560,31 @@ describe("Memory – LM Studio end-to-end flow", () => {
|
||||
});
|
||||
|
||||
it("add flow works with lmstudio LLM for fact extraction", async () => {
|
||||
mockLlm.generateResponse.mockResolvedValueOnce('{"facts":["User loves sushi"]}');
|
||||
mockLlm.generateResponse.mockResolvedValueOnce(
|
||||
'{"facts":["User loves sushi"]}',
|
||||
);
|
||||
mockVStore.search.mockResolvedValue([]);
|
||||
mockVStore.list.mockResolvedValue([[], 0]);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1", embeddingDims: 768 },
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
embeddingDims: 768,
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 768 },
|
||||
},
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test", dimension: 768 } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: { model: "meta-llama-3.1-70b", baseURL: "http://localhost:1234/v1" },
|
||||
config: {
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
import {
|
||||
EmbedderFactory,
|
||||
VectorStoreFactory,
|
||||
} from "../src/utils/factory";
|
||||
import { LMStudioEmbedder } from "../src/embeddings/lmstudio";
|
||||
import { AzureAISearch } from "../src/vector_stores/azure_ai_search";
|
||||
|
||||
describe("EmbedderFactory", () => {
|
||||
describe("create", () => {
|
||||
it("should create LM Studio embedder with baseURL", () => {
|
||||
const embedder = EmbedderFactory.create("lmstudio", {
|
||||
model: "text-embedding-gte-qwen2-1.5b-instruct",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
expect(embedder).toBeInstanceOf(LMStudioEmbedder);
|
||||
});
|
||||
|
||||
it("should throw error for unsupported embedder provider", () => {
|
||||
expect(() => {
|
||||
EmbedderFactory.create("unsupported-embedder", {});
|
||||
}).toThrow("Unsupported embedder provider: unsupported-embedder");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("VectorStoreFactory", () => {
|
||||
describe("create", () => {
|
||||
it("should create Azure AI Search vector store", () => {
|
||||
const config = {
|
||||
collectionName: "test-memories",
|
||||
serviceName: "test-service",
|
||||
apiKey: "test-api-key",
|
||||
embeddingModelDims: 1536,
|
||||
compressionType: "none" as const,
|
||||
useFloat16: false,
|
||||
hybridSearch: false,
|
||||
vectorFilterMode: "preFilter" as const,
|
||||
};
|
||||
|
||||
const vectorStore = VectorStoreFactory.create("azure-ai-search", config);
|
||||
|
||||
expect(vectorStore).toBeInstanceOf(AzureAISearch);
|
||||
});
|
||||
|
||||
it("should create memory vector store", () => {
|
||||
const config = {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
};
|
||||
|
||||
const vectorStore = VectorStoreFactory.create("memory", config);
|
||||
|
||||
expect(vectorStore).toBeDefined();
|
||||
expect(vectorStore.constructor.name).toBe("MemoryVectorStore");
|
||||
});
|
||||
|
||||
it("should throw error for unsupported provider", () => {
|
||||
const config = {};
|
||||
|
||||
expect(() => {
|
||||
VectorStoreFactory.create("unsupported-provider", config);
|
||||
}).toThrow("Unsupported vector store provider: unsupported-provider");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Factory unit tests — EmbedderFactory, LLMFactory, VectorStoreFactory, HistoryManagerFactory.
|
||||
* Mocks all provider modules to avoid external dependency crashes.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
|
||||
// Mock all provider modules before importing factory
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "openai-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/ollama", () => ({
|
||||
OllamaEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "ollama-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "google-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/azure", () => ({
|
||||
AzureOpenAIEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/langchain", () => ({
|
||||
LangchainEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/lmstudio", () => ({
|
||||
LMStudioEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "lmstudio-embedder", config })),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "openai-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/openai_structured", () => ({
|
||||
OpenAIStructuredLLM: jest.fn().mockImplementation((config) => ({
|
||||
type: "openai-structured-llm",
|
||||
config,
|
||||
})),
|
||||
}));
|
||||
jest.mock("../src/llms/anthropic", () => ({
|
||||
AnthropicLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "anthropic-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/groq", () => ({
|
||||
GroqLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "groq-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/ollama", () => ({
|
||||
OllamaLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "ollama-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "google-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/azure", () => ({
|
||||
AzureOpenAILLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/mistral", () => ({
|
||||
MistralLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "mistral-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/langchain", () => ({
|
||||
LangchainLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/lmstudio", () => ({
|
||||
LMStudioLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "lmstudio-llm", config })),
|
||||
}));
|
||||
|
||||
jest.mock("../src/vector_stores/qdrant", () => ({
|
||||
Qdrant: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "qdrant", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/redis", () => ({
|
||||
RedisDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "redis", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/supabase", () => ({
|
||||
SupabaseDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "supabase", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/langchain", () => ({
|
||||
LangchainVectorStore: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-vs", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/vectorize", () => ({
|
||||
VectorizeDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "vectorize", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/azure_ai_search", () => ({
|
||||
AzureAISearch: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-ai-search", config })),
|
||||
}));
|
||||
jest.mock("../src/storage/SupabaseHistoryManager", () => ({
|
||||
SupabaseHistoryManager: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "supabase-history", config })),
|
||||
}));
|
||||
|
||||
import {
|
||||
EmbedderFactory,
|
||||
LLMFactory,
|
||||
VectorStoreFactory,
|
||||
HistoryManagerFactory,
|
||||
} from "../src/utils/factory";
|
||||
import type {
|
||||
EmbeddingConfig,
|
||||
LLMConfig,
|
||||
VectorStoreConfig,
|
||||
HistoryStoreConfig,
|
||||
} from "../src/types";
|
||||
|
||||
const dummyEmbedConfig: EmbeddingConfig = { apiKey: "test" };
|
||||
const dummyLLMConfig: LLMConfig = { apiKey: "test" };
|
||||
const dummyVSConfig: VectorStoreConfig = {
|
||||
collectionName: "test",
|
||||
dimension: 1536,
|
||||
};
|
||||
|
||||
// ─── EmbedderFactory ────────────────────────────────────
|
||||
|
||||
describe("EmbedderFactory", () => {
|
||||
test.each([
|
||||
["openai"],
|
||||
["ollama"],
|
||||
["google"],
|
||||
["gemini"],
|
||||
["azure_openai"],
|
||||
["langchain"],
|
||||
["lmstudio"],
|
||||
])("creates embedder for provider '%s'", (provider) => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create(provider, dummyEmbedConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create("OpenAI", dummyEmbedConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create("nonexistent", dummyEmbedConfig),
|
||||
).toThrow("Unsupported embedder provider: nonexistent");
|
||||
});
|
||||
|
||||
test("passes config to created embedder", () => {
|
||||
const config: EmbeddingConfig = { apiKey: "my-key", model: "my-model" };
|
||||
const result = EmbedderFactory.create("openai", config) as any;
|
||||
expect(result.config).toBe(config);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── LLMFactory ─────────────────────────────────────────
|
||||
|
||||
describe("LLMFactory", () => {
|
||||
test.each([
|
||||
["openai"],
|
||||
["openai_structured"],
|
||||
["anthropic"],
|
||||
["groq"],
|
||||
["ollama"],
|
||||
["google"],
|
||||
["gemini"],
|
||||
["azure_openai"],
|
||||
["mistral"],
|
||||
["langchain"],
|
||||
["lmstudio"],
|
||||
])("creates LLM for provider '%s'", (provider) => {
|
||||
expect(() => LLMFactory.create(provider, dummyLLMConfig)).not.toThrow();
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(() => LLMFactory.create("Anthropic", dummyLLMConfig)).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() => LLMFactory.create("nonexistent", dummyLLMConfig)).toThrow(
|
||||
"Unsupported LLM provider: nonexistent",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes config to created LLM", () => {
|
||||
const config: LLMConfig = { apiKey: "my-key", model: "gpt-4" };
|
||||
const result = LLMFactory.create("openai", config) as any;
|
||||
expect(result.config).toBe(config);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VectorStoreFactory ─────────────────────────────────
|
||||
|
||||
describe("VectorStoreFactory", () => {
|
||||
test("creates memory vector store", () => {
|
||||
// MemoryVectorStore is real (not mocked) — needs valid config
|
||||
expect(() =>
|
||||
VectorStoreFactory.create("memory", {
|
||||
collectionName: "test",
|
||||
dimension: 4,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["qdrant"],
|
||||
["redis"],
|
||||
["supabase"],
|
||||
["langchain"],
|
||||
["vectorize"],
|
||||
["azure-ai-search"],
|
||||
])("creates vector store for provider '%s'", (provider) => {
|
||||
expect(() =>
|
||||
VectorStoreFactory.create(provider, dummyVSConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() =>
|
||||
VectorStoreFactory.create("nonexistent", dummyVSConfig),
|
||||
).toThrow("Unsupported vector store provider: nonexistent");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── HistoryManagerFactory ──────────────────────────────
|
||||
|
||||
describe("HistoryManagerFactory", () => {
|
||||
test("creates SQLite history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: ":memory:" },
|
||||
};
|
||||
expect(() => HistoryManagerFactory.create("sqlite", config)).not.toThrow();
|
||||
});
|
||||
|
||||
test("creates supabase history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "supabase",
|
||||
config: { supabaseUrl: "http://test", supabaseKey: "key" },
|
||||
};
|
||||
expect(() =>
|
||||
HistoryManagerFactory.create("supabase", config),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("creates memory history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "memory",
|
||||
config: {},
|
||||
};
|
||||
expect(() => HistoryManagerFactory.create("memory", config)).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
const config: HistoryStoreConfig = { provider: "bad", config: {} };
|
||||
expect(() => HistoryManagerFactory.create("bad", config)).toThrow(
|
||||
"Unsupported history store provider: bad",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -61,12 +61,12 @@ describe("LMStudioEmbedder (unit)", () => {
|
||||
});
|
||||
|
||||
it("embedBatch() returns vectors for multiple inputs", async () => {
|
||||
const mockBatch = [[0.1, 0.2], [0.3, 0.4]];
|
||||
const mockBatch = [
|
||||
[0.1, 0.2],
|
||||
[0.3, 0.4],
|
||||
];
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ embedding: mockBatch[0] },
|
||||
{ embedding: mockBatch[1] },
|
||||
],
|
||||
data: [{ embedding: mockBatch[0] }, { embedding: mockBatch[1] }],
|
||||
});
|
||||
|
||||
const embedder = new LMStudioEmbedder({
|
||||
|
||||
@@ -20,17 +20,26 @@ const describeIf = RUN_INTEGRATION ? describe : describe.skip;
|
||||
|
||||
jest.setTimeout(120_000);
|
||||
|
||||
async function listModels(): Promise<{ embedding: string | null; chat: string | null }> {
|
||||
async function listModels(): Promise<{
|
||||
embedding: string | null;
|
||||
chat: string | null;
|
||||
}> {
|
||||
const res = await fetch(`${LMSTUDIO_BASE_URL}/models`);
|
||||
const body = await res.json();
|
||||
const models: any[] = body.data || [];
|
||||
const embedding = models.find((m) => m.id.includes("embed") || m.id.includes("nomic"));
|
||||
const chat = models.find((m) => !m.id.includes("embed") && !m.id.includes("nomic"));
|
||||
const embedding = models.find(
|
||||
(m) => m.id.includes("embed") || m.id.includes("nomic"),
|
||||
);
|
||||
const chat = models.find(
|
||||
(m) => !m.id.includes("embed") && !m.id.includes("nomic"),
|
||||
);
|
||||
return { embedding: embedding?.id ?? null, chat: chat?.id ?? null };
|
||||
}
|
||||
|
||||
function cosineSim(a: number[], b: number[]): number {
|
||||
let dot = 0, normA = 0, normB = 0;
|
||||
let dot = 0,
|
||||
normA = 0,
|
||||
normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
@@ -46,7 +55,10 @@ describeIf("LM Studio Integration", () => {
|
||||
expect(res.ok).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.data).toBeDefined();
|
||||
console.log("Loaded models:", body.data.map((m: any) => m.id));
|
||||
console.log(
|
||||
"Loaded models:",
|
||||
body.data.map((m: any) => m.id),
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Embedder ────────────────────────────────────────────────────────
|
||||
@@ -58,7 +70,10 @@ describeIf("LM Studio Integration", () => {
|
||||
const models = await listModels();
|
||||
if (!models.embedding) throw new Error("No embedding model loaded");
|
||||
modelId = models.embedding;
|
||||
embedder = new LMStudioEmbedder({ baseURL: LMSTUDIO_BASE_URL, model: modelId });
|
||||
embedder = new LMStudioEmbedder({
|
||||
baseURL: LMSTUDIO_BASE_URL,
|
||||
model: modelId,
|
||||
});
|
||||
});
|
||||
|
||||
it("embed() returns a numeric vector", async () => {
|
||||
@@ -94,7 +109,9 @@ describeIf("LM Studio Integration", () => {
|
||||
]);
|
||||
const simSimilar = cosineSim(v1, v2);
|
||||
const simDifferent = cosineSim(v1, v3);
|
||||
console.log(` Similar: ${simSimilar.toFixed(4)}, Different: ${simDifferent.toFixed(4)}`);
|
||||
console.log(
|
||||
` Similar: ${simSimilar.toFixed(4)}, Different: ${simDifferent.toFixed(4)}`,
|
||||
);
|
||||
expect(Number.isFinite(simSimilar)).toBe(true);
|
||||
expect(Number.isFinite(simDifferent)).toBe(true);
|
||||
expect(simSimilar).toBeGreaterThan(simDifferent);
|
||||
|
||||
@@ -18,9 +18,15 @@ describe("LMStudioLLM (unit)", () => {
|
||||
|
||||
it("generateResponse() returns a text response", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{
|
||||
message: { content: "Hello, world!", role: "assistant", tool_calls: null },
|
||||
}],
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "Hello, world!",
|
||||
role: "assistant",
|
||||
tool_calls: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
@@ -34,15 +40,22 @@ describe("LMStudioLLM (unit)", () => {
|
||||
|
||||
it("generateResponse() handles tool calls", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{
|
||||
message: {
|
||||
content: "",
|
||||
role: "assistant",
|
||||
tool_calls: [{
|
||||
function: { name: "get_weather", arguments: '{"city": "London"}' },
|
||||
}],
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "",
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"city": "London"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}],
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
@@ -71,9 +84,11 @@ describe("LMStudioLLM (unit)", () => {
|
||||
|
||||
it("generateChat() returns LLMResponse shape", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{
|
||||
message: { content: "I can help with that.", role: "assistant" },
|
||||
}],
|
||||
choices: [
|
||||
{
|
||||
message: { content: "I can help with that.", role: "assistant" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
@@ -81,7 +96,10 @@ describe("LMStudioLLM (unit)", () => {
|
||||
{ role: "user", content: "Help me" },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ content: "I can help with that.", role: "assistant" });
|
||||
expect(result).toEqual({
|
||||
content: "I can help with that.",
|
||||
role: "assistant",
|
||||
});
|
||||
});
|
||||
|
||||
it("generateChat() wraps API errors with a clear message", async () => {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* OSS Memory unit tests — add() with inference, without inference, filter validation, metadata.
|
||||
* Content-based LLM mock: system-prompt calls → facts, user-only calls → memory actions.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryConfig, MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["extracted fact from input"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "extracted fact from input",
|
||||
old_memory: "",
|
||||
new_memory: "extracted fact from input",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: `test-add-${Date.now()}`, dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Memory - add()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `add_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns SearchResult with results array for string input", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns at least one result with an id", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0].id).toBeDefined();
|
||||
});
|
||||
|
||||
test("result item has a memory string field", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(typeof result.results[0].memory).toBe("string");
|
||||
});
|
||||
|
||||
test("accepts Message[] input", async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What is your favorite city?" },
|
||||
{ role: "assistant", content: "I love Paris." },
|
||||
];
|
||||
const result: SearchResult = await memory.add(messages, { userId });
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("works with agentId filter instead of userId", async () => {
|
||||
const result: SearchResult = await memory.add("test", {
|
||||
agentId: "agent_1",
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("works with runId filter instead of userId", async () => {
|
||||
const result: SearchResult = await memory.add("test", { runId: "run_1" });
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("throws when no userId/agentId/runId provided", async () => {
|
||||
await expect(memory.add("test", {} as any)).rejects.toThrow(
|
||||
"One of the filters: userId, agentId or runId is required!",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes metadata through to stored memory", async () => {
|
||||
const result: SearchResult = await memory.add("I love TypeScript", {
|
||||
userId,
|
||||
metadata: { source: "chat", tag: "programming" },
|
||||
});
|
||||
const stored: MemoryItem | null = await memory.get(result.results[0].id);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored!.metadata).toEqual(
|
||||
expect.objectContaining({ source: "chat", tag: "programming" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("with infer=false skips LLM and stores messages directly", async () => {
|
||||
const result: SearchResult = await memory.add("Direct storage content", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
// When infer=false, the literal message text is stored
|
||||
expect(result.results[0].memory).toBe("Direct storage content");
|
||||
});
|
||||
|
||||
test("with infer=false marks event as ADD in metadata", async () => {
|
||||
const result: SearchResult = await memory.add("Direct fact", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
expect(result.results[0].metadata).toEqual(
|
||||
expect.objectContaining({ event: "ADD" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* OSS Memory unit tests — get, update, delete, deleteAll, getAll, search, history.
|
||||
* Content-based LLM mock. Tests verify real behavior, not mock echoes.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["stored fact"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "stored fact",
|
||||
old_memory: "",
|
||||
new_memory: "stored fact",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: `test-crud-${Date.now()}-${Math.random()}`,
|
||||
dimension: 1536,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── get() ───────────────────────────────────────────────
|
||||
|
||||
describe("Memory - get()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `get_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns the memory matching the ID from add()", async () => {
|
||||
const addResult: SearchResult = await memory.add("I love AI", { userId });
|
||||
const id = addResult.results[0].id;
|
||||
const item: MemoryItem | null = await memory.get(id);
|
||||
expect(item).not.toBeNull();
|
||||
expect(item!.id).toBe(id);
|
||||
});
|
||||
|
||||
test("returns a string for the memory field", async () => {
|
||||
const addResult: SearchResult = await memory.add("Testing get", {
|
||||
userId,
|
||||
});
|
||||
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
|
||||
expect(typeof item!.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("returns null for non-existent ID", async () => {
|
||||
const item = await memory.get("nonexistent-uuid-12345");
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
|
||||
test("returns hash and createdAt on stored memory", async () => {
|
||||
const addResult: SearchResult = await memory.add("Hash test", { userId });
|
||||
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
|
||||
expect(typeof item!.hash).toBe("string");
|
||||
expect(item!.createdAt).toBeDefined();
|
||||
expect(new Date(item!.createdAt!).toString()).not.toBe("Invalid Date");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - update()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `update_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
// Use infer: false for update tests — bypasses LLM, gives us a stable ID
|
||||
test("returns success message", async () => {
|
||||
const addResult: SearchResult = await memory.add("Original", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const result = await memory.update(id, "Updated");
|
||||
expect(result.message).toBe("Memory updated successfully!");
|
||||
});
|
||||
|
||||
test("persists the updated text", async () => {
|
||||
const addResult: SearchResult = await memory.add("Before update", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
await memory.update(id, "After update");
|
||||
const item: MemoryItem | null = await memory.get(id);
|
||||
expect(item!.memory).toBe("After update");
|
||||
});
|
||||
|
||||
test("preserves createdAt and sets updatedAt", async () => {
|
||||
const addResult: SearchResult = await memory.add("Timestamp test", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const before: MemoryItem | null = await memory.get(id);
|
||||
const originalCreatedAt = before!.createdAt;
|
||||
|
||||
await memory.update(id, "New text");
|
||||
const after: MemoryItem | null = await memory.get(id);
|
||||
expect(after!.createdAt).toBe(originalCreatedAt);
|
||||
expect(after!.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
test("updates the hash", async () => {
|
||||
const addResult: SearchResult = await memory.add("Hash change", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const before: MemoryItem | null = await memory.get(id);
|
||||
await memory.update(id, "Completely different text");
|
||||
const after: MemoryItem | null = await memory.get(id);
|
||||
expect(after!.hash).not.toBe(before!.hash);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── delete() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - delete()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `delete_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns success message", async () => {
|
||||
const addResult: SearchResult = await memory.add("Delete me", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const result = await memory.delete(addResult.results[0].id);
|
||||
expect(result.message).toBe("Memory deleted successfully!");
|
||||
});
|
||||
|
||||
test("get() returns null after deletion", async () => {
|
||||
const addResult: SearchResult = await memory.add("Temporary", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
await memory.delete(id);
|
||||
expect(await memory.get(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteAll() ─────────────────────────────────────────
|
||||
|
||||
describe("Memory - deleteAll()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `deleteall_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("removes all memories for the user and returns success", async () => {
|
||||
await memory.add("Fact A", { userId });
|
||||
await memory.add("Fact B", { userId });
|
||||
const result = await memory.deleteAll({ userId });
|
||||
expect(result.message).toBe("Memories deleted successfully!");
|
||||
const remaining: SearchResult = await memory.getAll({ userId });
|
||||
expect(remaining.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("throws when no filter is provided", async () => {
|
||||
await expect(memory.deleteAll({} as any)).rejects.toThrow(
|
||||
"At least one filter is required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAll() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - getAll()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `getall_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns all stored memories for the user", async () => {
|
||||
await memory.add("First", { userId });
|
||||
await memory.add("Second", { userId });
|
||||
const result: SearchResult = await memory.getAll({ userId });
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("each result has id and memory fields", async () => {
|
||||
const result: SearchResult = await memory.getAll({ userId });
|
||||
for (const item of result.results) {
|
||||
expect(item.id).toBeDefined();
|
||||
expect(typeof item.memory).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns empty array when no memories exist", async () => {
|
||||
const result: SearchResult = await memory.getAll({
|
||||
userId: "no_such_user",
|
||||
});
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── search() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - search()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `search_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
await memory.add("I love TypeScript", { userId });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns SearchResult with results array", async () => {
|
||||
const result: SearchResult = await memory.search("TypeScript", { userId });
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns results with score field", async () => {
|
||||
const result: SearchResult = await memory.search("content", { userId });
|
||||
if (result.results.length > 0) {
|
||||
expect(typeof result.results[0].score).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("throws when no userId/agentId/runId provided", async () => {
|
||||
await expect(memory.search("query", {} as any)).rejects.toThrow(
|
||||
"One of the filters: userId, agentId or runId is required!",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty results for user with no memories", async () => {
|
||||
const result: SearchResult = await memory.search("query", {
|
||||
userId: "empty_user",
|
||||
});
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── history() ───────────────────────────────────────────
|
||||
|
||||
describe("Memory - history()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `history_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("records ADD event after add()", async () => {
|
||||
const addResult: SearchResult = await memory.add("New fact", { userId });
|
||||
const history = await memory.history(addResult.results[0].id);
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("records additional entry after update()", async () => {
|
||||
const addResult: SearchResult = await memory.add("Before", { userId });
|
||||
const id = addResult.results[0].id;
|
||||
await memory.update(id, "After");
|
||||
const history = await memory.history(id);
|
||||
expect(history.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("returns empty array for non-existent memory ID", async () => {
|
||||
const history = await memory.history("nonexistent-id");
|
||||
expect(history).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* OSS Memory E2E tests — exercises full add/get/search/update/delete flow with mocked LLM/embedder.
|
||||
* Skipped by default. Run with: MEM0_RUN_E2E=1 npx jest memory.e2e.test.ts
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import { MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
const describeOrSkip = process.env.MEM0_RUN_E2E ? describe : describe.skip;
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
// Mock LLM and embedder so tests run without API keys.
|
||||
// Content-based mock: system-prompt calls → facts, user-only calls → memory actions.
|
||||
jest.mock("../src/embeddings/google", () => ({ GoogleEmbedder: jest.fn() }));
|
||||
jest.mock("../src/llms/google", () => ({ GoogleLLM: jest.fn() }));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({
|
||||
facts: ["John is a software engineer"],
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "John is a software engineer",
|
||||
old_memory: "",
|
||||
new_memory: "John is a software engineer",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
describeOrSkip("Memory Class (E2E)", () => {
|
||||
let memory: Memory;
|
||||
const userId =
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
|
||||
beforeEach(async () => {
|
||||
memory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-memories", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
});
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
describe("add() single memory", () => {
|
||||
let result: SearchResult;
|
||||
|
||||
beforeEach(async () => {
|
||||
result = (await memory.add(
|
||||
"Hi, my name is John and I am a software engineer.",
|
||||
{ userId },
|
||||
)) as SearchResult;
|
||||
});
|
||||
|
||||
it("returns a defined result", () => {
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns results array", () => {
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least one result", () => {
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns result with an id", () => {
|
||||
expect(result.results[0]?.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("add() multiple messages", () => {
|
||||
let result: SearchResult;
|
||||
|
||||
beforeEach(async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What is your favorite city?" },
|
||||
{ role: "assistant", content: "I love Paris, it is my favorite city." },
|
||||
];
|
||||
result = (await memory.add(messages, { userId })) as SearchResult;
|
||||
});
|
||||
|
||||
it("returns results array", () => {
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least one result", () => {
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get() single memory", () => {
|
||||
let memoryItem: MemoryItem;
|
||||
let memoryId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const addResult = (await memory.add(
|
||||
"I am a big advocate of using AI to make the world a better place",
|
||||
{ userId },
|
||||
)) as SearchResult;
|
||||
memoryId = addResult.results[0].id;
|
||||
memoryItem = (await memory.get(memoryId)) as MemoryItem;
|
||||
});
|
||||
|
||||
it("returns the correct id", () => {
|
||||
expect(memoryItem.id).toBe(memoryId);
|
||||
});
|
||||
|
||||
it("returns a string memory", () => {
|
||||
expect(typeof memoryItem.memory).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update() memory", () => {
|
||||
let memoryId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const addResult = (await memory.add(
|
||||
"I love speaking foreign languages especially Spanish",
|
||||
{ userId },
|
||||
)) as SearchResult;
|
||||
memoryId = addResult.results[0].id;
|
||||
});
|
||||
|
||||
it("returns success message", async () => {
|
||||
const result = await memory.update(memoryId, "Updated content");
|
||||
expect(result.message).toBe("Memory updated successfully!");
|
||||
});
|
||||
|
||||
it("persists the updated content", async () => {
|
||||
await memory.update(memoryId, "Updated content");
|
||||
const updated = (await memory.get(memoryId)) as MemoryItem;
|
||||
expect(updated.memory).toBe("Updated content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAll() memories for user", () => {
|
||||
let result: SearchResult;
|
||||
|
||||
beforeEach(async () => {
|
||||
await memory.add("I love visiting new places in the winters", { userId });
|
||||
await memory.add("I like to rule the world", { userId });
|
||||
result = (await memory.getAll({ userId })) as SearchResult;
|
||||
});
|
||||
|
||||
it("returns results array", () => {
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least two results", () => {
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search() memories", () => {
|
||||
let result: SearchResult;
|
||||
|
||||
beforeEach(async () => {
|
||||
await memory.add("I love programming in Python", { userId });
|
||||
await memory.add("JavaScript is my favorite language", { userId });
|
||||
result = (await memory.search("What programming languages do I know?", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
});
|
||||
|
||||
it("returns results array", () => {
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least one result", () => {
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("history() of a memory", () => {
|
||||
let history: unknown[];
|
||||
|
||||
beforeEach(async () => {
|
||||
const addResult = (await memory.add("I like swimming in warm water", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
const memoryId = addResult.results[0].id;
|
||||
await memory.update(memoryId, "Updated content");
|
||||
history = await memory.history(memoryId);
|
||||
});
|
||||
|
||||
it("returns an array", () => {
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least one entry", () => {
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete() a memory", () => {
|
||||
it("returns null after deletion", async () => {
|
||||
const addResult = (await memory.add("I love to drink vodka in summers", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
const memoryId = addResult.results[0].id;
|
||||
await memory.delete(memoryId);
|
||||
const result = await memory.get(memoryId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory with Custom Configuration", () => {
|
||||
let customMemory: Memory;
|
||||
|
||||
beforeEach(() => {
|
||||
customMemory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-memories", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await customMemory.reset();
|
||||
});
|
||||
|
||||
it("add() returns results with custom config", async () => {
|
||||
const result = (await customMemory.add("I love programming in Python", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("search() returns results with custom config", async () => {
|
||||
await customMemory.add("The weather in London is rainy today", {
|
||||
userId,
|
||||
});
|
||||
await customMemory.add("The temperature in Paris is 25 degrees", {
|
||||
userId,
|
||||
});
|
||||
const result = (await customMemory.search("What is the weather like?", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* OSS Memory unit tests — constructor, initialization, config validation, reset.
|
||||
* Mocks LLM/Embedder at module level. No API keys needed.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryConfig, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
// ─── Content-based LLM mock (reviewer #9) ────────────────
|
||||
// Returns facts for system-prompt calls, memory actions for user-only calls.
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["test fact"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "test fact",
|
||||
old_memory: "",
|
||||
new_memory: "test fact",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-init", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Memory - Initialization", () => {
|
||||
test("constructs without throwing with valid config", () => {
|
||||
expect(() => createMemory()).not.toThrow();
|
||||
});
|
||||
|
||||
test("fromConfig creates instance from config dict", () => {
|
||||
const config = {
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4" },
|
||||
},
|
||||
};
|
||||
const mem = Memory.fromConfig(config);
|
||||
expect(mem).toBeInstanceOf(Memory);
|
||||
});
|
||||
|
||||
test("fromConfig throws on invalid config", () => {
|
||||
expect(() => Memory.fromConfig({ invalid: true } as any)).toThrow();
|
||||
});
|
||||
|
||||
test("disableHistory=true uses DummyHistoryManager (no crash on history)", async () => {
|
||||
const mem = createMemory({ disableHistory: true });
|
||||
// If DummyHistoryManager is used, history returns [] without error
|
||||
const result = await mem.history("nonexistent-id");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory - reset()", () => {
|
||||
test("reset clears all stored memories", async () => {
|
||||
const mem = createMemory();
|
||||
const userId = `reset_test_${Date.now()}`;
|
||||
|
||||
await mem.add("Remember this fact", { userId });
|
||||
const before: SearchResult = await mem.getAll({ userId });
|
||||
expect(before.results.length).toBeGreaterThan(0);
|
||||
|
||||
await mem.reset();
|
||||
|
||||
const after: SearchResult = await mem.getAll({ userId });
|
||||
expect(after.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src";
|
||||
import { MemoryItem, SearchResult } from "../src/types";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
jest.setTimeout(30000); // Increase timeout to 30 seconds
|
||||
|
||||
describe("Memory Class", () => {
|
||||
let memory: Memory;
|
||||
const userId =
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
|
||||
beforeEach(async () => {
|
||||
// Initialize with default configuration
|
||||
memory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "gpt-4-turbo-preview",
|
||||
},
|
||||
},
|
||||
historyDbPath: ":memory:", // Use in-memory SQLite for tests
|
||||
});
|
||||
// Reset all memories before each test
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up after each test
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
describe("Basic Memory Operations", () => {
|
||||
it("should add a single memory", async () => {
|
||||
const result = (await memory.add(
|
||||
"Hi, my name is John and I am a software engineer.",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0]?.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("should add multiple messages", async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What is your favorite city?" },
|
||||
{ role: "assistant", content: "I love Paris, it is my favorite city." },
|
||||
];
|
||||
|
||||
const result = (await memory.add(messages, userId)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should get a single memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I am a big advocate of using AI to make the world a better place",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
const result = (await memory.get(memoryId)) as MemoryItem;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(memoryId);
|
||||
expect(result.memory).toBeDefined();
|
||||
expect(typeof result.memory).toBe("string");
|
||||
});
|
||||
|
||||
it("should update a memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I love speaking foreign languages especially Spanish",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
const updatedContent = "Updated content";
|
||||
const result = await memory.update(memoryId, updatedContent);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.message).toBe("Memory updated successfully!");
|
||||
|
||||
// Verify the update by getting the memory
|
||||
const updatedMemory = (await memory.get(memoryId)) as MemoryItem;
|
||||
expect(updatedMemory.memory).toBe(updatedContent);
|
||||
});
|
||||
|
||||
it("should get all memories for a user", async () => {
|
||||
// Add a few memories
|
||||
await memory.add("I love visiting new places in the winters", userId);
|
||||
await memory.add("I like to rule the world", userId);
|
||||
|
||||
const result = (await memory.getAll(userId)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should search memories", async () => {
|
||||
// Add some test memories
|
||||
await memory.add("I love programming in Python", userId);
|
||||
await memory.add("JavaScript is my favorite language", userId);
|
||||
|
||||
const result = (await memory.search(
|
||||
"What programming languages do I know?",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should get memory history", async () => {
|
||||
// Add and update a memory to create history
|
||||
const addResult = (await memory.add(
|
||||
"I like swimming in warm water",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
await memory.update(memoryId, "Updated content");
|
||||
|
||||
const history = await memory.history(memoryId);
|
||||
|
||||
expect(history).toBeDefined();
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should delete a memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I love to drink vodka in summers",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
|
||||
// Delete the memory
|
||||
await memory.delete(memoryId);
|
||||
|
||||
// Try to get the deleted memory - should throw or return null
|
||||
const result = await memory.get(memoryId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory with Custom Configuration", () => {
|
||||
let customMemory: Memory;
|
||||
|
||||
beforeEach(() => {
|
||||
customMemory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "gpt-4-turbo-preview",
|
||||
},
|
||||
},
|
||||
historyDbPath: ":memory:", // Use in-memory SQLite for tests
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await customMemory.reset();
|
||||
});
|
||||
|
||||
it("should work with custom configuration", async () => {
|
||||
const result = (await customMemory.add(
|
||||
"I love programming in Python",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should perform semantic search with custom embeddings", async () => {
|
||||
// Add test memories
|
||||
await customMemory.add("The weather in London is rainy today", userId);
|
||||
await customMemory.add("The temperature in Paris is 25 degrees", userId);
|
||||
|
||||
const result = (await customMemory.search(
|
||||
"What is the weather like?",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
// Results should be ordered by relevance
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Storage manager unit tests — SQLiteManager, DummyHistoryManager.
|
||||
* Uses real in-memory SQLite, no external dependencies.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { SQLiteManager } from "../src/storage/SQLiteManager";
|
||||
import { DummyHistoryManager } from "../src/storage/DummyHistoryManager";
|
||||
import { MemoryHistoryManager } from "../src/storage/MemoryHistoryManager";
|
||||
|
||||
// ─── SQLiteManager ──────────────────────────────────────
|
||||
|
||||
describe("SQLiteManager", () => {
|
||||
let db: SQLiteManager;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new SQLiteManager(":memory:");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("constructs without throwing", () => {
|
||||
expect(db).toBeDefined();
|
||||
});
|
||||
|
||||
test("addHistory inserts a record retrievable by getHistory", async () => {
|
||||
await db.addHistory(
|
||||
"mem1",
|
||||
null,
|
||||
"new value",
|
||||
"ADD",
|
||||
"2026-01-01T00:00:00Z",
|
||||
);
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].memory_id).toBe("mem1");
|
||||
expect(history[0].new_value).toBe("new value");
|
||||
expect(history[0].action).toBe("ADD");
|
||||
});
|
||||
|
||||
test("getHistory returns records in reverse chronological order", async () => {
|
||||
await db.addHistory("mem1", null, "first", "ADD", "2026-01-01");
|
||||
await db.addHistory("mem1", "first", "second", "UPDATE", "2026-01-02");
|
||||
await db.addHistory("mem1", "second", "third", "UPDATE", "2026-01-03");
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history).toHaveLength(3);
|
||||
// DESC order by id: most recent first
|
||||
expect(history[0].new_value).toBe("third");
|
||||
expect(history[2].new_value).toBe("first");
|
||||
});
|
||||
|
||||
test("getHistory returns empty array for non-existent memory", async () => {
|
||||
const history = await db.getHistory("nonexistent");
|
||||
expect(history).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("addHistory stores previous_value for UPDATE", async () => {
|
||||
await db.addHistory("mem1", "old text", "new text", "UPDATE");
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history[0].previous_value).toBe("old text");
|
||||
expect(history[0].new_value).toBe("new text");
|
||||
});
|
||||
|
||||
test("addHistory stores null new_value for DELETE", async () => {
|
||||
await db.addHistory(
|
||||
"mem1",
|
||||
"deleted text",
|
||||
null,
|
||||
"DELETE",
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
);
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history[0].action).toBe("DELETE");
|
||||
expect(history[0].new_value).toBeNull();
|
||||
expect(history[0].is_deleted).toBe(1);
|
||||
});
|
||||
|
||||
test("reset clears all history and recreates table", async () => {
|
||||
await db.addHistory("mem1", null, "data", "ADD");
|
||||
await db.addHistory("mem2", null, "data", "ADD");
|
||||
await db.reset();
|
||||
expect(await db.getHistory("mem1")).toHaveLength(0);
|
||||
expect(await db.getHistory("mem2")).toHaveLength(0);
|
||||
// Table still works after reset
|
||||
await db.addHistory("mem3", null, "after reset", "ADD");
|
||||
expect(await db.getHistory("mem3")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("stores createdAt and updatedAt timestamps", async () => {
|
||||
const created = "2026-03-17T10:00:00Z";
|
||||
const updated = "2026-03-17T11:00:00Z";
|
||||
await db.addHistory("mem1", null, "data", "ADD", created, updated);
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history[0].created_at).toBe(created);
|
||||
expect(history[0].updated_at).toBe(updated);
|
||||
});
|
||||
|
||||
test("handles multiple memories independently", async () => {
|
||||
await db.addHistory("mem1", null, "data1", "ADD");
|
||||
await db.addHistory("mem2", null, "data2", "ADD");
|
||||
expect(await db.getHistory("mem1")).toHaveLength(1);
|
||||
expect(await db.getHistory("mem2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DummyHistoryManager ────────────────────────────────
|
||||
|
||||
describe("DummyHistoryManager", () => {
|
||||
let dummy: DummyHistoryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
dummy = new DummyHistoryManager();
|
||||
});
|
||||
|
||||
test("constructs without throwing", () => {
|
||||
expect(dummy).toBeDefined();
|
||||
});
|
||||
|
||||
test("addHistory is a no-op that resolves", async () => {
|
||||
await expect(
|
||||
dummy.addHistory("id", null, "val", "ADD"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("getHistory returns empty array", async () => {
|
||||
const result = await dummy.getHistory("any-id");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("reset resolves without throwing", async () => {
|
||||
await expect(dummy.reset()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("close does not throw", () => {
|
||||
expect(() => dummy.close()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MemoryHistoryManager ───────────────────────────────
|
||||
|
||||
describe("MemoryHistoryManager", () => {
|
||||
let mgr: MemoryHistoryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mgr = new MemoryHistoryManager();
|
||||
});
|
||||
|
||||
test("constructs without throwing", () => {
|
||||
expect(mgr).toBeDefined();
|
||||
});
|
||||
|
||||
test("addHistory + getHistory round-trips correctly", async () => {
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
null,
|
||||
"new value",
|
||||
"ADD",
|
||||
"2026-01-01T00:00:00Z",
|
||||
);
|
||||
const history = await mgr.getHistory("mem1");
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].memory_id).toBe("mem1");
|
||||
expect(history[0].new_value).toBe("new value");
|
||||
expect(history[0].action).toBe("ADD");
|
||||
});
|
||||
|
||||
test("getHistory returns entries sorted by date descending", async () => {
|
||||
await mgr.addHistory("mem1", null, "first", "ADD", "2026-01-01T00:00:00Z");
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
"first",
|
||||
"second",
|
||||
"UPDATE",
|
||||
"2026-01-02T00:00:00Z",
|
||||
);
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
"second",
|
||||
"third",
|
||||
"UPDATE",
|
||||
"2026-01-03T00:00:00Z",
|
||||
);
|
||||
const history = await mgr.getHistory("mem1");
|
||||
expect(history).toHaveLength(3);
|
||||
expect(history[0].new_value).toBe("third");
|
||||
expect(history[2].new_value).toBe("first");
|
||||
});
|
||||
|
||||
test("getHistory returns empty array for non-existent memory", async () => {
|
||||
expect(await mgr.getHistory("nonexistent")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("getHistory caps at 100 entries", async () => {
|
||||
for (let i = 0; i < 110; i++) {
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
null,
|
||||
`entry-${i}`,
|
||||
"ADD",
|
||||
`2026-01-01T00:${String(i).padStart(2, "0")}:00Z`,
|
||||
);
|
||||
}
|
||||
const history = await mgr.getHistory("mem1");
|
||||
expect(history).toHaveLength(100);
|
||||
});
|
||||
|
||||
test("reset clears all entries", async () => {
|
||||
await mgr.addHistory("mem1", null, "data", "ADD");
|
||||
await mgr.addHistory("mem2", null, "data", "ADD");
|
||||
await mgr.reset();
|
||||
expect(await mgr.getHistory("mem1")).toHaveLength(0);
|
||||
expect(await mgr.getHistory("mem2")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("close does not throw", () => {
|
||||
expect(() => mgr.close()).not.toThrow();
|
||||
});
|
||||
|
||||
test("isolates history by memory_id", async () => {
|
||||
await mgr.addHistory("mem1", null, "d1", "ADD");
|
||||
await mgr.addHistory("mem2", null, "d2", "ADD");
|
||||
expect(await mgr.getHistory("mem1")).toHaveLength(1);
|
||||
expect(await mgr.getHistory("mem2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* MemoryVectorStore unit tests — insert, search, get, update, delete, list, cosine similarity.
|
||||
* Uses real SQLite in-memory DB, no external dependencies.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { MemoryVectorStore } from "../src/vector_stores/memory";
|
||||
import type { VectorStoreResult } from "../src/types";
|
||||
|
||||
const DIM = 4; // Small dimension for fast tests
|
||||
|
||||
function createStore(): MemoryVectorStore {
|
||||
return new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: DIM,
|
||||
dbPath: ":memory:",
|
||||
});
|
||||
}
|
||||
|
||||
function vec(values: number[]): number[] {
|
||||
return values;
|
||||
}
|
||||
|
||||
describe("MemoryVectorStore - insert + get", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(() => {
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
test("inserts and retrieves a vector by ID", async () => {
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0])],
|
||||
["id1"],
|
||||
[{ data: "hello", userId: "u1" }],
|
||||
);
|
||||
const result: VectorStoreResult | null = await store.get("id1");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe("id1");
|
||||
expect(result!.payload.data).toBe("hello");
|
||||
});
|
||||
|
||||
test("returns null for non-existent ID", async () => {
|
||||
const result = await store.get("nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("throws on dimension mismatch during insert", async () => {
|
||||
await expect(
|
||||
store.insert([vec([1, 0, 0])], ["bad"], [{ data: "x" }]),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - search", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = createStore();
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0]), vec([0.9, 0.1, 0, 0])],
|
||||
["a", "b", "c"],
|
||||
[
|
||||
{ data: "north", userId: "u1" },
|
||||
{ data: "east", userId: "u1" },
|
||||
{ data: "north-ish", userId: "u2" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns results sorted by cosine similarity descending", async () => {
|
||||
const results: VectorStoreResult[] = await store.search(
|
||||
vec([1, 0, 0, 0]),
|
||||
10,
|
||||
);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].id).toBe("a"); // exact match
|
||||
// scores should be descending
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i - 1].score!).toBeGreaterThanOrEqual(results[i].score!);
|
||||
}
|
||||
});
|
||||
|
||||
test("respects limit parameter", async () => {
|
||||
const results = await store.search(vec([1, 0, 0, 0]), 1);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("filters by userId", async () => {
|
||||
const results = await store.search(vec([1, 0, 0, 0]), 10, { userId: "u2" });
|
||||
expect(results.every((r) => r.payload.userId === "u2")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns empty when filter matches nothing", async () => {
|
||||
const results = await store.search(vec([1, 0, 0, 0]), 10, {
|
||||
userId: "nobody",
|
||||
});
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("throws on query dimension mismatch", async () => {
|
||||
await expect(store.search(vec([1, 0]), 10)).rejects.toThrow(
|
||||
"Query dimension mismatch",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - update", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = createStore();
|
||||
await store.insert([vec([1, 0, 0, 0])], ["upd1"], [{ data: "original" }]);
|
||||
});
|
||||
|
||||
test("updates payload and vector", async () => {
|
||||
await store.update("upd1", vec([0, 1, 0, 0]), { data: "updated" });
|
||||
const result = await store.get("upd1");
|
||||
expect(result!.payload.data).toBe("updated");
|
||||
});
|
||||
|
||||
test("throws on dimension mismatch during update", async () => {
|
||||
await expect(
|
||||
store.update("upd1", vec([1, 0]), { data: "bad" }),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - delete + deleteCol", () => {
|
||||
test("delete removes a vector", async () => {
|
||||
const store = createStore();
|
||||
await store.insert([vec([1, 0, 0, 0])], ["del1"], [{ data: "bye" }]);
|
||||
await store.delete("del1");
|
||||
expect(await store.get("del1")).toBeNull();
|
||||
});
|
||||
|
||||
test("deleteCol clears all vectors", async () => {
|
||||
const store = createStore();
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0])],
|
||||
["x", "y"],
|
||||
[{ data: "a" }, { data: "b" }],
|
||||
);
|
||||
await store.deleteCol();
|
||||
const [results] = await store.list();
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - list", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = createStore();
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0]), vec([0, 0, 1, 0])],
|
||||
["l1", "l2", "l3"],
|
||||
[
|
||||
{ data: "a", userId: "u1" },
|
||||
{ data: "b", userId: "u1" },
|
||||
{ data: "c", userId: "u2" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns all vectors without filter", async () => {
|
||||
const [results, count] = await store.list();
|
||||
expect(count).toBe(3);
|
||||
expect(results).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("filters by userId", async () => {
|
||||
const [results, count] = await store.list({ userId: "u1" });
|
||||
expect(count).toBe(2);
|
||||
expect(results.every((r) => r.payload.userId === "u1")).toBe(true);
|
||||
});
|
||||
|
||||
test("respects limit", async () => {
|
||||
const [results] = await store.list(undefined, 1);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - userId tracking", () => {
|
||||
test("getUserId generates and persists a random ID", async () => {
|
||||
const store = createStore();
|
||||
const id = await store.getUserId();
|
||||
expect(typeof id).toBe("string");
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
// Calling again returns same ID
|
||||
expect(await store.getUserId()).toBe(id);
|
||||
});
|
||||
|
||||
test("setUserId overrides the stored ID", async () => {
|
||||
const store = createStore();
|
||||
await store.setUserId("custom-id");
|
||||
expect(await store.getUserId()).toBe("custom-id");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user