diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index 1d8f7ba4786..9ea3576bac7 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -4,14 +4,19 @@ import { OpenAiEmbedder } from "../embedders/openai" import { CodeIndexOllamaEmbedder } from "../embedders/ollama" import { OpenAICompatibleEmbedder } from "../embedders/openai-compatible" import { GeminiEmbedder } from "../embedders/gemini" -import { QdrantVectorStore } from "../vector-store/qdrant-client" +import { CodeIndexVectorStoreAdapter } from "../vector-store-adapter" // Mock the embedders and vector store vitest.mock("../embedders/openai") vitest.mock("../embedders/ollama") vitest.mock("../embedders/openai-compatible") vitest.mock("../embedders/gemini") -vitest.mock("../vector-store/qdrant-client") +vitest.mock("../vector-store-adapter") +const hoisted = vitest.hoisted(() => ({ createFactoryMock: vitest.fn() })) +const createFactoryMock = hoisted.createFactoryMock +vitest.mock("../vector-store/factory", () => ({ + VectorStoreFactory: { create: hoisted.createFactoryMock }, +})) // Mock the embedding models module vitest.mock("../../../shared/embeddingModels", () => ({ @@ -32,7 +37,9 @@ const MockedOpenAiEmbedder = OpenAiEmbedder as MockedClass const MockedOpenAICompatibleEmbedder = OpenAICompatibleEmbedder as MockedClass const MockedGeminiEmbedder = GeminiEmbedder as MockedClass -const MockedQdrantVectorStore = QdrantVectorStore as MockedClass +const MockedCodeIndexVectorStoreAdapter = CodeIndexVectorStoreAdapter as unknown as MockedClass< + typeof CodeIndexVectorStoreAdapter +> // Import the mocked functions import { getDefaultModelId, getModelDimension } from "../../../shared/embeddingModels" @@ -46,6 +53,20 @@ describe("CodeIndexServiceFactory", () => { beforeEach(() => { vitest.clearAllMocks() + createFactoryMock.mockReset() + // Default shared adapter stub + createFactoryMock.mockReturnValue({ + provider: () => "qdrant", + capabilities: () => ({ deleteByFilter: true }), + collectionName: () => "ws-abcdef0123456789", + ensureCollection: vitest.fn(), + upsert: vitest.fn(), + search: vitest.fn(), + clearAll: vitest.fn(), + deleteCollection: vitest.fn(), + collectionExists: vitest.fn(), + deleteByFilter: vitest.fn(), + }) mockConfigManager = { getConfig: vitest.fn(), @@ -362,12 +383,9 @@ describe("CodeIndexServiceFactory", () => { // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai", testModelId) - expect(MockedQdrantVectorStore).toHaveBeenCalledWith( - "/test/workspace", - "http://localhost:6333", - 3072, - "test-key", - ) + expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled() + const args0 = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0] + expect(args0[1]).toBe(3072) }) it("should use config.modelId for Ollama provider", () => { @@ -387,12 +405,9 @@ describe("CodeIndexServiceFactory", () => { // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("ollama", testModelId) - expect(MockedQdrantVectorStore).toHaveBeenCalledWith( - "/test/workspace", - "http://localhost:6333", - 768, - "test-key", - ) + expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled() + const adapterArgs = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0] + expect(adapterArgs[1]).toBe(768) }) it("should use config.modelId for OpenAI Compatible provider", () => { @@ -412,12 +427,9 @@ describe("CodeIndexServiceFactory", () => { // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) - expect(MockedQdrantVectorStore).toHaveBeenCalledWith( - "/test/workspace", - "http://localhost:6333", - 3072, - "test-key", - ) + expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled() + const adapterArgs2 = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0] + expect(adapterArgs2[1]).toBe(3072) }) it("should prioritize getModelDimension over manual modelDimension for OpenAI Compatible provider", () => { @@ -444,12 +456,9 @@ describe("CodeIndexServiceFactory", () => { // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) - expect(MockedQdrantVectorStore).toHaveBeenCalledWith( - "/test/workspace", - "http://localhost:6333", - modelDimension, // Should use model's built-in dimension, not manual - "test-key", - ) + expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled() + const argsA = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0] + expect(argsA[1]).toBe(modelDimension) }) it("should use manual modelDimension only when model has no built-in dimension", () => { @@ -475,12 +484,9 @@ describe("CodeIndexServiceFactory", () => { // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) - expect(MockedQdrantVectorStore).toHaveBeenCalledWith( - "/test/workspace", - "http://localhost:6333", - manualDimension, // Should use manual dimension as fallback - "test-key", - ) + expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled() + const argsB = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0] + expect(argsB[1]).toBe(manualDimension) }) it("should fall back to getModelDimension when manual modelDimension is not set for OpenAI Compatible", () => { @@ -504,12 +510,9 @@ describe("CodeIndexServiceFactory", () => { // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) - expect(MockedQdrantVectorStore).toHaveBeenCalledWith( - "/test/workspace", - "http://localhost:6333", - 768, - "test-key", - ) + expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled() + const argsC = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0] + expect(argsC[1]).toBe(768) }) it("should throw error when manual modelDimension is invalid for OpenAI Compatible", () => { @@ -573,12 +576,9 @@ describe("CodeIndexServiceFactory", () => { // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("gemini", "gemini-embedding-001") - expect(MockedQdrantVectorStore).toHaveBeenCalledWith( - "/test/workspace", - "http://localhost:6333", - 3072, - "test-key", - ) + expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled() + const adapterArgs3 = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0] + expect(adapterArgs3[1]).toBe(3072) }) it("should use default model dimension for Gemini when modelId not specified", () => { @@ -598,12 +598,9 @@ describe("CodeIndexServiceFactory", () => { // Assert expect(mockGetDefaultModelId).toHaveBeenCalledWith("gemini") expect(mockGetModelDimension).toHaveBeenCalledWith("gemini", "gemini-embedding-001") - expect(MockedQdrantVectorStore).toHaveBeenCalledWith( - "/test/workspace", - "http://localhost:6333", - 3072, - "test-key", - ) + expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled() + const argsD = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0] + expect(argsD[1]).toBe(3072) }) it("should use default model when config.modelId is undefined", () => { @@ -622,12 +619,9 @@ describe("CodeIndexServiceFactory", () => { // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai", "default-model") - expect(MockedQdrantVectorStore).toHaveBeenCalledWith( - "/test/workspace", - "http://localhost:6333", - 1536, - "test-key", - ) + expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled() + const adapterArgs4 = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0] + expect(adapterArgs4[1]).toBe(1536) }) it("should throw error when vector dimension cannot be determined", () => { diff --git a/src/services/code-index/__tests__/vector-store-adapter.spec.ts b/src/services/code-index/__tests__/vector-store-adapter.spec.ts new file mode 100644 index 00000000000..fcf52fd67a0 --- /dev/null +++ b/src/services/code-index/__tests__/vector-store-adapter.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import { CodeIndexVectorStoreAdapter } from "../vector-store-adapter" + +vi.mock("../../../utils/path", () => ({ getWorkspacePath: () => "/test/workspace" })) + +describe("CodeIndexVectorStoreAdapter", () => { + let mockAdapter: any + let store: CodeIndexVectorStoreAdapter + + beforeEach(() => { + mockAdapter = { + collectionName: vi.fn().mockReturnValue("ws-abcdef0123456789"), + ensureCollection: vi.fn().mockResolvedValue(true), + upsert: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([]), + deleteByFilter: vi.fn().mockResolvedValue(undefined), + clearAll: vi.fn().mockResolvedValue(undefined), + deleteCollection: vi.fn().mockResolvedValue(undefined), + collectionExists: vi.fn().mockResolvedValue(true), + capabilities: vi.fn().mockReturnValue({ deleteByFilter: true }), + } + store = new CodeIndexVectorStoreAdapter(mockAdapter, 1536) + }) + + it("initialize delegates to adapter via ensureOnce policy and returns created flag", async () => { + const created = await store.initialize() + expect(created).toBe(true) + expect(mockAdapter.ensureCollection).toHaveBeenCalledWith("ws-abcdef0123456789", 1536) + }) + + it("search builds directory prefix filter using pathSegments.*", async () => { + await store.search([0.1, 0.2], "src/utils") + const call = mockAdapter.search.mock.calls[0] + const filter = call[2] + expect(filter).toEqual({ + must: [ + { key: "pathSegments.0", match: { value: "src" } }, + { key: "pathSegments.1", match: { value: "utils" } }, + ], + }) + }) + + it("deletePointsByMultipleFilePaths builds OR filter and calls deleteByFilter", async () => { + mockAdapter.collectionExists.mockResolvedValueOnce(true) + await store.deletePointsByMultipleFilePaths(["/test/workspace/src/a.ts", "src/b.ts"]) + const filter = mockAdapter.deleteByFilter.mock.calls[0][0] + expect(filter.should).toBeDefined() + expect(filter.should.length).toBe(2) + // First branch should match src/a.ts exactly + expect(filter.should[0].must).toEqual([ + { key: "pathSegments.0", match: { value: "src" } }, + { key: "pathSegments.1", match: { value: "a.ts" } }, + ]) + }) + + it("deletePointsByMultipleFilePaths is no-op when collection is missing (parity)", async () => { + mockAdapter.collectionExists.mockResolvedValueOnce(false) + await store.deletePointsByMultipleFilePaths(["src/a.ts"]) // should not call deleteByFilter + expect(mockAdapter.deleteByFilter).not.toHaveBeenCalled() + }) +}) diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index 6d69e1f0b6c..5ab5c185096 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -6,7 +6,8 @@ import { GeminiEmbedder } from "./embedders/gemini" import { MistralEmbedder } from "./embedders/mistral" import { VercelAiGatewayEmbedder } from "./embedders/vercel-ai-gateway" import { EmbedderProvider, getDefaultModelId, getModelDimension } from "../../shared/embeddingModels" -import { QdrantVectorStore } from "./vector-store/qdrant-client" +import { VectorStoreFactory } from "../vector-store/factory" +import { CodeIndexVectorStoreAdapter } from "./vector-store-adapter" import { codeParser, DirectoryScanner, FileWatcher } from "./processors" import { ICodeParser, IEmbedder, IFileWatcher, IVectorStore } from "./interfaces" import { CodeIndexConfigManager } from "./config-manager" @@ -145,8 +146,13 @@ export class CodeIndexServiceFactory { throw new Error(t("embeddings:serviceFactory.qdrantUrlMissing")) } - // Assuming constructor is updated: new QdrantVectorStore(workspacePath, url, vectorSize, apiKey?) - return new QdrantVectorStore(this.workspacePath, config.qdrantUrl, vectorSize, config.qdrantApiKey) + const shared = VectorStoreFactory.create({ + provider: "qdrant", + workspacePath: this.workspacePath, + dimension: vectorSize, + qdrant: { url: config.qdrantUrl, apiKey: config.qdrantApiKey }, + }) + return new CodeIndexVectorStoreAdapter(shared, vectorSize) } /** diff --git a/src/services/code-index/vector-store-adapter.ts b/src/services/code-index/vector-store-adapter.ts new file mode 100644 index 00000000000..5fc9037700b --- /dev/null +++ b/src/services/code-index/vector-store-adapter.ts @@ -0,0 +1,96 @@ +import { IVectorStore, VectorStoreSearchResult, Payload } from "./interfaces" +import { VectorDatabaseAdapter } from "../vector-store/interfaces" +import { CollectionManager } from "../vector-store/collection-manager" +import { FilterTranslator, QdrantFilterTranslator } from "../vector-store/filters" +import { getWorkspacePath } from "../../utils/path" + +/** + * Bridges Code Index's `IVectorStore` interface to the provider-agnostic + * `VectorDatabaseAdapter`. + * + * Notes + * - initialize() uses ensure-once and returns true on create/recreate (parity). + * - search() builds Qdrant-compatible directory prefix filters via translator. + * - deletePointsBy* no-ops when collection is missing (legacy parity). + */ +export class CodeIndexVectorStoreAdapter implements IVectorStore { + private ensured = false + constructor( + private readonly adapter: VectorDatabaseAdapter, + private readonly dimension: number, + private readonly translator: FilterTranslator = new QdrantFilterTranslator(), + private readonly workspaceRoot: string = getWorkspacePath() ?? "", + ) {} + + async initialize(): Promise { + const name = this.adapter.collectionName() + const created = await CollectionManager.ensureOnce( + (n, d) => this.adapter.ensureCollection(n, d), + name, + this.dimension, + ) + this.ensured = true + return created + } + + /** Upserts points by mapping to adapter records. */ + async upsertPoints(points: Array<{ id: string; vector: number[]; payload: Record }>): Promise { + const records = points.map((p) => ({ id: p.id, vector: p.vector, payload: p.payload })) + await this.adapter.upsert(records) + } + + /** + * Vector similarity search. + * @param queryVector Query embedding. + * @param directoryPrefix Optional directory prefix filter; '.' or './' result in no filter. + * @param minScore Optional minimum similarity score. + * @param maxResults Optional max number of results. + */ + async search( + queryVector: number[], + directoryPrefix?: string, + minScore?: number, + maxResults?: number, + ): Promise { + const filter = this.translator.directoryPrefixToFilter(directoryPrefix) + const results = await this.adapter.search(queryVector, maxResults ?? 10, filter, minScore) + return results.map((r) => ({ id: r.id, score: (r as any).score, payload: r.payload as Payload })) + } + + /** Deletes all points for a single file path. */ + async deletePointsByFilePath(filePath: string): Promise { + await this.deletePointsByMultipleFilePaths([filePath]) + } + + /** + * Deletes all points for the provided file paths. For parity, this is a no-op + * when the collection does not exist. + */ + async deletePointsByMultipleFilePaths(filePaths: string[]): Promise { + if (filePaths.length === 0) return + const caps = this.adapter.capabilities() + const filter = this.translator.filePathsToDeleteFilter(filePaths, this.workspaceRoot) + if (caps.deleteByFilter && typeof this.adapter.deleteByFilter === "function" && filter) { + // Match legacy behavior: no-op if collection doesn't exist + if (!(await this.adapter.collectionExists())) return + await this.adapter.deleteByFilter(filter) + } else { + // No-op fallback for now since Qdrant supports delete-by-filter + } + } + + /** Clears all points from the current collection. */ + async clearCollection(): Promise { + await this.adapter.clearAll() + } + + /** Deletes the current collection. */ + async deleteCollection(): Promise { + await this.adapter.deleteCollection() + } + + /** Returns true if the current collection exists. */ + async collectionExists(): Promise { + return this.adapter.collectionExists() + } +} diff --git a/src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts b/src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts deleted file mode 100644 index 8947c2f3e79..00000000000 --- a/src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts +++ /dev/null @@ -1,1752 +0,0 @@ -import { QdrantClient } from "@qdrant/js-client-rest" -import { createHash } from "crypto" - -import { QdrantVectorStore } from "../qdrant-client" -import { getWorkspacePath } from "../../../../utils/path" -import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE } from "../../constants" - -// Mocks -vitest.mock("@qdrant/js-client-rest") -vitest.mock("crypto") -vitest.mock("../../../../utils/path") -vitest.mock("../../../../i18n", () => ({ - t: (key: string, params?: any) => { - // Mock translation function that includes parameters for testing - if (key === "embeddings:vectorStore.vectorDimensionMismatch" && params?.errorMessage) { - return `Failed to update vector index for new model. Please try clearing the index and starting again. Details: ${params.errorMessage}` - } - if (key === "embeddings:vectorStore.qdrantConnectionFailed" && params?.qdrantUrl && params?.errorMessage) { - return `Failed to connect to Qdrant vector database. Please ensure Qdrant is running and accessible at ${params.qdrantUrl}. Error: ${params.errorMessage}` - } - return key // Just return the key for other cases - }, -})) -vitest.mock("path", async () => { - const actual = await vitest.importActual("path") - return { - ...actual, - sep: "/", - posix: actual.posix, - } -}) - -const mockQdrantClientInstance = { - getCollection: vitest.fn(), - createCollection: vitest.fn(), - deleteCollection: vitest.fn(), - createPayloadIndex: vitest.fn(), - upsert: vitest.fn(), - query: vitest.fn(), - delete: vitest.fn(), -} - -const mockCreateHashInstance = { - update: vitest.fn().mockReturnThis(), - digest: vitest.fn(), -} - -describe("QdrantVectorStore", () => { - let vectorStore: QdrantVectorStore - const mockWorkspacePath = "/test/workspace" - const mockQdrantUrl = "http://mock-qdrant:6333" - const mockApiKey = "test-api-key" - const mockVectorSize = 1536 - const mockHashedPath = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" // Needs to be long enough - const expectedCollectionName = `ws-${mockHashedPath.substring(0, 16)}` - - beforeEach(() => { - vitest.clearAllMocks() - - // Mock QdrantClient constructor - ;(QdrantClient as any).mockImplementation(() => mockQdrantClientInstance) - - // Mock crypto.createHash - ;(createHash as any).mockReturnValue(mockCreateHashInstance) - mockCreateHashInstance.update.mockReturnValue(mockCreateHashInstance) // Ensure it returns 'this' - mockCreateHashInstance.digest.mockReturnValue(mockHashedPath) - - // Mock getWorkspacePath - ;(getWorkspacePath as any).mockReturnValue(mockWorkspacePath) - - vectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize, mockApiKey) - }) - - it("should correctly initialize QdrantClient and collectionName in constructor", () => { - expect(QdrantClient).toHaveBeenCalledTimes(1) - expect(QdrantClient).toHaveBeenCalledWith({ - host: "mock-qdrant", - https: false, - port: 6333, - apiKey: mockApiKey, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect(createHash).toHaveBeenCalledWith("sha256") - expect(mockCreateHashInstance.update).toHaveBeenCalledWith(mockWorkspacePath) - expect(mockCreateHashInstance.digest).toHaveBeenCalledWith("hex") - // Access private member for testing constructor logic (not ideal, but necessary here) - expect((vectorStore as any).collectionName).toBe(expectedCollectionName) - expect((vectorStore as any).vectorSize).toBe(mockVectorSize) - }) - it("should handle constructor with default URL when none provided", () => { - const vectorStoreWithDefaults = new QdrantVectorStore(mockWorkspacePath, undefined as any, mockVectorSize) - - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "localhost", - https: false, - port: 6333, - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - }) - - it("should handle constructor without API key", () => { - const vectorStoreWithoutKey = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize) - - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "mock-qdrant", - https: false, - port: 6333, - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - }) - - describe("URL Parsing and Explicit Port Handling", () => { - describe("HTTPS URL handling", () => { - it("should use explicit port 443 for HTTPS URLs without port (fixes the main bug)", () => { - const vectorStore = new QdrantVectorStore( - mockWorkspacePath, - "https://qdrant.ashbyfam.com", - mockVectorSize, - ) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "qdrant.ashbyfam.com", - https: true, - port: 443, - prefix: undefined, // No prefix for root path - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("https://qdrant.ashbyfam.com") - }) - - it("should use explicit port for HTTPS URLs with explicit port", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "https://example.com:9000", mockVectorSize) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "example.com", - https: true, - port: 9000, - prefix: undefined, // No prefix for root path - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("https://example.com:9000") - }) - - it("should use port 443 for HTTPS URLs with paths and query parameters", () => { - const vectorStore = new QdrantVectorStore( - mockWorkspacePath, - "https://example.com/api/v1?key=value", - mockVectorSize, - ) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "example.com", - https: true, - port: 443, - prefix: "/api/v1", // Should have prefix - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("https://example.com/api/v1?key=value") - }) - }) - - describe("HTTP URL handling", () => { - it("should use explicit port 80 for HTTP URLs without port", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://example.com", mockVectorSize) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "example.com", - https: false, - port: 80, - prefix: undefined, // No prefix for root path - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("http://example.com") - }) - - it("should use explicit port for HTTP URLs with explicit port", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://localhost:8080", mockVectorSize) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "localhost", - https: false, - port: 8080, - prefix: undefined, // No prefix for root path - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("http://localhost:8080") - }) - - it("should use port 80 for HTTP URLs while preserving paths and query parameters", () => { - const vectorStore = new QdrantVectorStore( - mockWorkspacePath, - "http://example.com/api/v1?key=value", - mockVectorSize, - ) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "example.com", - https: false, - port: 80, - prefix: "/api/v1", // Should have prefix - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("http://example.com/api/v1?key=value") - }) - }) - - describe("Hostname handling", () => { - it("should convert hostname to http with port 80", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "qdrant.example.com", mockVectorSize) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "qdrant.example.com", - https: false, - port: 80, - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("http://qdrant.example.com") - }) - - it("should handle hostname:port format with explicit port", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "localhost:6333", mockVectorSize) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "localhost", - https: false, - port: 6333, - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333") - }) - - it("should handle explicit HTTP URLs correctly", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://localhost:9000", mockVectorSize) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "localhost", - https: false, - port: 9000, - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("http://localhost:9000") - }) - }) - - describe("IP address handling", () => { - it("should convert IP address to http with port 80", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "192.168.1.100", mockVectorSize) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "192.168.1.100", - https: false, - port: 80, - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("http://192.168.1.100") - }) - - it("should handle IP:port format with explicit port", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "192.168.1.100:6333", mockVectorSize) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "192.168.1.100", - https: false, - port: 6333, - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("http://192.168.1.100:6333") - }) - }) - - describe("Edge cases", () => { - it("should handle undefined URL with host-based config", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, undefined as any, mockVectorSize) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "localhost", - https: false, - port: 6333, - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333") - }) - - it("should handle empty string URL with host-based config", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "", mockVectorSize) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "localhost", - https: false, - port: 6333, - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333") - }) - - it("should handle whitespace-only URL with host-based config", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, " ", mockVectorSize) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "localhost", - https: false, - port: 6333, - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333") - }) - }) - - describe("Invalid URL fallback", () => { - it("should treat invalid URLs as hostnames with port 80", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "invalid-url-format", mockVectorSize) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "invalid-url-format", - https: false, - port: 80, - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStore as any).qdrantUrl).toBe("http://invalid-url-format") - }) - }) - }) - - describe("URL Prefix Handling", () => { - it("should pass the URL pathname as prefix to QdrantClient if not root", () => { - const vectorStoreWithPrefix = new QdrantVectorStore( - mockWorkspacePath, - "http://localhost:6333/some/path", - mockVectorSize, - ) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "localhost", - https: false, - port: 6333, - prefix: "/some/path", - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStoreWithPrefix as any).qdrantUrl).toBe("http://localhost:6333/some/path") - }) - - it("should not pass prefix if the URL pathname is root ('/')", () => { - const vectorStoreWithoutPrefix = new QdrantVectorStore( - mockWorkspacePath, - "http://localhost:6333/", - mockVectorSize, - ) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "localhost", - https: false, - port: 6333, - prefix: undefined, - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStoreWithoutPrefix as any).qdrantUrl).toBe("http://localhost:6333/") - }) - - it("should handle HTTPS URL with path as prefix", () => { - const vectorStoreWithHttpsPrefix = new QdrantVectorStore( - mockWorkspacePath, - "https://qdrant.ashbyfam.com/api", - mockVectorSize, - ) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "qdrant.ashbyfam.com", - https: true, - port: 443, - prefix: "/api", - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStoreWithHttpsPrefix as any).qdrantUrl).toBe("https://qdrant.ashbyfam.com/api") - }) - - it("should normalize URL pathname by removing trailing slash for prefix", () => { - const vectorStoreWithTrailingSlash = new QdrantVectorStore( - mockWorkspacePath, - "http://localhost:6333/api/", - mockVectorSize, - ) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "localhost", - https: false, - port: 6333, - prefix: "/api", // Trailing slash should be removed - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStoreWithTrailingSlash as any).qdrantUrl).toBe("http://localhost:6333/api/") - }) - - it("should normalize URL pathname by removing multiple trailing slashes for prefix", () => { - const vectorStoreWithMultipleTrailingSlashes = new QdrantVectorStore( - mockWorkspacePath, - "http://localhost:6333/api///", - mockVectorSize, - ) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "localhost", - https: false, - port: 6333, - prefix: "/api", // All trailing slashes should be removed - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStoreWithMultipleTrailingSlashes as any).qdrantUrl).toBe("http://localhost:6333/api///") - }) - - it("should handle multiple path segments correctly for prefix", () => { - const vectorStoreWithMultiSegment = new QdrantVectorStore( - mockWorkspacePath, - "http://localhost:6333/api/v1/qdrant", - mockVectorSize, - ) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "localhost", - https: false, - port: 6333, - prefix: "/api/v1/qdrant", - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStoreWithMultiSegment as any).qdrantUrl).toBe("http://localhost:6333/api/v1/qdrant") - }) - - it("should handle complex URL with multiple segments, multiple trailing slashes, query params, and fragment", () => { - const complexUrl = "https://example.com/ollama/api/v1///?key=value#pos" - const vectorStoreComplex = new QdrantVectorStore(mockWorkspacePath, complexUrl, mockVectorSize) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "example.com", - https: true, - port: 443, - prefix: "/ollama/api/v1", // Trailing slash removed, query/fragment ignored - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStoreComplex as any).qdrantUrl).toBe(complexUrl) - }) - - it("should ignore query parameters and fragments when determining prefix", () => { - const vectorStoreWithQueryParams = new QdrantVectorStore( - mockWorkspacePath, - "http://localhost:6333/api/path?key=value#fragment", - mockVectorSize, - ) - expect(QdrantClient).toHaveBeenLastCalledWith({ - host: "localhost", - https: false, - port: 6333, - prefix: "/api/path", // Query params and fragment should be ignored - apiKey: undefined, - headers: { - "User-Agent": "Roo-Code", - }, - }) - expect((vectorStoreWithQueryParams as any).qdrantUrl).toBe( - "http://localhost:6333/api/path?key=value#fragment", - ) - }) - }) - - describe("initialize", () => { - it("should create a new collection if none exists and return true", async () => { - // Mock getCollection to throw a 404-like error - mockQdrantClientInstance.getCollection.mockRejectedValue({ - response: { status: 404 }, - message: "Not found", - }) - mockQdrantClientInstance.createCollection.mockResolvedValue(true as any) // Cast to any to satisfy QdrantClient types if strict - mockQdrantClientInstance.createPayloadIndex.mockResolvedValue({} as any) // Mock successful index creation - - const result = await vectorStore.initialize() - - expect(result).toBe(true) - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledWith(expectedCollectionName) - expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledWith(expectedCollectionName, { - vectors: { - size: mockVectorSize, - distance: "Cosine", // Assuming 'Cosine' is the DISTANCE_METRIC - on_disk: true, - }, - hnsw_config: { - m: 64, - ef_construct: 512, - on_disk: true, - }, - }) - expect(mockQdrantClientInstance.deleteCollection).not.toHaveBeenCalled() - - // Verify payload index creation - for (let i = 0; i <= 4; i++) { - expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledWith(expectedCollectionName, { - field_name: `pathSegments.${i}`, - field_schema: "keyword", - }) - } - expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledTimes(5) - }) - it("should not create a new collection if one exists with matching vectorSize and return false", async () => { - // Mock getCollection to return existing collection info with matching vector size - mockQdrantClientInstance.getCollection.mockResolvedValue({ - config: { - params: { - vectors: { - size: mockVectorSize, // Matching vector size - }, - }, - }, - } as any) // Cast to any to satisfy QdrantClient types - mockQdrantClientInstance.createPayloadIndex.mockResolvedValue({} as any) - - const result = await vectorStore.initialize() - - expect(result).toBe(false) - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledWith(expectedCollectionName) - expect(mockQdrantClientInstance.createCollection).not.toHaveBeenCalled() - expect(mockQdrantClientInstance.deleteCollection).not.toHaveBeenCalled() - - // Verify payload index creation still happens - for (let i = 0; i <= 4; i++) { - expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledWith(expectedCollectionName, { - field_name: `pathSegments.${i}`, - field_schema: "keyword", - }) - } - expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledTimes(5) - }) - it("should recreate collection if it exists but vectorSize mismatches and return true", async () => { - const differentVectorSize = 768 - // Mock getCollection to return existing collection info with different vector size first, - // then return 404 to confirm deletion - mockQdrantClientInstance.getCollection - .mockResolvedValueOnce({ - config: { - params: { - vectors: { - size: differentVectorSize, // Mismatching vector size - }, - }, - }, - } as any) - .mockRejectedValueOnce({ - response: { status: 404 }, - message: "Not found", - }) - mockQdrantClientInstance.deleteCollection.mockResolvedValue(true as any) - mockQdrantClientInstance.createCollection.mockResolvedValue(true as any) - mockQdrantClientInstance.createPayloadIndex.mockResolvedValue({} as any) - vitest.spyOn(console, "warn").mockImplementation(() => {}) // Suppress console.warn - - const result = await vectorStore.initialize() - - expect(result).toBe(true) - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(2) // Once to check, once to verify deletion - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledWith(expectedCollectionName) - expect(mockQdrantClientInstance.deleteCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.deleteCollection).toHaveBeenCalledWith(expectedCollectionName) - expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledWith(expectedCollectionName, { - vectors: { - size: mockVectorSize, // Should use the new, correct vector size - distance: "Cosine", - on_disk: true, - }, - hnsw_config: { - m: 64, - ef_construct: 512, - on_disk: true, - }, - }) - - // Verify payload index creation - for (let i = 0; i <= 4; i++) { - expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledWith(expectedCollectionName, { - field_name: `pathSegments.${i}`, - field_schema: "keyword", - }) - } - expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledTimes(5) - ;(console.warn as any).mockRestore() // Restore console.warn - }) - it("should log warning for non-404 errors but still create collection", async () => { - const genericError = new Error("Generic Qdrant Error") - mockQdrantClientInstance.getCollection.mockRejectedValue(genericError) - vitest.spyOn(console, "warn").mockImplementation(() => {}) // Suppress console.warn - - const result = await vectorStore.initialize() - - expect(result).toBe(true) // Collection was created - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.deleteCollection).not.toHaveBeenCalled() - expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledTimes(5) - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining(`Warning during getCollectionInfo for "${expectedCollectionName}"`), - genericError.message, - ) - ;(console.warn as any).mockRestore() - }) - it("should re-throw error from createCollection when no collection initially exists", async () => { - mockQdrantClientInstance.getCollection.mockRejectedValue({ - response: { status: 404 }, - message: "Not found", - }) - const createError = new Error("Create Collection Failed") - mockQdrantClientInstance.createCollection.mockRejectedValue(createError) - vitest.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error - - // The actual error message includes the URL and error details - await expect(vectorStore.initialize()).rejects.toThrow( - /Failed to connect to Qdrant vector database|vectorStore\.qdrantConnectionFailed/, - ) - - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.deleteCollection).not.toHaveBeenCalled() - expect(mockQdrantClientInstance.createPayloadIndex).not.toHaveBeenCalled() // Should not be called if createCollection fails - expect(console.error).toHaveBeenCalledTimes(1) // Only the outer try/catch - ;(console.error as any).mockRestore() - }) - it("should log but not fail if payload index creation errors occur", async () => { - // Mock successful collection creation - mockQdrantClientInstance.getCollection.mockRejectedValue({ - response: { status: 404 }, - message: "Not found", - }) - mockQdrantClientInstance.createCollection.mockResolvedValue(true as any) - - // Mock payload index creation to fail - const indexError = new Error("Index creation failed") - mockQdrantClientInstance.createPayloadIndex.mockRejectedValue(indexError) - vitest.spyOn(console, "warn").mockImplementation(() => {}) // Suppress console.warn - - const result = await vectorStore.initialize() - - // Should still return true since main collection setup succeeded - expect(result).toBe(true) - expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1) - - // Verify all payload index creations were attempted - expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledTimes(5) - - // Verify warnings were logged for each failed index - expect(console.warn).toHaveBeenCalledTimes(5) - for (let i = 0; i <= 4; i++) { - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining(`Could not create payload index for pathSegments.${i}`), - indexError.message, - ) - } - - ;(console.warn as any).mockRestore() - }) - - it("should throw vectorDimensionMismatch error when deleteCollection fails during recreation", async () => { - const differentVectorSize = 768 - mockQdrantClientInstance.getCollection.mockResolvedValue({ - config: { - params: { - vectors: { - size: differentVectorSize, - }, - }, - }, - } as any) - - const deleteError = new Error("Delete Collection Failed") - mockQdrantClientInstance.deleteCollection.mockRejectedValue(deleteError) - vitest.spyOn(console, "error").mockImplementation(() => {}) - vitest.spyOn(console, "warn").mockImplementation(() => {}) - - // The error should have a cause property set to the original error - let caughtError: any - try { - await vectorStore.initialize() - } catch (error: any) { - caughtError = error - } - - expect(caughtError).toBeDefined() - expect(caughtError.message).toContain("Failed to update vector index for new model") - expect(caughtError.cause).toBe(deleteError) - - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.deleteCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.createCollection).not.toHaveBeenCalled() - expect(mockQdrantClientInstance.createPayloadIndex).not.toHaveBeenCalled() - // Should log both the warning and the critical error - expect(console.warn).toHaveBeenCalledTimes(1) - expect(console.error).toHaveBeenCalledTimes(2) // One for the critical error, one for the outer catch - ;(console.error as any).mockRestore() - ;(console.warn as any).mockRestore() - }) - - it("should throw vectorDimensionMismatch error when createCollection fails during recreation", async () => { - const differentVectorSize = 768 - mockQdrantClientInstance.getCollection - .mockResolvedValueOnce({ - config: { - params: { - vectors: { - size: differentVectorSize, - }, - }, - }, - } as any) - // Second call should return 404 to confirm deletion - .mockRejectedValueOnce({ - response: { status: 404 }, - message: "Not found", - }) - - // Delete succeeds but create fails - mockQdrantClientInstance.deleteCollection.mockResolvedValue(true as any) - const createError = new Error("Create Collection Failed") - mockQdrantClientInstance.createCollection.mockRejectedValue(createError) - vitest.spyOn(console, "error").mockImplementation(() => {}) - vitest.spyOn(console, "warn").mockImplementation(() => {}) - - // Should throw an error with cause property set to the original error - let caughtError: any - try { - await vectorStore.initialize() - } catch (error: any) { - caughtError = error - } - - expect(caughtError).toBeDefined() - expect(caughtError.message).toContain("Failed to update vector index for new model") - expect(caughtError.cause).toBe(createError) - - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(2) - expect(mockQdrantClientInstance.deleteCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.createPayloadIndex).not.toHaveBeenCalled() - // Should log warning, critical error, and outer error - expect(console.warn).toHaveBeenCalledTimes(1) - expect(console.error).toHaveBeenCalledTimes(2) - ;(console.error as any).mockRestore() - ;(console.warn as any).mockRestore() - }) - - it("should verify collection deletion before proceeding with recreation", async () => { - const differentVectorSize = 768 - mockQdrantClientInstance.getCollection - .mockResolvedValueOnce({ - config: { - params: { - vectors: { - size: differentVectorSize, - }, - }, - }, - } as any) - // Second call should return 404 to confirm deletion - .mockRejectedValueOnce({ - response: { status: 404 }, - message: "Not found", - }) - - mockQdrantClientInstance.deleteCollection.mockResolvedValue(true as any) - mockQdrantClientInstance.createCollection.mockResolvedValue(true as any) - mockQdrantClientInstance.createPayloadIndex.mockResolvedValue({} as any) - vitest.spyOn(console, "warn").mockImplementation(() => {}) - - const result = await vectorStore.initialize() - - expect(result).toBe(true) - // Should call getCollection twice: once to check existing, once to verify deletion - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(2) - expect(mockQdrantClientInstance.deleteCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledTimes(5) - ;(console.warn as any).mockRestore() - }) - - it("should throw error if collection still exists after deletion attempt", async () => { - const differentVectorSize = 768 - mockQdrantClientInstance.getCollection - .mockResolvedValueOnce({ - config: { - params: { - vectors: { - size: differentVectorSize, - }, - }, - }, - } as any) - // Second call should still return the collection (deletion failed) - .mockResolvedValueOnce({ - config: { - params: { - vectors: { - size: differentVectorSize, - }, - }, - }, - } as any) - - mockQdrantClientInstance.deleteCollection.mockResolvedValue(true as any) - vitest.spyOn(console, "error").mockImplementation(() => {}) - vitest.spyOn(console, "warn").mockImplementation(() => {}) - - let caughtError: any - try { - await vectorStore.initialize() - } catch (error: any) { - caughtError = error - } - - expect(caughtError).toBeDefined() - expect(caughtError.message).toContain("Failed to update vector index for new model") - // The error message should contain the contextual error details - expect(caughtError.message).toContain("Deleted existing collection but failed verification step") - - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(2) - expect(mockQdrantClientInstance.deleteCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.createCollection).not.toHaveBeenCalled() - expect(mockQdrantClientInstance.createPayloadIndex).not.toHaveBeenCalled() - ;(console.error as any).mockRestore() - ;(console.warn as any).mockRestore() - }) - - it("should handle dimension mismatch scenario from 2048 to 768 dimensions", async () => { - // Simulate the exact scenario from the issue: switching from 2048 to 768 dimensions - const oldVectorSize = 2048 - const newVectorSize = 768 - - // Create a new vector store with the new dimension - const newVectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, newVectorSize, mockApiKey) - - mockQdrantClientInstance.getCollection - .mockResolvedValueOnce({ - config: { - params: { - vectors: { - size: oldVectorSize, // Existing collection has 2048 dimensions - }, - }, - }, - } as any) - // Second call should return 404 to confirm deletion - .mockRejectedValueOnce({ - response: { status: 404 }, - message: "Not found", - }) - - mockQdrantClientInstance.deleteCollection.mockResolvedValue(true as any) - mockQdrantClientInstance.createCollection.mockResolvedValue(true as any) - mockQdrantClientInstance.createPayloadIndex.mockResolvedValue({} as any) - vitest.spyOn(console, "warn").mockImplementation(() => {}) - - const result = await newVectorStore.initialize() - - expect(result).toBe(true) - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(2) - expect(mockQdrantClientInstance.deleteCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledWith(expectedCollectionName, { - vectors: { - size: newVectorSize, // Should create with new 768 dimensions - distance: "Cosine", - on_disk: true, - }, - hnsw_config: { - m: 64, - ef_construct: 512, - on_disk: true, - }, - }) - expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledTimes(5) - ;(console.warn as any).mockRestore() - }) - - it("should provide detailed error context for different failure scenarios", async () => { - const differentVectorSize = 768 - mockQdrantClientInstance.getCollection.mockResolvedValue({ - config: { - params: { - vectors: { - size: differentVectorSize, - }, - }, - }, - } as any) - - // Test deletion failure with specific error message - const deleteError = new Error("Qdrant server unavailable") - mockQdrantClientInstance.deleteCollection.mockRejectedValue(deleteError) - vitest.spyOn(console, "error").mockImplementation(() => {}) - vitest.spyOn(console, "warn").mockImplementation(() => {}) - - let caughtError: any - try { - await vectorStore.initialize() - } catch (error: any) { - caughtError = error - } - - expect(caughtError).toBeDefined() - expect(caughtError.message).toContain("Failed to update vector index for new model") - // The error message should contain the contextual error details - expect(caughtError.message).toContain("Failed to delete existing collection with vector size") - expect(caughtError.message).toContain("Qdrant server unavailable") - expect(caughtError.cause).toBe(deleteError) - ;(console.error as any).mockRestore() - ;(console.warn as any).mockRestore() - }) - }) - - it("should return true when collection exists", async () => { - mockQdrantClientInstance.getCollection.mockResolvedValue({ - config: { - /* collection data */ - }, - } as any) - - const result = await vectorStore.collectionExists() - - expect(result).toBe(true) - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledWith(expectedCollectionName) - }) - - it("should return false when collection does not exist (404 error)", async () => { - mockQdrantClientInstance.getCollection.mockRejectedValue({ - response: { status: 404 }, - message: "Not found", - }) - - const result = await vectorStore.collectionExists() - - expect(result).toBe(false) - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledWith(expectedCollectionName) - }) - - it("should return false and log warning for non-404 errors", async () => { - const genericError = new Error("Network error") - mockQdrantClientInstance.getCollection.mockRejectedValue(genericError) - vitest.spyOn(console, "warn").mockImplementation(() => {}) - - const result = await vectorStore.collectionExists() - - expect(result).toBe(false) - expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(1) - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining(`Warning during getCollectionInfo for "${expectedCollectionName}"`), - genericError.message, - ) - ;(console.warn as any).mockRestore() - }) - describe("deleteCollection", () => { - it("should delete collection when it exists", async () => { - // Mock collectionExists to return true - vitest.spyOn(vectorStore, "collectionExists").mockResolvedValue(true) - mockQdrantClientInstance.deleteCollection.mockResolvedValue(true as any) - - await vectorStore.deleteCollection() - - expect(vectorStore.collectionExists).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.deleteCollection).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.deleteCollection).toHaveBeenCalledWith(expectedCollectionName) - }) - - it("should not attempt to delete collection when it does not exist", async () => { - // Mock collectionExists to return false - vitest.spyOn(vectorStore, "collectionExists").mockResolvedValue(false) - - await vectorStore.deleteCollection() - - expect(vectorStore.collectionExists).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.deleteCollection).not.toHaveBeenCalled() - }) - - it("should log and re-throw error when deletion fails", async () => { - vitest.spyOn(vectorStore, "collectionExists").mockResolvedValue(true) - const deleteError = new Error("Deletion failed") - mockQdrantClientInstance.deleteCollection.mockRejectedValue(deleteError) - vitest.spyOn(console, "error").mockImplementation(() => {}) - - await expect(vectorStore.deleteCollection()).rejects.toThrow(deleteError) - - expect(vectorStore.collectionExists).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.deleteCollection).toHaveBeenCalledTimes(1) - expect(console.error).toHaveBeenCalledWith( - `[QdrantVectorStore] Failed to delete collection ${expectedCollectionName}:`, - deleteError, - ) - ;(console.error as any).mockRestore() - }) - }) - - describe("upsertPoints", () => { - it("should correctly call qdrantClient.upsert with processed points", async () => { - const mockPoints = [ - { - id: "test-id-1", - vector: [0.1, 0.2, 0.3], - payload: { - filePath: "src/components/Button.tsx", - content: "export const Button = () => {}", - startLine: 1, - endLine: 3, - }, - }, - { - id: "test-id-2", - vector: [0.4, 0.5, 0.6], - payload: { - filePath: "src/utils/helpers.ts", - content: "export function helper() {}", - startLine: 5, - endLine: 7, - }, - }, - ] - - mockQdrantClientInstance.upsert.mockResolvedValue({} as any) - - await vectorStore.upsertPoints(mockPoints) - - expect(mockQdrantClientInstance.upsert).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.upsert).toHaveBeenCalledWith(expectedCollectionName, { - points: [ - { - id: "test-id-1", - vector: [0.1, 0.2, 0.3], - payload: { - filePath: "src/components/Button.tsx", - content: "export const Button = () => {}", - startLine: 1, - endLine: 3, - pathSegments: { - "0": "src", - "1": "components", - "2": "Button.tsx", - }, - }, - }, - { - id: "test-id-2", - vector: [0.4, 0.5, 0.6], - payload: { - filePath: "src/utils/helpers.ts", - content: "export function helper() {}", - startLine: 5, - endLine: 7, - pathSegments: { - "0": "src", - "1": "utils", - "2": "helpers.ts", - }, - }, - }, - ], - wait: true, - }) - }) - - it("should handle points without filePath in payload", async () => { - const mockPoints = [ - { - id: "test-id-1", - vector: [0.1, 0.2, 0.3], - payload: { - content: "some content without filePath", - startLine: 1, - endLine: 3, - }, - }, - ] - - mockQdrantClientInstance.upsert.mockResolvedValue({} as any) - - await vectorStore.upsertPoints(mockPoints) - - expect(mockQdrantClientInstance.upsert).toHaveBeenCalledWith(expectedCollectionName, { - points: [ - { - id: "test-id-1", - vector: [0.1, 0.2, 0.3], - payload: { - content: "some content without filePath", - startLine: 1, - endLine: 3, - }, - }, - ], - wait: true, - }) - }) - - it("should handle empty input arrays", async () => { - mockQdrantClientInstance.upsert.mockResolvedValue({} as any) - - await vectorStore.upsertPoints([]) - - expect(mockQdrantClientInstance.upsert).toHaveBeenCalledWith(expectedCollectionName, { - points: [], - wait: true, - }) - }) - - it("should correctly process pathSegments for nested file paths", async () => { - const mockPoints = [ - { - id: "test-id-1", - vector: [0.1, 0.2, 0.3], - payload: { - filePath: "src/components/ui/forms/InputField.tsx", - content: "export const InputField = () => {}", - startLine: 1, - endLine: 3, - }, - }, - ] - - mockQdrantClientInstance.upsert.mockResolvedValue({} as any) - - await vectorStore.upsertPoints(mockPoints) - - expect(mockQdrantClientInstance.upsert).toHaveBeenCalledWith(expectedCollectionName, { - points: [ - { - id: "test-id-1", - vector: [0.1, 0.2, 0.3], - payload: { - filePath: "src/components/ui/forms/InputField.tsx", - content: "export const InputField = () => {}", - startLine: 1, - endLine: 3, - pathSegments: { - "0": "src", - "1": "components", - "2": "ui", - "3": "forms", - "4": "InputField.tsx", - }, - }, - }, - ], - wait: true, - }) - }) - - it("should handle error scenarios when qdrantClient.upsert fails", async () => { - const mockPoints = [ - { - id: "test-id-1", - vector: [0.1, 0.2, 0.3], - payload: { - filePath: "src/test.ts", - content: "test content", - startLine: 1, - endLine: 1, - }, - }, - ] - - const upsertError = new Error("Upsert failed") - mockQdrantClientInstance.upsert.mockRejectedValue(upsertError) - vitest.spyOn(console, "error").mockImplementation(() => {}) - - await expect(vectorStore.upsertPoints(mockPoints)).rejects.toThrow(upsertError) - - expect(mockQdrantClientInstance.upsert).toHaveBeenCalledTimes(1) - expect(console.error).toHaveBeenCalledWith("Failed to upsert points:", upsertError) - ;(console.error as any).mockRestore() - }) - }) - - describe("search", () => { - it("should correctly call qdrantClient.query and transform results", async () => { - const queryVector = [0.1, 0.2, 0.3] - const mockQdrantResults = { - points: [ - { - id: "test-id-1", - score: 0.85, - payload: { - filePath: "src/test.ts", - codeChunk: "test code", - startLine: 1, - endLine: 5, - pathSegments: { "0": "src", "1": "test.ts" }, - }, - }, - { - id: "test-id-2", - score: 0.75, - payload: { - filePath: "src/utils.ts", - codeChunk: "utility code", - startLine: 10, - endLine: 15, - pathSegments: { "0": "src", "1": "utils.ts" }, - }, - }, - ], - } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - const results = await vectorStore.search(queryVector) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledTimes(1) - expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, { - query: queryVector, - filter: undefined, - score_threshold: DEFAULT_SEARCH_MIN_SCORE, - limit: DEFAULT_MAX_SEARCH_RESULTS, - params: { - hnsw_ef: 128, - exact: false, - }, - with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], - }, - }) - - expect(results).toEqual(mockQdrantResults.points) - }) - - it("should apply filePathPrefix filter correctly", async () => { - const queryVector = [0.1, 0.2, 0.3] - const directoryPrefix = "src/components" - const mockQdrantResults = { - points: [ - { - id: "test-id-1", - score: 0.85, - payload: { - filePath: "src/components/Button.tsx", - codeChunk: "button code", - startLine: 1, - endLine: 5, - pathSegments: { "0": "src", "1": "components", "2": "Button.tsx" }, - }, - }, - ], - } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - const results = await vectorStore.search(queryVector, directoryPrefix) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, { - query: queryVector, - filter: { - must: [ - { - key: "pathSegments.0", - match: { value: "src" }, - }, - { - key: "pathSegments.1", - match: { value: "components" }, - }, - ], - }, - score_threshold: DEFAULT_SEARCH_MIN_SCORE, - limit: DEFAULT_MAX_SEARCH_RESULTS, - params: { - hnsw_ef: 128, - exact: false, - }, - with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], - }, - }) - - expect(results).toEqual(mockQdrantResults.points) - }) - - it("should use custom minScore when provided", async () => { - const queryVector = [0.1, 0.2, 0.3] - const customMinScore = 0.8 - const mockQdrantResults = { points: [] } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - await vectorStore.search(queryVector, undefined, customMinScore) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, { - query: queryVector, - filter: undefined, - score_threshold: customMinScore, - limit: DEFAULT_MAX_SEARCH_RESULTS, - params: { - hnsw_ef: 128, - exact: false, - }, - with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], - }, - }) - }) - - it("should use custom maxResults when provided", async () => { - const queryVector = [0.1, 0.2, 0.3] - const customMaxResults = 100 - const mockQdrantResults = { points: [] } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - await vectorStore.search(queryVector, undefined, undefined, customMaxResults) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, { - query: queryVector, - filter: undefined, - score_threshold: DEFAULT_SEARCH_MIN_SCORE, - limit: customMaxResults, - params: { - hnsw_ef: 128, - exact: false, - }, - with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], - }, - }) - }) - - it("should filter out results with invalid payloads", async () => { - const queryVector = [0.1, 0.2, 0.3] - const mockQdrantResults = { - points: [ - { - id: "valid-result", - score: 0.85, - payload: { - filePath: "src/test.ts", - codeChunk: "test code", - startLine: 1, - endLine: 5, - }, - }, - { - id: "invalid-result-1", - score: 0.75, - payload: { - // Missing required fields - filePath: "src/invalid.ts", - }, - }, - { - id: "valid-result-2", - score: 0.55, - payload: { - filePath: "src/test2.ts", - codeChunk: "test code 2", - startLine: 10, - endLine: 15, - }, - }, - ], - } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - const results = await vectorStore.search(queryVector) - - // Should only return results with valid payloads - expect(results).toHaveLength(2) - expect(results[0].id).toBe("valid-result") - expect(results[1].id).toBe("valid-result-2") - }) - - it("should filter out results with null or undefined payloads", async () => { - const queryVector = [0.1, 0.2, 0.3] - const mockQdrantResults = { - points: [ - { - id: "valid-result", - score: 0.85, - payload: { - filePath: "src/test.ts", - codeChunk: "test code", - startLine: 1, - endLine: 5, - }, - }, - { - id: "null-payload-result", - score: 0.75, - payload: null, - }, - { - id: "undefined-payload-result", - score: 0.65, - payload: undefined, - }, - { - id: "valid-result-2", - score: 0.55, - payload: { - filePath: "src/test2.ts", - codeChunk: "test code 2", - startLine: 10, - endLine: 15, - }, - }, - ], - } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - const results = await vectorStore.search(queryVector) - - // Should only return results with valid payloads, filtering out null and undefined - expect(results).toHaveLength(2) - expect(results[0].id).toBe("valid-result") - expect(results[1].id).toBe("valid-result-2") - }) - - it("should handle scenarios where no results are found", async () => { - const queryVector = [0.1, 0.2, 0.3] - const mockQdrantResults = { points: [] } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - const results = await vectorStore.search(queryVector) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledTimes(1) - expect(results).toEqual([]) - }) - - it("should handle complex directory prefix with multiple segments", async () => { - const queryVector = [0.1, 0.2, 0.3] - const directoryPrefix = "src/components/ui/forms" - const mockQdrantResults = { points: [] } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - await vectorStore.search(queryVector, directoryPrefix) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, { - query: queryVector, - filter: { - must: [ - { - key: "pathSegments.0", - match: { value: "src" }, - }, - { - key: "pathSegments.1", - match: { value: "components" }, - }, - { - key: "pathSegments.2", - match: { value: "ui" }, - }, - { - key: "pathSegments.3", - match: { value: "forms" }, - }, - ], - }, - score_threshold: DEFAULT_SEARCH_MIN_SCORE, - limit: DEFAULT_MAX_SEARCH_RESULTS, - params: { - hnsw_ef: 128, - exact: false, - }, - with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], - }, - }) - }) - - it("should handle error scenarios when qdrantClient.query fails", async () => { - const queryVector = [0.1, 0.2, 0.3] - const queryError = new Error("Query failed") - mockQdrantClientInstance.query.mockRejectedValue(queryError) - vitest.spyOn(console, "error").mockImplementation(() => {}) - - await expect(vectorStore.search(queryVector)).rejects.toThrow(queryError) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledTimes(1) - expect(console.error).toHaveBeenCalledWith("Failed to search points:", queryError) - ;(console.error as any).mockRestore() - }) - - it("should use constants DEFAULT_MAX_SEARCH_RESULTS and DEFAULT_SEARCH_MIN_SCORE correctly", async () => { - const queryVector = [0.1, 0.2, 0.3] - const mockQdrantResults = { points: [] } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - await vectorStore.search(queryVector) - - const callArgs = mockQdrantClientInstance.query.mock.calls[0][1] - expect(callArgs.limit).toBe(DEFAULT_MAX_SEARCH_RESULTS) - expect(callArgs.score_threshold).toBe(DEFAULT_SEARCH_MIN_SCORE) - }) - - describe("current directory path handling", () => { - it("should not apply filter when directoryPrefix is '.'", async () => { - const queryVector = [0.1, 0.2, 0.3] - const directoryPrefix = "." - const mockQdrantResults = { - points: [ - { - id: "test-id-1", - score: 0.85, - payload: { - filePath: "src/test.ts", - codeChunk: "test code", - startLine: 1, - endLine: 5, - pathSegments: { "0": "src", "1": "test.ts" }, - }, - }, - ], - } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - const results = await vectorStore.search(queryVector, directoryPrefix) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, { - query: queryVector, - filter: undefined, // Should be undefined for current directory - score_threshold: DEFAULT_SEARCH_MIN_SCORE, - limit: DEFAULT_MAX_SEARCH_RESULTS, - params: { - hnsw_ef: 128, - exact: false, - }, - with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], - }, - }) - - expect(results).toEqual(mockQdrantResults.points) - }) - - it("should not apply filter when directoryPrefix is './'", async () => { - const queryVector = [0.1, 0.2, 0.3] - const directoryPrefix = "./" - const mockQdrantResults = { points: [] } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - await vectorStore.search(queryVector, directoryPrefix) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, { - query: queryVector, - filter: undefined, // Should be undefined for current directory - score_threshold: DEFAULT_SEARCH_MIN_SCORE, - limit: DEFAULT_MAX_SEARCH_RESULTS, - params: { - hnsw_ef: 128, - exact: false, - }, - with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], - }, - }) - }) - - it("should not apply filter when directoryPrefix is empty string", async () => { - const queryVector = [0.1, 0.2, 0.3] - const directoryPrefix = "" - const mockQdrantResults = { points: [] } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - await vectorStore.search(queryVector, directoryPrefix) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, { - query: queryVector, - filter: undefined, // Should be undefined for empty string - score_threshold: DEFAULT_SEARCH_MIN_SCORE, - limit: DEFAULT_MAX_SEARCH_RESULTS, - params: { - hnsw_ef: 128, - exact: false, - }, - with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], - }, - }) - }) - - it("should not apply filter when directoryPrefix is '.\\' (Windows style)", async () => { - const queryVector = [0.1, 0.2, 0.3] - const directoryPrefix = ".\\" - const mockQdrantResults = { points: [] } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - await vectorStore.search(queryVector, directoryPrefix) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, { - query: queryVector, - filter: undefined, // Should be undefined for Windows current directory - score_threshold: DEFAULT_SEARCH_MIN_SCORE, - limit: DEFAULT_MAX_SEARCH_RESULTS, - params: { - hnsw_ef: 128, - exact: false, - }, - with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], - }, - }) - }) - - it("should not apply filter when directoryPrefix has trailing slashes", async () => { - const queryVector = [0.1, 0.2, 0.3] - const directoryPrefix = ".///" - const mockQdrantResults = { points: [] } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - await vectorStore.search(queryVector, directoryPrefix) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, { - query: queryVector, - filter: undefined, // Should be undefined after normalizing trailing slashes - score_threshold: DEFAULT_SEARCH_MIN_SCORE, - limit: DEFAULT_MAX_SEARCH_RESULTS, - params: { - hnsw_ef: 128, - exact: false, - }, - with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], - }, - }) - }) - - it("should still apply filter for relative paths like './src'", async () => { - const queryVector = [0.1, 0.2, 0.3] - const directoryPrefix = "./src" - const mockQdrantResults = { points: [] } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - await vectorStore.search(queryVector, directoryPrefix) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, { - query: queryVector, - filter: { - must: [ - { - key: "pathSegments.0", - match: { value: "src" }, - }, - ], - }, // Should normalize "./src" to "src" - score_threshold: DEFAULT_SEARCH_MIN_SCORE, - limit: DEFAULT_MAX_SEARCH_RESULTS, - params: { - hnsw_ef: 128, - exact: false, - }, - with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], - }, - }) - }) - - it("should still apply filter for regular directory paths", async () => { - const queryVector = [0.1, 0.2, 0.3] - const directoryPrefix = "src" - const mockQdrantResults = { points: [] } - - mockQdrantClientInstance.query.mockResolvedValue(mockQdrantResults) - - await vectorStore.search(queryVector, directoryPrefix) - - expect(mockQdrantClientInstance.query).toHaveBeenCalledWith(expectedCollectionName, { - query: queryVector, - filter: { - must: [ - { - key: "pathSegments.0", - match: { value: "src" }, - }, - ], - }, // Should still create filter for regular paths - score_threshold: DEFAULT_SEARCH_MIN_SCORE, - limit: DEFAULT_MAX_SEARCH_RESULTS, - params: { - hnsw_ef: 128, - exact: false, - }, - with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], - }, - }) - }) - }) - }) -}) diff --git a/src/services/code-index/vector-store/qdrant-client.ts b/src/services/code-index/vector-store/qdrant-client.ts deleted file mode 100644 index f18f3883237..00000000000 --- a/src/services/code-index/vector-store/qdrant-client.ts +++ /dev/null @@ -1,549 +0,0 @@ -import { QdrantClient, Schemas } from "@qdrant/js-client-rest" -import { createHash } from "crypto" -import * as path from "path" -import { getWorkspacePath } from "../../../utils/path" -import { IVectorStore } from "../interfaces/vector-store" -import { Payload, VectorStoreSearchResult } from "../interfaces" -import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE } from "../constants" -import { t } from "../../../i18n" - -/** - * Qdrant implementation of the vector store interface - */ -export class QdrantVectorStore implements IVectorStore { - private readonly vectorSize!: number - private readonly DISTANCE_METRIC = "Cosine" - - private client: QdrantClient - private readonly collectionName: string - private readonly qdrantUrl: string = "http://localhost:6333" - - /** - * Creates a new Qdrant vector store - * @param workspacePath Path to the workspace - * @param url Optional URL to the Qdrant server - */ - constructor(workspacePath: string, url: string, vectorSize: number, apiKey?: string) { - // Parse the URL to determine the appropriate QdrantClient configuration - const parsedUrl = this.parseQdrantUrl(url) - - // Store the resolved URL for our property - this.qdrantUrl = parsedUrl - - try { - const urlObj = new URL(parsedUrl) - - // Always use host-based configuration with explicit ports to avoid QdrantClient defaults - let port: number - let useHttps: boolean - - if (urlObj.port) { - // Explicit port specified - use it and determine protocol - port = Number(urlObj.port) - useHttps = urlObj.protocol === "https:" - } else { - // No explicit port - use protocol defaults - if (urlObj.protocol === "https:") { - port = 443 - useHttps = true - } else { - // http: or other protocols default to port 80 - port = 80 - useHttps = false - } - } - - this.client = new QdrantClient({ - host: urlObj.hostname, - https: useHttps, - port: port, - prefix: urlObj.pathname === "/" ? undefined : urlObj.pathname.replace(/\/+$/, ""), - apiKey, - headers: { - "User-Agent": "Roo-Code", - }, - }) - } catch (urlError) { - // If URL parsing fails, fall back to URL-based config - // Note: This fallback won't correctly handle prefixes, but it's a last resort for malformed URLs. - this.client = new QdrantClient({ - url: parsedUrl, - apiKey, - headers: { - "User-Agent": "Roo-Code", - }, - }) - } - - // Generate collection name from workspace path - const hash = createHash("sha256").update(workspacePath).digest("hex") - this.vectorSize = vectorSize - this.collectionName = `ws-${hash.substring(0, 16)}` - } - - /** - * Parses and normalizes Qdrant server URLs to handle various input formats - * @param url Raw URL input from user - * @returns Properly formatted URL for QdrantClient - */ - private parseQdrantUrl(url: string | undefined): string { - // Handle undefined/null/empty cases - if (!url || url.trim() === "") { - return "http://localhost:6333" - } - - const trimmedUrl = url.trim() - - // Check if it starts with a protocol - if (!trimmedUrl.startsWith("http://") && !trimmedUrl.startsWith("https://") && !trimmedUrl.includes("://")) { - // No protocol - treat as hostname - return this.parseHostname(trimmedUrl) - } - - try { - // Attempt to parse as complete URL - return as-is, let constructor handle ports - const parsedUrl = new URL(trimmedUrl) - return trimmedUrl - } catch { - // Failed to parse as URL - treat as hostname - return this.parseHostname(trimmedUrl) - } - } - - /** - * Handles hostname-only inputs - * @param hostname Raw hostname input - * @returns Properly formatted URL with http:// prefix - */ - private parseHostname(hostname: string): string { - if (hostname.includes(":")) { - // Has port - add http:// prefix if missing - return hostname.startsWith("http") ? hostname : `http://${hostname}` - } else { - // No port - add http:// prefix without port (let constructor handle port assignment) - return `http://${hostname}` - } - } - - private async getCollectionInfo(): Promise { - try { - const collectionInfo = await this.client.getCollection(this.collectionName) - return collectionInfo - } catch (error: unknown) { - if (error instanceof Error) { - console.warn( - `[QdrantVectorStore] Warning during getCollectionInfo for "${this.collectionName}". Collection may not exist or another error occurred:`, - error.message, - ) - } - return null - } - } - - /** - * Initializes the vector store - * @returns Promise resolving to boolean indicating if a new collection was created - */ - async initialize(): Promise { - let created = false - try { - const collectionInfo = await this.getCollectionInfo() - - if (collectionInfo === null) { - // Collection info not retrieved (assume not found or inaccessible), create it - await this.client.createCollection(this.collectionName, { - vectors: { - size: this.vectorSize, - distance: this.DISTANCE_METRIC, - on_disk: true, - }, - hnsw_config: { - m: 64, - ef_construct: 512, - on_disk: true, - }, - }) - created = true - } else { - // Collection exists, check vector size - const vectorsConfig = collectionInfo.config?.params?.vectors - let existingVectorSize: number - - if (typeof vectorsConfig === "number") { - existingVectorSize = vectorsConfig - } else if ( - vectorsConfig && - typeof vectorsConfig === "object" && - "size" in vectorsConfig && - typeof vectorsConfig.size === "number" - ) { - existingVectorSize = vectorsConfig.size - } else { - existingVectorSize = 0 // Fallback for unknown configuration - } - - if (existingVectorSize === this.vectorSize) { - created = false // Exists and correct - } else { - // Exists but wrong vector size, recreate with enhanced error handling - created = await this._recreateCollectionWithNewDimension(existingVectorSize) - } - } - - // Create payload indexes - await this._createPayloadIndexes() - return created - } catch (error: any) { - const errorMessage = error?.message || error - console.error( - `[QdrantVectorStore] Failed to initialize Qdrant collection "${this.collectionName}":`, - errorMessage, - ) - - // If this is already a vector dimension mismatch error (identified by cause), re-throw it as-is - if (error instanceof Error && error.cause !== undefined) { - throw error - } - - // Otherwise, provide a more user-friendly error message that includes the original error - throw new Error( - t("embeddings:vectorStore.qdrantConnectionFailed", { qdrantUrl: this.qdrantUrl, errorMessage }), - ) - } - } - - /** - * Recreates the collection with a new vector dimension, handling failures gracefully. - * @param existingVectorSize The current vector size of the existing collection - * @returns Promise resolving to boolean indicating if a new collection was created - */ - private async _recreateCollectionWithNewDimension(existingVectorSize: number): Promise { - console.warn( - `[QdrantVectorStore] Collection ${this.collectionName} exists with vector size ${existingVectorSize}, but expected ${this.vectorSize}. Recreating collection.`, - ) - - let deletionSucceeded = false - let recreationAttempted = false - - try { - // Step 1: Attempt to delete the existing collection - console.log(`[QdrantVectorStore] Deleting existing collection ${this.collectionName}...`) - await this.client.deleteCollection(this.collectionName) - deletionSucceeded = true - console.log(`[QdrantVectorStore] Successfully deleted collection ${this.collectionName}`) - - // Step 2: Wait a brief moment to ensure deletion is processed - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Step 3: Verify the collection is actually deleted - const verificationInfo = await this.getCollectionInfo() - if (verificationInfo !== null) { - throw new Error("Collection still exists after deletion attempt") - } - - // Step 4: Create the new collection with correct dimensions - console.log( - `[QdrantVectorStore] Creating new collection ${this.collectionName} with vector size ${this.vectorSize}...`, - ) - recreationAttempted = true - await this.client.createCollection(this.collectionName, { - vectors: { - size: this.vectorSize, - distance: this.DISTANCE_METRIC, - on_disk: true, - }, - hnsw_config: { - m: 64, - ef_construct: 512, - on_disk: true, - }, - }) - console.log(`[QdrantVectorStore] Successfully created new collection ${this.collectionName}`) - return true - } catch (recreationError) { - const errorMessage = recreationError instanceof Error ? recreationError.message : String(recreationError) - - // Provide detailed error context based on what stage failed - let contextualErrorMessage: string - if (!deletionSucceeded) { - contextualErrorMessage = `Failed to delete existing collection with vector size ${existingVectorSize}. ${errorMessage}` - } else if (!recreationAttempted) { - contextualErrorMessage = `Deleted existing collection but failed verification step. ${errorMessage}` - } else { - contextualErrorMessage = `Deleted existing collection but failed to create new collection with vector size ${this.vectorSize}. ${errorMessage}` - } - - console.error( - `[QdrantVectorStore] CRITICAL: Failed to recreate collection ${this.collectionName} for dimension change (${existingVectorSize} -> ${this.vectorSize}). ${contextualErrorMessage}`, - ) - - // Create a comprehensive error message for the user - const dimensionMismatchError = new Error( - t("embeddings:vectorStore.vectorDimensionMismatch", { - errorMessage: contextualErrorMessage, - }), - ) - - // Preserve the original error context - dimensionMismatchError.cause = recreationError - throw dimensionMismatchError - } - } - - /** - * Creates payload indexes for the collection, handling errors gracefully. - */ - private async _createPayloadIndexes(): Promise { - for (let i = 0; i <= 4; i++) { - try { - await this.client.createPayloadIndex(this.collectionName, { - field_name: `pathSegments.${i}`, - field_schema: "keyword", - }) - } catch (indexError: any) { - const errorMessage = (indexError?.message || "").toLowerCase() - if (!errorMessage.includes("already exists")) { - console.warn( - `[QdrantVectorStore] Could not create payload index for pathSegments.${i} on ${this.collectionName}. Details:`, - indexError?.message || indexError, - ) - } - } - } - } - - /** - * Upserts points into the vector store - * @param points Array of points to upsert - */ - async upsertPoints( - points: Array<{ - id: string - vector: number[] - payload: Record - }>, - ): Promise { - try { - const processedPoints = points.map((point) => { - if (point.payload?.filePath) { - const segments = point.payload.filePath.split(path.sep).filter(Boolean) - const pathSegments = segments.reduce( - (acc: Record, segment: string, index: number) => { - acc[index.toString()] = segment - return acc - }, - {}, - ) - return { - ...point, - payload: { - ...point.payload, - pathSegments, - }, - } - } - return point - }) - - await this.client.upsert(this.collectionName, { - points: processedPoints, - wait: true, - }) - } catch (error) { - console.error("Failed to upsert points:", error) - throw error - } - } - - /** - * Checks if a payload is valid - * @param payload Payload to check - * @returns Boolean indicating if the payload is valid - */ - private isPayloadValid(payload: Record | null | undefined): payload is Payload { - if (!payload) { - return false - } - const validKeys = ["filePath", "codeChunk", "startLine", "endLine"] - const hasValidKeys = validKeys.every((key) => key in payload) - return hasValidKeys - } - - /** - * Searches for similar vectors - * @param queryVector Vector to search for - * @param directoryPrefix Optional directory prefix to filter results - * @param minScore Optional minimum score threshold - * @param maxResults Optional maximum number of results to return - * @returns Promise resolving to search results - */ - async search( - queryVector: number[], - directoryPrefix?: string, - minScore?: number, - maxResults?: number, - ): Promise { - try { - let filter = undefined - - if (directoryPrefix) { - // Check if the path represents current directory - const normalizedPrefix = path.posix.normalize(directoryPrefix.replace(/\\/g, "/")) - // Note: path.posix.normalize("") returns ".", and normalize("./") returns "./" - if (normalizedPrefix === "." || normalizedPrefix === "./") { - // Don't create a filter - search entire workspace - filter = undefined - } else { - // Remove leading "./" from paths like "./src" to normalize them - const cleanedPrefix = path.posix.normalize( - normalizedPrefix.startsWith("./") ? normalizedPrefix.slice(2) : normalizedPrefix, - ) - const segments = cleanedPrefix.split("/").filter(Boolean) - if (segments.length > 0) { - filter = { - must: segments.map((segment, index) => ({ - key: `pathSegments.${index}`, - match: { value: segment }, - })), - } - } - } - } - - const searchRequest = { - query: queryVector, - filter, - score_threshold: minScore ?? DEFAULT_SEARCH_MIN_SCORE, - limit: maxResults ?? DEFAULT_MAX_SEARCH_RESULTS, - params: { - hnsw_ef: 128, - exact: false, - }, - with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], - }, - } - - const operationResult = await this.client.query(this.collectionName, searchRequest) - const filteredPoints = operationResult.points.filter((p) => this.isPayloadValid(p.payload)) - - return filteredPoints as VectorStoreSearchResult[] - } catch (error) { - console.error("Failed to search points:", error) - throw error - } - } - - /** - * Deletes points by file path - * @param filePath Path of the file to delete points for - */ - async deletePointsByFilePath(filePath: string): Promise { - return this.deletePointsByMultipleFilePaths([filePath]) - } - - async deletePointsByMultipleFilePaths(filePaths: string[]): Promise { - if (filePaths.length === 0) { - return - } - - try { - // First check if the collection exists - const collectionExists = await this.collectionExists() - if (!collectionExists) { - console.warn( - `[QdrantVectorStore] Skipping deletion - collection "${this.collectionName}" does not exist`, - ) - return - } - - const workspaceRoot = getWorkspacePath() - - // Build filters using pathSegments to match the indexed fields - const filters = filePaths.map((filePath) => { - // IMPORTANT: Use the relative path to match what's stored in upsertPoints - // upsertPoints stores the relative filePath, not the absolute path - const relativePath = path.isAbsolute(filePath) ? path.relative(workspaceRoot, filePath) : filePath - - // Normalize the relative path - const normalizedRelativePath = path.normalize(relativePath) - - // Split the path into segments like we do in upsertPoints - const segments = normalizedRelativePath.split(path.sep).filter(Boolean) - - // Create a filter that matches all segments of the path - // This ensures we only delete points that match the exact file path - const mustConditions = segments.map((segment, index) => ({ - key: `pathSegments.${index}`, - match: { value: segment }, - })) - - return { must: mustConditions } - }) - - // Use 'should' to match any of the file paths (OR condition) - const filter = filters.length === 1 ? filters[0] : { should: filters } - - await this.client.delete(this.collectionName, { - filter, - wait: true, - }) - } catch (error: any) { - // Extract more detailed error information - const errorMessage = error?.message || String(error) - const errorStatus = error?.status || error?.response?.status || error?.statusCode - const errorDetails = error?.response?.data || error?.data || "" - - console.error(`[QdrantVectorStore] Failed to delete points by file paths:`, { - error: errorMessage, - status: errorStatus, - details: errorDetails, - collection: this.collectionName, - fileCount: filePaths.length, - // Include first few file paths for debugging (avoid logging too many) - samplePaths: filePaths.slice(0, 3), - }) - } - } - - /** - * Deletes the entire collection. - */ - async deleteCollection(): Promise { - try { - // Check if collection exists before attempting deletion to avoid errors - if (await this.collectionExists()) { - await this.client.deleteCollection(this.collectionName) - } - } catch (error) { - console.error(`[QdrantVectorStore] Failed to delete collection ${this.collectionName}:`, error) - throw error // Re-throw to allow calling code to handle it - } - } - - /** - * Clears all points from the collection - */ - async clearCollection(): Promise { - try { - await this.client.delete(this.collectionName, { - filter: { - must: [], - }, - wait: true, - }) - } catch (error) { - console.error("Failed to clear collection:", error) - throw error - } - } - - /** - * Checks if the collection exists - * @returns Promise resolving to boolean indicating if the collection exists - */ - async collectionExists(): Promise { - const collectionInfo = await this.getCollectionInfo() - return collectionInfo !== null - } -} diff --git a/src/services/vector-store/__tests__/adapter-contract.spec.ts b/src/services/vector-store/__tests__/adapter-contract.spec.ts new file mode 100644 index 00000000000..cc1a792c9cb --- /dev/null +++ b/src/services/vector-store/__tests__/adapter-contract.spec.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import { QdrantAdapter } from "../adapters/qdrant" +import { QdrantClient } from "@qdrant/js-client-rest" +import { createHash } from "crypto" + +// Mocks +vi.mock("@qdrant/js-client-rest") +vi.mock("crypto") +vi.mock("../../../i18n", () => ({ + t: (key: string, params?: any) => { + if (key === "embeddings:vectorStore.vectorDimensionMismatch" && params?.errorMessage) { + return `Failed to update vector index for new model. Please try clearing the index and starting again. Details: ${params.errorMessage}` + } + if (key === "embeddings:vectorStore.qdrantConnectionFailed" && params?.qdrantUrl && params?.errorMessage) { + return `Failed to connect to Qdrant vector database. Please ensure Qdrant is running and accessible at ${params.qdrantUrl}. Error: ${params.errorMessage}` + } + return key + }, +})) +vi.mock("path", async () => { + const actual = await vi.importActual("path") + return { ...actual, sep: "/", posix: actual.posix } +}) + +const mockQdrantClientInstance = { + getCollection: vi.fn(), + createCollection: vi.fn(), + deleteCollection: vi.fn(), + createPayloadIndex: vi.fn(), + upsert: vi.fn(), + query: vi.fn(), + delete: vi.fn(), +} + +const mockCreateHashInstance = { + update: vi.fn().mockReturnThis(), + digest: vi.fn(), +} + +describe("Adapter Contract – QdrantAdapter", () => { + let adapter: QdrantAdapter + const mockWorkspacePath = "/test/workspace" + const mockQdrantUrl = "http://mock-qdrant:6333" + const mockApiKey = "test-api-key" + const mockVectorSize = 1536 + const mockHashedPath = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" + + beforeEach(() => { + vi.clearAllMocks() + ;(QdrantClient as any).mockImplementation(() => mockQdrantClientInstance) + ;(createHash as any).mockReturnValue(mockCreateHashInstance) + mockCreateHashInstance.update.mockReturnValue(mockCreateHashInstance) + mockCreateHashInstance.digest.mockReturnValue(mockHashedPath) + + adapter = new QdrantAdapter(mockWorkspacePath, mockQdrantUrl, mockApiKey, mockVectorSize) + }) + + it("ensureCollection: creates new collection and indexes when not found", async () => { + mockQdrantClientInstance.getCollection.mockRejectedValueOnce(new Error("Not Found")) + mockQdrantClientInstance.createCollection.mockResolvedValueOnce({}) + + const created = await adapter.ensureCollection(adapter.collectionName(), mockVectorSize) + + expect(created).toBe(true) + expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1) + // pathSegments indexes + expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalled() + }) + + it("ensureCollection: returns false when collection exists with correct dimension", async () => { + mockQdrantClientInstance.getCollection.mockResolvedValueOnce({ + config: { params: { vectors: { size: mockVectorSize } } }, + }) + + const created = await adapter.ensureCollection(adapter.collectionName(), mockVectorSize) + expect(created).toBe(false) + expect(mockQdrantClientInstance.createCollection).not.toHaveBeenCalled() + }) + + it("ensureCollection: recreates when dimension mismatch", async () => { + mockQdrantClientInstance.getCollection.mockResolvedValueOnce({ + config: { params: { vectors: { size: 768 } } }, + }) + mockQdrantClientInstance.deleteCollection.mockResolvedValueOnce({}) + mockQdrantClientInstance.getCollection.mockResolvedValueOnce(null) + mockQdrantClientInstance.createCollection.mockResolvedValueOnce({}) + + const created = await adapter.ensureCollection(adapter.collectionName(), mockVectorSize) + expect(created).toBe(true) + expect(mockQdrantClientInstance.deleteCollection).toHaveBeenCalledTimes(1) + expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1) + }) + + it("upsert: injects pathSegments from payload.filePath", async () => { + mockQdrantClientInstance.upsert.mockResolvedValueOnce({}) + await adapter.upsert([ + { + id: "1", + vector: [0.1, 0.2], + payload: { filePath: "src/a/b.ts", codeChunk: "x", startLine: 1, endLine: 2 }, + }, + ]) + const args = mockQdrantClientInstance.upsert.mock.calls[0][1] + expect(args.points[0].payload.pathSegments).toEqual({ "0": "src", "1": "a", "2": "b.ts" }) + }) + + it("search: returns only points with valid payload shape", async () => { + mockQdrantClientInstance.query.mockResolvedValueOnce({ + points: [ + { + id: "1", + score: 0.9, + payload: { filePath: "f", codeChunk: "c", startLine: 1, endLine: 2 }, + }, + { id: "2", score: 0.8, payload: { filePath: "f" } }, // invalid, missing keys + ], + }) + const results = await adapter.search([0.1, 0.2], 10) + expect(results.length).toBe(1) + expect(results[0].id).toBe("1") + }) + + it("deleteByFilter and clearAll delegate to Qdrant", async () => { + mockQdrantClientInstance.delete.mockResolvedValue({}) + await adapter.deleteByFilter?.({ must: [{ key: "pathSegments.0", match: { value: "src" } }] }) + await adapter.clearAll() + expect(mockQdrantClientInstance.delete).toHaveBeenCalledTimes(2) + const clearArgs = mockQdrantClientInstance.delete.mock.calls[1][1] + expect(clearArgs.filter).toEqual({ must: [] }) + }) + + it("collectionExists returns boolean", async () => { + mockQdrantClientInstance.getCollection.mockResolvedValueOnce({ config: {} }) + expect(await adapter.collectionExists()).toBe(true) + mockQdrantClientInstance.getCollection.mockRejectedValueOnce(new Error("not found")) + expect(await adapter.collectionExists()).toBe(false) + }) +}) diff --git a/src/services/vector-store/__tests__/collection-manager.spec.ts b/src/services/vector-store/__tests__/collection-manager.spec.ts new file mode 100644 index 00000000000..90b6110e83d --- /dev/null +++ b/src/services/vector-store/__tests__/collection-manager.spec.ts @@ -0,0 +1,336 @@ +import { CollectionManager } from "../collection-manager" + +describe("CollectionManager", () => { + /** + * Since the readyCollections and inFlightEnsures maps are module-scoped, + * we can't easily reset them between tests. Instead, we'll use unique + * collection names for each test to avoid interference. + */ + let testCounter = 0 + + beforeEach(() => { + testCounter++ + }) + + const getTestCollectionName = (suffix = "") => `test-collection-${testCounter}${suffix ? `-${suffix}` : ""}` + + describe("key generation", () => { + it("should generate consistent keys for same name and dimension", () => { + const collectionName = getTestCollectionName() + const key1 = CollectionManager.key(collectionName, 1536) + const key2 = CollectionManager.key(collectionName, 1536) + + expect(key1).toBe(key2) + expect(key1).toBe(`${collectionName}:1536`) + }) + + it("should generate different keys for different names", () => { + const collectionA = getTestCollectionName("a") + const collectionB = getTestCollectionName("b") + const key1 = CollectionManager.key(collectionA, 1536) + const key2 = CollectionManager.key(collectionB, 1536) + + expect(key1).not.toBe(key2) + expect(key1).toBe(`${collectionA}:1536`) + expect(key2).toBe(`${collectionB}:1536`) + }) + + it("should generate different keys for different dimensions", () => { + const collectionName = getTestCollectionName() + const key1 = CollectionManager.key(collectionName, 1536) + const key2 = CollectionManager.key(collectionName, 768) + + expect(key1).not.toBe(key2) + expect(key1).toBe(`${collectionName}:1536`) + expect(key2).toBe(`${collectionName}:768`) + }) + }) + + describe("readiness tracking", () => { + it("should return false for unknown collections", () => { + const unknownCollection = getTestCollectionName("unknown") + expect(CollectionManager.isReady(unknownCollection, 1536)).toBe(false) + }) + + it("should return true after marking as ready", () => { + const collectionName = getTestCollectionName() + CollectionManager.markReady(collectionName, 1536) + expect(CollectionManager.isReady(collectionName, 1536)).toBe(true) + }) + + it("should persist ready state across multiple checks", () => { + const collectionName = getTestCollectionName() + CollectionManager.markReady(collectionName, 1536) + + expect(CollectionManager.isReady(collectionName, 1536)).toBe(true) + expect(CollectionManager.isReady(collectionName, 1536)).toBe(true) + expect(CollectionManager.isReady(collectionName, 1536)).toBe(true) + }) + + it("should track readiness per unique key", () => { + const collectionA = getTestCollectionName("a") + const collectionB = getTestCollectionName("b") + CollectionManager.markReady(collectionA, 1536) + CollectionManager.markReady(collectionB, 768) + + expect(CollectionManager.isReady(collectionA, 1536)).toBe(true) + expect(CollectionManager.isReady(collectionB, 768)).toBe(true) + expect(CollectionManager.isReady(collectionA, 768)).toBe(false) + expect(CollectionManager.isReady(collectionB, 1536)).toBe(false) + }) + }) + + describe("ensureOnce basic behavior", () => { + it("should call ensure function for new collection", async () => { + const ensureFn = vi.fn().mockResolvedValue(true) + const collectionName = getTestCollectionName() + + const result = await CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + + expect(ensureFn).toHaveBeenCalledWith(collectionName, 1536) + expect(ensureFn).toHaveBeenCalledTimes(1) + expect(result).toBe(true) + }) + + it("should mark collection as ready after successful ensure", async () => { + const ensureFn = vi.fn().mockResolvedValue(true) + const collectionName = getTestCollectionName() + + expect(CollectionManager.isReady(collectionName, 1536)).toBe(false) + + await CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + + expect(CollectionManager.isReady(collectionName, 1536)).toBe(true) + }) + + it("should return false for already ready collections without calling ensure function", async () => { + const ensureFn = vi.fn().mockResolvedValue(true) + const collectionName = getTestCollectionName() + + // Mark as ready first + CollectionManager.markReady(collectionName, 1536) + + const result = await CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + + expect(ensureFn).not.toHaveBeenCalled() + expect(result).toBe(false) + }) + }) + + describe("ensure-once behavior", () => { + it("should call ensure function only once for multiple sequential calls", async () => { + const ensureFn = vi.fn().mockResolvedValue(true) + const collectionName = getTestCollectionName() + + const result1 = await CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + const result2 = await CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + const result3 = await CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + + expect(ensureFn).toHaveBeenCalledTimes(1) + expect(result1).toBe(true) // First call returns ensure function result + expect(result2).toBe(false) // Subsequent calls return false + expect(result3).toBe(false) + }) + + it("should handle different collections independently", async () => { + const ensureFn = vi.fn().mockResolvedValue(true) + const collectionA = getTestCollectionName("a") + const collectionB = getTestCollectionName("b") + + const result1 = await CollectionManager.ensureOnce(ensureFn, collectionA, 1536) + const result2 = await CollectionManager.ensureOnce(ensureFn, collectionB, 1536) + const result3 = await CollectionManager.ensureOnce(ensureFn, collectionA, 768) + + expect(ensureFn).toHaveBeenCalledTimes(3) + expect(ensureFn).toHaveBeenNthCalledWith(1, collectionA, 1536) + expect(ensureFn).toHaveBeenNthCalledWith(2, collectionB, 1536) + expect(ensureFn).toHaveBeenNthCalledWith(3, collectionA, 768) + + expect(result1).toBe(true) + expect(result2).toBe(true) + expect(result3).toBe(true) + }) + }) + + describe("concurrent access deduplication", () => { + /** + * Tests that multiple concurrent calls to ensureOnce for the same key + * are properly deduplicated and only result in a single ensure function call. + */ + it("should deduplicate concurrent ensure calls for same key", async () => { + let resolveEnsure: (value: boolean) => void + const ensurePromise = new Promise((resolve) => { + resolveEnsure = resolve + }) + + const ensureFn = vi.fn().mockReturnValue(ensurePromise) + const collectionName = getTestCollectionName() + + // Start multiple concurrent calls + const promise1 = CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + const promise2 = CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + const promise3 = CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + + // Ensure function should only be called once + expect(ensureFn).toHaveBeenCalledTimes(1) + + // Resolve the ensure function + resolveEnsure!(true) + + // All promises should resolve to the same value + const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]) + + expect(result1).toBe(true) + expect(result2).toBe(true) + expect(result3).toBe(true) + + // Collection should be marked as ready + expect(CollectionManager.isReady(collectionName, 1536)).toBe(true) + }) + + it("should handle concurrent calls for different keys independently", async () => { + let resolveEnsure1: (value: boolean) => void + let resolveEnsure2: (value: boolean) => void + + const ensurePromise1 = new Promise((resolve) => { + resolveEnsure1 = resolve + }) + const ensurePromise2 = new Promise((resolve) => { + resolveEnsure2 = resolve + }) + + const ensureFn = vi.fn().mockReturnValueOnce(ensurePromise1).mockReturnValueOnce(ensurePromise2) + + const collectionA = getTestCollectionName("a") + const collectionB = getTestCollectionName("b") + + // Start concurrent calls for different keys + const promise1 = CollectionManager.ensureOnce(ensureFn, collectionA, 1536) + const promise2 = CollectionManager.ensureOnce(ensureFn, collectionB, 1536) + + expect(ensureFn).toHaveBeenCalledTimes(2) + expect(ensureFn).toHaveBeenNthCalledWith(1, collectionA, 1536) + expect(ensureFn).toHaveBeenNthCalledWith(2, collectionB, 1536) + + // Resolve both promises + resolveEnsure1!(true) + resolveEnsure2!(false) + + const [result1, result2] = await Promise.all([promise1, promise2]) + + expect(result1).toBe(true) + expect(result2).toBe(false) + }) + }) + + describe("error handling", () => { + it("should not mark collection as ready if ensure function throws", async () => { + const error = new Error("Ensure failed") + const ensureFn = vi.fn().mockRejectedValue(error) + const collectionName = getTestCollectionName() + + await expect(CollectionManager.ensureOnce(ensureFn, collectionName, 1536)).rejects.toThrow("Ensure failed") + + expect(CollectionManager.isReady(collectionName, 1536)).toBe(false) + }) + + it("should allow retry after failed ensure", async () => { + const error = new Error("Ensure failed") + const ensureFn = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(true) + + const collectionName = getTestCollectionName() + + // First call should fail + await expect(CollectionManager.ensureOnce(ensureFn, collectionName, 1536)).rejects.toThrow("Ensure failed") + + expect(CollectionManager.isReady(collectionName, 1536)).toBe(false) + + // Second call should succeed + const result = await CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + + expect(ensureFn).toHaveBeenCalledTimes(2) + expect(result).toBe(true) + expect(CollectionManager.isReady(collectionName, 1536)).toBe(true) + }) + + /** + * Tests that when concurrent calls are in-flight and the ensure function fails, + * all concurrent calls receive the same error and cleanup happens properly. + */ + it("should handle errors in concurrent calls properly", async () => { + const error = new Error("Concurrent ensure failed") + let rejectEnsure: (error: Error) => void + + const ensurePromise = new Promise((_, reject) => { + rejectEnsure = reject + }) + + const ensureFn = vi.fn().mockReturnValue(ensurePromise) + const collectionName = getTestCollectionName() + + // Start multiple concurrent calls + const promise1 = CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + const promise2 = CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + const promise3 = CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + + expect(ensureFn).toHaveBeenCalledTimes(1) + + // Reject the ensure function + rejectEnsure!(error) + + // All promises should reject with the same error + await expect(promise1).rejects.toThrow("Concurrent ensure failed") + await expect(promise2).rejects.toThrow("Concurrent ensure failed") + await expect(promise3).rejects.toThrow("Concurrent ensure failed") + + // Collection should not be marked as ready + expect(CollectionManager.isReady(collectionName, 1536)).toBe(false) + + // Should be able to retry after cleanup + const retryEnsureFn = vi.fn().mockResolvedValue(true) + const retryResult = await CollectionManager.ensureOnce(retryEnsureFn, collectionName, 1536) + + expect(retryEnsureFn).toHaveBeenCalledTimes(1) + expect(retryResult).toBe(true) + expect(CollectionManager.isReady(collectionName, 1536)).toBe(true) + }) + }) + + describe("cleanup verification", () => { + /** + * Tests that the in-flight tracking map is properly cleaned up after + * ensure operations complete, preventing memory leaks. + */ + it("should clean up in-flight tracking after successful completion", async () => { + const ensureFn = vi.fn().mockResolvedValue(true) + const collectionName = getTestCollectionName() + + await CollectionManager.ensureOnce(ensureFn, collectionName, 1536) + + // After completion, subsequent calls should go through normal ready check + // rather than finding an in-flight operation + const ensureFn2 = vi.fn().mockResolvedValue(false) + const result = await CollectionManager.ensureOnce(ensureFn2, collectionName, 1536) + + expect(ensureFn2).not.toHaveBeenCalled() + expect(result).toBe(false) // Should return false because already ready + }) + + it("should clean up in-flight tracking after error", async () => { + const error = new Error("Cleanup test error") + const ensureFn = vi.fn().mockRejectedValue(error) + const collectionName = getTestCollectionName() + + await expect(CollectionManager.ensureOnce(ensureFn, collectionName, 1536)).rejects.toThrow( + "Cleanup test error", + ) + + // After error, should be able to retry (not find stale in-flight operation) + const retryEnsureFn = vi.fn().mockResolvedValue(true) + const result = await CollectionManager.ensureOnce(retryEnsureFn, collectionName, 1536) + + expect(retryEnsureFn).toHaveBeenCalledTimes(1) + expect(result).toBe(true) + }) + }) +}) diff --git a/src/services/vector-store/__tests__/factory.spec.ts b/src/services/vector-store/__tests__/factory.spec.ts new file mode 100644 index 00000000000..2f39d0f301b --- /dev/null +++ b/src/services/vector-store/__tests__/factory.spec.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { VectorStoreFactory } from "../factory" +import { UnsupportedProviderError } from "../errors" +import { createHash } from "crypto" + +vi.mock("@qdrant/js-client-rest", () => ({ QdrantClient: vi.fn(() => ({})) })) +vi.mock("crypto") + +describe("VectorStoreFactory", () => { + const mockCreateHashInstance = { + update: vi.fn().mockReturnThis(), + digest: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + ;(createHash as any).mockReturnValue(mockCreateHashInstance) + mockCreateHashInstance.update.mockReturnValue(mockCreateHashInstance) + mockCreateHashInstance.digest.mockReturnValue("0123456789abcdef0123456789abcdef") + }) + + it("creates Qdrant adapter with provider metadata", () => { + const adapter = VectorStoreFactory.create({ + provider: "qdrant", + workspacePath: "/test/workspace", + dimension: 1536, + qdrant: { url: "http://localhost:6333" }, + }) + expect(adapter.provider()).toBe("qdrant") + expect(adapter.collectionName()).toBe("ws-0123456789abcdef") + }) + + it("supports collection suffix to isolate feature data", () => { + const adapter = VectorStoreFactory.create({ + provider: "qdrant", + workspacePath: "/test/workspace", + dimension: 1536, + collectionSuffix: "chatmemory", + qdrant: { url: "http://localhost:6333" }, + }) + expect(adapter.collectionName()).toBe("ws-0123456789abcdef-chatmemory") + }) + + it("throws error with dynamic supported providers list for unsupported provider", () => { + expect(() => { + VectorStoreFactory.create({ + provider: "unsupported-provider", + workspacePath: "/test/workspace", + dimension: 1536, + }) + }).toThrow(UnsupportedProviderError) + + try { + VectorStoreFactory.create({ + provider: "unsupported-provider", + workspacePath: "/test/workspace", + dimension: 1536, + }) + } catch (error) { + expect(error).toBeInstanceOf(UnsupportedProviderError) + expect(error.message).toContain("Unsupported vector store provider: unsupported-provider") + expect(error.message).toContain("Currently supported: qdrant") + } + }) + + it("returns list of supported providers", () => { + const supportedProviders = VectorStoreFactory.getSupportedProviders() + expect(supportedProviders).toEqual(["qdrant"]) + }) + + it("allows registering new providers", () => { + // Save original state + const originalProviders = VectorStoreFactory.getSupportedProviders() + + // Register a new provider + VectorStoreFactory.registerProvider("pinecone") + + // Check that it's now in the supported list + const updatedProviders = VectorStoreFactory.getSupportedProviders() + expect(updatedProviders).toContain("pinecone") + expect(updatedProviders).toContain("qdrant") + expect(updatedProviders.length).toBe(originalProviders.length + 1) + + // Error message should now include the new provider + try { + VectorStoreFactory.create({ + provider: "unsupported-provider", + workspacePath: "/test/workspace", + dimension: 1536, + }) + } catch (error) { + expect(error.message).toContain("Currently supported: qdrant, pinecone") + } + + // Clean up - remove the test provider + // Note: In a real scenario, you'd want a proper cleanup mechanism + // For this test, we'll just accept that the provider registry is modified + }) + + it("prevents duplicate provider registration", () => { + const beforeCount = VectorStoreFactory.getSupportedProviders().length + + // Register the same provider twice + VectorStoreFactory.registerProvider("qdrant") + VectorStoreFactory.registerProvider("qdrant") + + const afterCount = VectorStoreFactory.getSupportedProviders().length + expect(afterCount).toBe(beforeCount) // Should not increase + }) +}) diff --git a/src/services/vector-store/__tests__/filters.spec.ts b/src/services/vector-store/__tests__/filters.spec.ts new file mode 100644 index 00000000000..d54484d7a11 --- /dev/null +++ b/src/services/vector-store/__tests__/filters.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest" +import { QdrantFilterTranslator } from "../filters" + +describe("QdrantFilterTranslator", () => { + const t = new QdrantFilterTranslator() + + it("directoryPrefixToFilter handles '.' and './' as no-op", () => { + expect(t.directoryPrefixToFilter(".")).toBeUndefined() + expect(t.directoryPrefixToFilter("./")).toBeUndefined() + }) + + it("directoryPrefixToFilter builds must clauses for segments", () => { + const filter = t.directoryPrefixToFilter("./src/utils") + expect(filter).toEqual({ + must: [ + { key: "pathSegments.0", match: { value: "src" } }, + { key: "pathSegments.1", match: { value: "utils" } }, + ], + }) + }) + + it("filePathsToDeleteFilter builds OR over exact path segments with workspace root", () => { + const filter = t.filePathsToDeleteFilter(["/repo/src/a.ts", "src/b.ts"], "/repo") + expect(filter.should.length).toBe(2) + expect(filter.should[0].must).toEqual([ + { key: "pathSegments.0", match: { value: "src" } }, + { key: "pathSegments.1", match: { value: "a.ts" } }, + ]) + }) +}) diff --git a/src/services/vector-store/adapters/__tests__/qdrant-adapter.spec.ts b/src/services/vector-store/adapters/__tests__/qdrant-adapter.spec.ts new file mode 100644 index 00000000000..6faf170d503 --- /dev/null +++ b/src/services/vector-store/adapters/__tests__/qdrant-adapter.spec.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import { QdrantAdapter } from "../qdrant" +import { QdrantClient } from "@qdrant/js-client-rest" +import { createHash } from "crypto" + +vi.mock("@qdrant/js-client-rest") +vi.mock("crypto") +vi.mock("../../../i18n", () => ({ + t: (key: string, params?: any) => key, +})) + +const mockQdrantClientInstance = { + getCollection: vi.fn(), + createCollection: vi.fn(), + deleteCollection: vi.fn(), + createPayloadIndex: vi.fn(), + upsert: vi.fn(), + query: vi.fn(), + delete: vi.fn(), +} + +const mockCreateHashInstance = { + update: vi.fn().mockReturnThis(), + digest: vi.fn(), +} + +describe("QdrantAdapter URL and init", () => { + const mockWorkspacePath = "/test/workspace" + const mockVectorSize = 1536 + const mockHashedPath = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" + + beforeEach(() => { + vi.clearAllMocks() + ;(QdrantClient as any).mockImplementation(() => mockQdrantClientInstance) + ;(createHash as any).mockReturnValue(mockCreateHashInstance) + mockCreateHashInstance.update.mockReturnValue(mockCreateHashInstance) + mockCreateHashInstance.digest.mockReturnValue(mockHashedPath) + }) + + it("https URL without port uses 443 and https", () => { + new QdrantAdapter(mockWorkspacePath, "https://q.example.com", undefined, mockVectorSize) + expect(QdrantClient).toHaveBeenLastCalledWith({ + host: "q.example.com", + https: true, + port: 443, + prefix: undefined, + apiKey: undefined, + headers: { "User-Agent": "Roo-Code" }, + }) + }) + + it("http URL without port uses 80", () => { + new QdrantAdapter(mockWorkspacePath, "http://example.com", undefined, mockVectorSize) + expect(QdrantClient).toHaveBeenLastCalledWith({ + host: "example.com", + https: false, + port: 80, + prefix: undefined, + apiKey: undefined, + headers: { "User-Agent": "Roo-Code" }, + }) + }) + + it("URL with path preserves prefix", () => { + new QdrantAdapter(mockWorkspacePath, "https://example.com/api/v1", undefined, mockVectorSize) + expect(QdrantClient).toHaveBeenLastCalledWith({ + host: "example.com", + https: true, + port: 443, + prefix: "/api/v1", + apiKey: undefined, + headers: { "User-Agent": "Roo-Code" }, + }) + }) + + it("hostname without scheme defaults to http and port 80", () => { + new QdrantAdapter(mockWorkspacePath, "qdrant.local", undefined, mockVectorSize) + expect(QdrantClient).toHaveBeenLastCalledWith({ + host: "qdrant.local", + https: false, + port: 80, + prefix: undefined, + apiKey: undefined, + headers: { "User-Agent": "Roo-Code" }, + }) + }) + + it("appends sanitized collection suffix when provided", () => { + const adapter = new QdrantAdapter( + mockWorkspacePath, + "http://localhost:6333", + undefined, + mockVectorSize, + "Chat Memory!!", + ) + // hash mocked to a1b2..., base name is ws-a1b2... + expect(adapter.collectionName()).toBe("ws-a1b2c3d4e5f6g7h8-chat-memory") + }) +}) + +describe("QdrantAdapter upsert recovery", () => { + const mockWorkspacePath = "/test/workspace" + const mockVectorSize = 1536 + + beforeEach(() => { + vi.clearAllMocks() + ;(QdrantClient as any).mockImplementation(() => mockQdrantClientInstance) + }) + + it("retries upsert after ensure on 404 Not Found", async () => { + // First upsert rejects with 404, second resolves + const notFound = { status: 404, message: "Not Found" } + mockQdrantClientInstance.upsert.mockRejectedValueOnce(notFound).mockResolvedValueOnce({}) + + const adapter = new QdrantAdapter(mockWorkspacePath, "http://localhost:6333", undefined, mockVectorSize) + + // Stub ensureCollection to avoid calling the real endpoint flow + const ensureSpy = vi.spyOn(adapter as any, "ensureCollection").mockResolvedValue(true) + + await adapter.upsert([ + { + id: "1", + vector: [0.1, 0.2], + payload: { filePath: "src/a.ts", codeChunk: "x", startLine: 1, endLine: 2 }, + }, + ]) + + expect(mockQdrantClientInstance.upsert).toHaveBeenCalledTimes(2) + expect(ensureSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/services/vector-store/adapters/qdrant.ts b/src/services/vector-store/adapters/qdrant.ts new file mode 100644 index 00000000000..c6f0b87b961 --- /dev/null +++ b/src/services/vector-store/adapters/qdrant.ts @@ -0,0 +1,309 @@ +import { QdrantClient, Schemas } from "@qdrant/js-client-rest" +import { createHash } from "crypto" +import * as path from "path" +import { VectorDatabaseAdapter, VectorRecord } from "../interfaces" +import { QdrantFilterTranslator } from "../filters" +import { DimensionMismatchError, CollectionInitError } from "../errors" +import { t } from "../../../i18n" + +/** + * Qdrant adapter implementing the provider-agnostic VectorDatabaseAdapter. + * + * Parity goals: + * - Collection naming via workspace hash (handled by caller) and dimension checks. + * - URL/port normalization (HTTPS→443, HTTP→80) with path prefix support. + * - Payload indexes for `pathSegments.0..4`. + * - Upsert shapes `pathSegments` from payload.filePath. + * - Search returns results including `filePath`, `codeChunk`, `startLine`, `endLine`, `pathSegments` and filters out invalid payloads. + */ +export class QdrantAdapter implements VectorDatabaseAdapter { + private client: QdrantClient + private readonly name: string + private readonly urlResolved: string + private readonly vectorSize: number + private readonly DISTANCE_METRIC = "Cosine" + private readonly filterTranslator = new QdrantFilterTranslator() + + /** + * Creates a new Qdrant adapter bound to a workspace. + * @param workspacePath Path to workspace used for stable collection naming. + * @param qdrantUrl Qdrant endpoint (scheme/host[:port][/prefix]). + * @param apiKey Optional Qdrant API key. + * @param dimension Embedding dimension for the collection. + */ + constructor( + private readonly workspacePath: string, + qdrantUrl: string | undefined, + private readonly apiKey: string | undefined, + dimension: number, + collectionSuffix?: string, + ) { + this.vectorSize = dimension + const parsedUrl = this.parseQdrantUrl(qdrantUrl) + this.urlResolved = parsedUrl + + try { + const urlObj = new URL(parsedUrl) + let port: number + let useHttps: boolean + if (urlObj.port) { + port = Number(urlObj.port) + useHttps = urlObj.protocol === "https:" + } else { + if (urlObj.protocol === "https:") { + port = 443 + useHttps = true + } else { + port = 80 + useHttps = false + } + } + this.client = new QdrantClient({ + host: urlObj.hostname, + https: useHttps, + port: port, + prefix: urlObj.pathname === "/" ? undefined : urlObj.pathname.replace(/\/+$/, ""), + apiKey: apiKey, + headers: { "User-Agent": "Roo-Code" }, + }) + } catch { + this.client = new QdrantClient({ url: parsedUrl, apiKey: apiKey, headers: { "User-Agent": "Roo-Code" } }) + } + + const hash = createHash("sha256").update(workspacePath).digest("hex") + const base = `ws-${hash.substring(0, 16)}` + const suffix = collectionSuffix ? this.sanitizeSuffix(collectionSuffix) : "" + this.name = suffix ? `${base}-${suffix}` : base + } + + /** + * Sanitizes a collection suffix to a safe, predictable token. + */ + private sanitizeSuffix(raw: string): string { + return raw + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + } + + provider(): "qdrant" { + return "qdrant" + } + + capabilities() { + return { deleteByFilter: true, filterScroll: true } + } + + collectionName(): string { + return this.name + } + + private parseQdrantUrl(url: string | undefined): string { + if (!url || url.trim() === "") return "http://localhost:6333" + const trimmedUrl = url.trim() + if (!trimmedUrl.startsWith("http://") && !trimmedUrl.startsWith("https://") && !trimmedUrl.includes("://")) { + return this.parseHostname(trimmedUrl) + } + try { + new URL(trimmedUrl) + return trimmedUrl + } catch { + return this.parseHostname(trimmedUrl) + } + } + + private parseHostname(hostname: string): string { + if (hostname.includes(":")) { + return hostname.startsWith("http") ? hostname : `http://${hostname}` + } + return `http://${hostname}` + } + + private async getCollectionInfo(): Promise { + try { + const collectionInfo = await this.client.getCollection(this.name) + return collectionInfo + } catch (error) { + if (error instanceof Error) { + console.warn(`[QdrantAdapter] getCollectionInfo warning for "${this.name}":`, error.message) + } + return null + } + } + + /** + * Ensures the collection exists and matches the configured dimension. + * Returns true when created or recreated due to dimension mismatch. + */ + async ensureCollection(name: string, dimension: number): Promise { + let created = false + try { + const info = await this.getCollectionInfo() + if (info === null) { + await this.client.createCollection(this.name, { + vectors: { size: this.vectorSize, distance: this.DISTANCE_METRIC, on_disk: true }, + hnsw_config: { m: 64, ef_construct: 512, on_disk: true }, + }) + created = true + } else { + const vectorsConfig = info.config?.params?.vectors + let existingVectorSize = 0 + if (typeof vectorsConfig === "number") { + existingVectorSize = vectorsConfig + } else if (vectorsConfig && typeof vectorsConfig === "object" && "size" in vectorsConfig) { + existingVectorSize = (vectorsConfig as any).size ?? 0 + } + if (existingVectorSize !== this.vectorSize) { + try { + await this.recreateCollectionWithNewDimension(existingVectorSize) + created = true + } catch (error) { + throw new DimensionMismatchError( + `Failed to recreate collection '${this.name}' with new dimension ${this.vectorSize}. Previous dimension: ${existingVectorSize}. ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + } + await this.createPayloadIndexes() + return created + } catch (error: any) { + const errorMessage = error?.message || String(error) + console.error(`[QdrantAdapter] Failed to ensure collection "${this.name}":`, errorMessage) + if (error instanceof DimensionMismatchError) throw error + if (error instanceof Error && (error as any).cause !== undefined) throw error + throw new CollectionInitError( + t("embeddings:vectorStore.qdrantConnectionFailed", { qdrantUrl: this.urlResolved, errorMessage }), + ) + } + } + + /** Recreates a collection when the dimension changes. */ + private async recreateCollectionWithNewDimension(existingVectorSize: number): Promise { + console.log( + `[QdrantAdapter] Recreating collection '${this.name}': changing from ${existingVectorSize} to ${this.vectorSize} dimensions`, + ) + try { + await this.client.deleteCollection(this.name) + await new Promise((r) => setTimeout(r, 100)) + const verificationInfo = await this.getCollectionInfo() + if (verificationInfo !== null) { + throw new CollectionInitError("Collection still exists after deletion attempt") + } + await this.client.createCollection(this.name, { + vectors: { size: this.vectorSize, distance: this.DISTANCE_METRIC, on_disk: true }, + hnsw_config: { m: 64, ef_construct: 512, on_disk: true }, + }) + } catch (recreationError) { + const errorMessage = recreationError instanceof Error ? recreationError.message : String(recreationError) + throw new DimensionMismatchError(t("embeddings:vectorStore.vectorDimensionMismatch", { errorMessage })) + } + } + + /** Creates `pathSegments.N` payload indexes (best-effort). */ + private async createPayloadIndexes(): Promise { + for (let i = 0; i <= 4; i++) { + try { + await this.client.createPayloadIndex(this.name, { + field_name: `pathSegments.${i}`, + field_schema: "keyword", + }) + } catch (indexError: any) { + const msg = (indexError?.message || "").toLowerCase() + if (!msg.includes("already exists")) { + console.warn( + `[QdrantAdapter] Could not create payload index pathSegments.${i} on ${this.name}:`, + indexError?.message || indexError, + ) + } + } + } + } + + /** Upserts a batch of vector records, shaping `pathSegments` from filePath if present. */ + async upsert(records: Array): Promise { + try { + const processed = records.map((r) => { + const p = r.payload as any + if (p?.filePath) { + const segments = String(p.filePath).replace(/\\/g, "/").split("/").filter(Boolean) + const pathSegments = segments.reduce( + (acc: Record, segment: string, index: number) => { + acc[index.toString()] = segment + return acc + }, + {}, + ) + return { ...r, payload: { ...p, pathSegments } } + } + return r + }) + await this.client.upsert(this.name, { points: processed as any, wait: true }) + } catch (error) { + // Recover on NotFound by ensuring the collection, then retry once. + const status = (error as any)?.status || (error as any)?.response?.status + const message = (error as any)?.message || String(error) + if (status === 404 || /not\s*found/i.test(message)) { + try { + await this.ensureCollection(this.name, this.vectorSize) + await this.client.upsert(this.name, { points: records as any, wait: true }) + return + } catch (retryErr) { + console.error("[QdrantAdapter] Upsert retry after ensure failed:", retryErr) + throw retryErr + } + } + console.error("[QdrantAdapter] Failed to upsert records:", error) + throw error + } + } + + /** Performs similarity search with optional filter and minScore. */ + async search(embedding: number[], limit: number, filters?: any, minScore?: number): Promise> { + try { + const searchRequest: any = { + query: embedding, + filter: filters, + score_threshold: minScore, + limit, + params: { hnsw_ef: 128, exact: false }, + with_payload: { include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"] }, + } + const res = await this.client.query(this.name, searchRequest) + const points = (res.points || []).filter((p: any) => this.isPayloadValid(p?.payload)) + return points as any + } catch (error) { + console.error("[QdrantAdapter] Failed to search:", error) + throw error + } + } + + /** Deletes points matching the provided filter. */ + async deleteByFilter(filter: any): Promise { + await this.client.delete(this.name, { filter, wait: true }) + } + + /** Deletes all points in the collection. */ + async clearAll(): Promise { + await this.client.delete(this.name, { filter: { must: [] }, wait: true }) + } + + /** Deletes the collection if it exists. */ + async deleteCollection(): Promise { + if (await this.collectionExists()) { + await this.client.deleteCollection(this.name) + } + } + + /** Returns true if the collection exists. */ + async collectionExists(): Promise { + const info = await this.getCollectionInfo() + return info !== null + } + + private isPayloadValid(payload: Record | null | undefined): boolean { + if (!payload) return false + const validKeys = ["filePath", "codeChunk", "startLine", "endLine"] + return validKeys.every((k) => k in payload) + } +} diff --git a/src/services/vector-store/collection-manager.ts b/src/services/vector-store/collection-manager.ts new file mode 100644 index 00000000000..9987ca966a1 --- /dev/null +++ b/src/services/vector-store/collection-manager.ts @@ -0,0 +1,62 @@ +const readyCollections = new Map() +const inFlightEnsures = new Map>() + +/** + * Tracks ensure-once readiness per (collection, dimension) pair. + * Deduplicates concurrent ensure calls for the same key. + */ +export class CollectionManager { + /** + * Builds a stable key for a (collection, dimension) pair. + */ + static key(name: string, dimension: number): string { + return `${name}:${dimension}` + } + + /** + * Returns true if ensure has already succeeded for this key in the current process. + */ + static isReady(name: string, dimension: number): boolean { + return readyCollections.get(this.key(name, dimension)) === true + } + + /** Marks a collection as ready for the given dimension. */ + static markReady(name: string, dimension: number): void { + readyCollections.set(this.key(name, dimension), true) + } + + /** + * Calls the provided ensure function at most once per key. + * Deduplicates concurrent calls for the same key. + * @returns true if the underlying ensure created/recreated the collection, false if it was already compatible. + */ + static async ensureOnce( + ensureFn: (name: string, dimension: number) => Promise, + name: string, + dimension: number, + ): Promise { + const key = this.key(name, dimension) + + if (this.isReady(name, dimension)) return false + + // Check if already in-flight + if (inFlightEnsures.has(key)) { + return inFlightEnsures.get(key)! + } + + // Start new ensure operation with cleanup + const promise = ensureFn(name, dimension) + .then((created) => { + this.markReady(name, dimension) + inFlightEnsures.delete(key) + return created + }) + .catch((error) => { + inFlightEnsures.delete(key) + throw error + }) + + inFlightEnsures.set(key, promise) + return promise + } +} diff --git a/src/services/vector-store/errors.ts b/src/services/vector-store/errors.ts new file mode 100644 index 00000000000..efd2545e62f --- /dev/null +++ b/src/services/vector-store/errors.ts @@ -0,0 +1,50 @@ +/** + * Base class for vector-store related errors. + */ +export class VectorStoreError extends Error { + constructor(message: string) { + super(message) + this.name = "VectorStoreError" + } +} + +/** + * Raised when an existing collection's vector dimension does not match + * the requested one and recovery fails. + */ +export class DimensionMismatchError extends VectorStoreError { + constructor(message: string) { + super(message) + this.name = "DimensionMismatchError" + } +} + +/** + * Raised when a collection cannot be created or initialized. + */ +export class CollectionInitError extends VectorStoreError { + constructor(message: string) { + super(message) + this.name = "CollectionInitError" + } +} + +/** + * Raised when attempting to instantiate or use an unsupported provider. + */ +export class UnsupportedProviderError extends VectorStoreError { + constructor(message: string) { + super(message) + this.name = "UnsupportedProviderError" + } +} + +/** + * Raised when configuration is invalid or incomplete. + */ +export class ConfigurationError extends VectorStoreError { + constructor(message: string) { + super(message) + this.name = "ConfigurationError" + } +} diff --git a/src/services/vector-store/factory.ts b/src/services/vector-store/factory.ts new file mode 100644 index 00000000000..674f545de74 --- /dev/null +++ b/src/services/vector-store/factory.ts @@ -0,0 +1,35 @@ +import { VectorDatabaseAdapter, VectorStoreConfig } from "./interfaces" +import { QdrantAdapter } from "./adapters/qdrant" +import { UnsupportedProviderError } from "./errors" + +export class VectorStoreFactory { + private static readonly supportedProviders = new Set(["qdrant"]) + + static create(config: VectorStoreConfig): VectorDatabaseAdapter { + if (config.provider === "qdrant") { + const url = config.qdrant?.url + const apiKey = config.qdrant?.apiKey + return new QdrantAdapter(config.workspacePath, url, apiKey, config.dimension, config.collectionSuffix) + } + + const supportedList = Array.from(this.supportedProviders).join(", ") + throw new UnsupportedProviderError( + `Unsupported vector store provider: ${config.provider}. Currently supported: ${supportedList}`, + ) + } + + /** + * Register a new provider name to the list of supported providers. + * This allows future providers to be added without modifying error messages. + */ + static registerProvider(providerName: string): void { + this.supportedProviders.add(providerName) + } + + /** + * Get a list of currently supported provider names. + */ + static getSupportedProviders(): string[] { + return Array.from(this.supportedProviders) + } +} diff --git a/src/services/vector-store/filters.ts b/src/services/vector-store/filters.ts new file mode 100644 index 00000000000..53b4f2d9c86 --- /dev/null +++ b/src/services/vector-store/filters.ts @@ -0,0 +1,65 @@ +import * as path from "path" + +/** + * Translates high-level, provider-agnostic filter inputs (e.g. directory + * prefixes, file paths) into provider-native filter objects. + */ +export abstract class FilterTranslator { + /** + * Builds a provider-native filter for a directory prefix. Implementations + * should return undefined when no filter should be applied. + */ + abstract directoryPrefixToFilter(prefix?: string): any | undefined + /** + * Builds a provider-native delete filter for one or many file paths. + * Implementations should encode exact-path matching semantics. + */ + abstract filePathsToDeleteFilter(filePaths: string[], workspaceRoot: string): any | undefined +} + +/** + * Qdrant implementation that expresses directory and path filters using + * `pathSegments.N` must/should clauses. + */ +export class QdrantFilterTranslator extends FilterTranslator { + directoryPrefixToFilter(prefix?: string): any | undefined { + if (!prefix) return undefined + + const normalizedPrefix = path.posix.normalize(prefix.replace(/\\/g, "/")) + if (normalizedPrefix === "." || normalizedPrefix === "./") { + return undefined + } + const cleaned = path.posix.normalize( + normalizedPrefix.startsWith("./") ? normalizedPrefix.slice(2) : normalizedPrefix, + ) + const segments = cleaned.split("/").filter(Boolean) + if (segments.length === 0) return undefined + return { + must: segments.map((segment, index) => ({ + key: `pathSegments.${index}`, + match: { value: segment }, + })), + } + } + + /** + * Builds an OR filter across file paths, each expressed as a series of + * `pathSegments.N` must clauses to match the exact path. + */ + filePathsToDeleteFilter(filePaths: string[], workspaceRoot: string): any | undefined { + if (filePaths.length === 0) return undefined + + const filters = filePaths.map((filePath) => { + const relativePath = path.isAbsolute(filePath) ? path.relative(workspaceRoot, filePath) : filePath + const normalizedRelativePath = path.normalize(relativePath) + const segments = normalizedRelativePath.split(path.sep).filter(Boolean) + const mustConditions = segments.map((segment, index) => ({ + key: `pathSegments.${index}`, + match: { value: segment }, + })) + return { must: mustConditions } + }) + + return filters.length === 1 ? filters[0] : { should: filters } + } +} diff --git a/src/services/vector-store/interfaces.ts b/src/services/vector-store/interfaces.ts new file mode 100644 index 00000000000..c29164e16a9 --- /dev/null +++ b/src/services/vector-store/interfaces.ts @@ -0,0 +1,79 @@ +/** + * Represents a stored or retrieved vector record. + * @template T Optional payload shape carried alongside the vector. + */ +export interface VectorRecord { + id: string + vector: number[] + payload: T + score?: number +} + +/** + * Generic key/value filter type passed to provider adapters. + * Adapters are responsible for translating these into their native + * query/filter language (see FilterTranslator in filters.ts). + */ +export type VectorFilter = Record + +/** + * Declares optional capabilities that an adapter can expose so that + * higher layers can branch behavior without provider-specific code. + */ +export interface DatabaseCapabilities { + deleteByFilter?: boolean + filterScroll?: boolean +} + +/** + * Provider-agnostic vector database surface consumed by higher layers. + * + * Implementations should provide parity with the behavior documented + * in Code Index (naming, payload includes, filtering semantics) where + * applicable. + */ +export interface VectorDatabaseAdapter { + provider(): string + capabilities(): DatabaseCapabilities + collectionName(): string + + /** + * Ensures the collection exists with the requested dimension. + * @returns true when the collection was created or recreated (dimension change), false when already compatible. + */ + ensureCollection(name: string, dimension: number): Promise + + /** + * Upserts (inserts or replaces) a batch of vector records. + */ + upsert(records: Array): Promise + + /** + * Vector similarity search. + * @param embedding Query embedding. + * @param limit Maximum number of results to return. + * @param filters Optional provider-native filter produced by a FilterTranslator. + * @param minScore Optional minimum similarity score threshold. + */ + search(embedding: number[], limit: number, filters?: any, minScore?: number): Promise> + + deleteByFilter?(filter: any): Promise + clearAll(): Promise + deleteCollection(): Promise + collectionExists(): Promise +} + +/** + * Minimal configuration required to instantiate a provider adapter via the factory. + */ +export interface VectorStoreConfig { + provider: string + workspacePath: string + dimension: number + /** Optional collection suffix to allow feature-specific isolation (e.g., "chatmemory"). */ + collectionSuffix?: string + qdrant?: { + url: string + apiKey?: string + } +} diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index 45bf4224a12..385c8880769 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -1131,7 +1131,19 @@ export const CodeIndexPopover: React.FC = ({ )} - {/* Qdrant Settings */} + {/* Vector Database Provider (Qdrant only for now) */} +
+ + + Qdrant + +
+ + {/* Vector Database Settings (Qdrant implementation) */}