From 80615048752035248bc82315baff37421bde219b Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Fri, 11 Jul 2025 09:56:38 -0500 Subject: [PATCH 1/4] feat: add enable/disable toggle for code indexing feature - Add checkbox to enable/disable code indexing in settings - Persist enabled state across sessions - Stop indexing service when feature is disabled - Move description to tooltip for consistent UI - Update translations to remove experimental status --- src/core/webview/ClineProvider.ts | 26 +- src/core/webview/webviewMessageHandler.ts | 3 +- .../__tests__/config-manager.spec.ts | 1388 +++-------------- src/services/code-index/config-manager.ts | 24 +- src/services/code-index/manager.ts | 11 + .../src/components/chat/CodeIndexPopover.tsx | 116 +- webview-ui/src/i18n/locales/ca/settings.json | 2 +- webview-ui/src/i18n/locales/de/settings.json | 2 +- webview-ui/src/i18n/locales/en/settings.json | 2 +- webview-ui/src/i18n/locales/es/settings.json | 2 +- webview-ui/src/i18n/locales/fr/settings.json | 2 +- webview-ui/src/i18n/locales/hi/settings.json | 2 +- webview-ui/src/i18n/locales/id/settings.json | 2 +- webview-ui/src/i18n/locales/it/settings.json | 2 +- webview-ui/src/i18n/locales/ja/settings.json | 2 +- webview-ui/src/i18n/locales/ko/settings.json | 2 +- webview-ui/src/i18n/locales/nl/settings.json | 2 +- webview-ui/src/i18n/locales/pl/settings.json | 2 +- .../src/i18n/locales/pt-BR/settings.json | 2 +- webview-ui/src/i18n/locales/ru/settings.json | 2 +- webview-ui/src/i18n/locales/tr/settings.json | 2 +- webview-ui/src/i18n/locales/vi/settings.json | 2 +- .../src/i18n/locales/zh-CN/settings.json | 2 +- .../src/i18n/locales/zh-TW/settings.json | 2 +- 24 files changed, 375 insertions(+), 1229 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4a934e9fa0e..e96f16103a7 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1509,12 +1509,12 @@ export class ClineProvider condensingApiConfigId, customCondensingPrompt, codebaseIndexModels: codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, - codebaseIndexConfig: codebaseIndexConfig ?? { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://localhost:6333", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderBaseUrl: "", - codebaseIndexEmbedderModelId: "", + codebaseIndexConfig: { + codebaseIndexEnabled: codebaseIndexConfig?.codebaseIndexEnabled ?? true, + codebaseIndexQdrantUrl: codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", + codebaseIndexEmbedderProvider: codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? "openai", + codebaseIndexEmbedderBaseUrl: codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", + codebaseIndexEmbedderModelId: codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", }, mdmCompliant: this.checkMdmCompliance(), profileThresholds: profileThresholds ?? {}, @@ -1667,12 +1667,14 @@ export class ClineProvider condensingApiConfigId: stateValues.condensingApiConfigId, customCondensingPrompt: stateValues.customCondensingPrompt, codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, - codebaseIndexConfig: stateValues.codebaseIndexConfig ?? { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://localhost:6333", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderBaseUrl: "", - codebaseIndexEmbedderModelId: "", + codebaseIndexConfig: { + codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? true, + codebaseIndexQdrantUrl: + stateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", + codebaseIndexEmbedderProvider: + stateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? "openai", + codebaseIndexEmbedderBaseUrl: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", + codebaseIndexEmbedderModelId: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", }, profileThresholds: stateValues.profileThresholds ?? {}, } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index a6577fb2fbf..fe5033d29e9 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1936,9 +1936,10 @@ export const webviewMessageHandler = async ( const embedderProviderChanged = currentConfig.codebaseIndexEmbedderProvider !== settings.codebaseIndexEmbedderProvider - // Save global state settings atomically (without codebaseIndexEnabled which is now in global settings) + // Save global state settings atomically const globalStateConfig = { ...currentConfig, + codebaseIndexEnabled: settings.codebaseIndexEnabled, codebaseIndexQdrantUrl: settings.codebaseIndexQdrantUrl, codebaseIndexEmbedderProvider: settings.codebaseIndexEmbedderProvider, codebaseIndexEmbedderBaseUrl: settings.codebaseIndexEmbedderBaseUrl, diff --git a/src/services/code-index/__tests__/config-manager.spec.ts b/src/services/code-index/__tests__/config-manager.spec.ts index 641abfa306a..5c9794f4ee5 100644 --- a/src/services/code-index/__tests__/config-manager.spec.ts +++ b/src/services/code-index/__tests__/config-manager.spec.ts @@ -1,1303 +1,399 @@ +// npx vitest services/code-index/__tests__/config-manager.spec.ts + +import { describe, it, expect, beforeEach, vi } from "vitest" import { CodeIndexConfigManager } from "../config-manager" +import { ContextProxy } from "../../../core/config/ContextProxy" +import { PreviousConfigSnapshot } from "../interfaces/config" + +// Mock ContextProxy +vi.mock("../../../core/config/ContextProxy") describe("CodeIndexConfigManager", () => { - let mockContextProxy: any let configManager: CodeIndexConfigManager + let mockContextProxy: any beforeEach(() => { + // Reset mocks + vi.clearAllMocks() + // Setup mock ContextProxy mockContextProxy = { - getGlobalState: vitest.fn(), - getSecret: vitest.fn().mockReturnValue(undefined), - refreshSecrets: vitest.fn().mockResolvedValue(undefined), + getGlobalState: vi.fn(), + updateGlobalState: vi.fn(), + refreshSecrets: vi.fn().mockResolvedValue(undefined), + getSecret: vi.fn(), } + // Create a new instance for each test configManager = new CodeIndexConfigManager(mockContextProxy) }) - // Helper function to setup secret mocking - const setupSecretMocks = (secrets: Record) => { - // Mock sync secret access - mockContextProxy.getSecret.mockImplementation((key: string) => { - return secrets[key] || undefined - }) - - // Mock refreshSecrets to update the getSecret mock with new values - mockContextProxy.refreshSecrets.mockImplementation(async () => { - // In real implementation, this would refresh from VSCode storage - // For tests, we just keep the existing mock behavior - }) - } - - describe("constructor", () => { - it("should initialize with ContextProxy", () => { - expect(configManager).toBeDefined() - expect(configManager.isFeatureEnabled).toBe(true) - expect(configManager.currentEmbedderProvider).toBe("openai") - }) - }) - - describe("loadConfiguration", () => { - it("should load default configuration when no state exists", async () => { - mockContextProxy.getGlobalState.mockReturnValue(undefined) + describe("isFeatureEnabled", () => { + it("should return false when codebaseIndexEnabled is false", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: false, + }) mockContextProxy.getSecret.mockReturnValue(undefined) - const result = await configManager.loadConfiguration() - - expect(result.currentConfig).toEqual({ - isConfigured: false, - embedderProvider: "openai", - modelId: undefined, - openAiOptions: { openAiNativeApiKey: "" }, - ollamaOptions: { ollamaBaseUrl: "" }, - qdrantUrl: "http://localhost:6333", - qdrantApiKey: "", - searchMinScore: 0.4, - }) - expect(result.requiresRestart).toBe(false) + // Re-create instance to load the configuration + configManager = new CodeIndexConfigManager(mockContextProxy) + expect(configManager.isFeatureEnabled).toBe(false) }) - it("should load configuration from globalState and secrets", async () => { - const mockGlobalState = { + it("should return true when codebaseIndexEnabled is true", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderBaseUrl: "", - codebaseIndexEmbedderModelId: "text-embedding-3-large", - } - mockContextProxy.getGlobalState.mockReturnValue(mockGlobalState) - - // Mock both sync and async secret access - setupSecretMocks({ - codeIndexOpenAiKey: "test-openai-key", - codeIndexQdrantApiKey: "test-qdrant-key", }) + mockContextProxy.getSecret.mockReturnValue(undefined) - const result = await configManager.loadConfiguration() - - expect(result.currentConfig).toEqual({ - isConfigured: true, - embedderProvider: "openai", - modelId: "text-embedding-3-large", - openAiOptions: { openAiNativeApiKey: "test-openai-key" }, - ollamaOptions: { ollamaBaseUrl: "" }, - qdrantUrl: "http://qdrant.local", - qdrantApiKey: "test-qdrant-key", - searchMinScore: 0.4, - }) + // Re-create instance to load the configuration + configManager = new CodeIndexConfigManager(mockContextProxy) + expect(configManager.isFeatureEnabled).toBe(true) }) - it("should load OpenAI Compatible configuration from globalState and secrets", async () => { - const mockGlobalState = { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderBaseUrl: "", - codebaseIndexEmbedderModelId: "text-embedding-3-large", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - } - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") return mockGlobalState - return undefined - }) - - setupSecretMocks({ - codeIndexQdrantApiKey: "test-qdrant-key", - codebaseIndexOpenAiCompatibleApiKey: "test-openai-compatible-key", - }) - - const result = await configManager.loadConfiguration() + it("should default to true when codebaseIndexEnabled is not set", async () => { + mockContextProxy.getGlobalState.mockReturnValue({}) + mockContextProxy.getSecret.mockReturnValue(undefined) - expect(result.currentConfig).toEqual({ - isConfigured: true, - embedderProvider: "openai-compatible", - modelId: "text-embedding-3-large", - openAiOptions: { openAiNativeApiKey: "" }, - ollamaOptions: { ollamaBaseUrl: "" }, - openAiCompatibleOptions: { - baseUrl: "https://api.example.com/v1", - apiKey: "test-openai-compatible-key", - }, - qdrantUrl: "http://qdrant.local", - qdrantApiKey: "test-qdrant-key", - searchMinScore: 0.4, - }) + // Re-create instance to load the configuration + configManager = new CodeIndexConfigManager(mockContextProxy) + expect(configManager.isFeatureEnabled).toBe(true) }) + }) - it("should load OpenAI Compatible configuration with modelDimension from globalState", async () => { - const mockGlobalState = { + describe("isConfigured", () => { + it("should return true when OpenAI provider is properly configured", () => { + mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderBaseUrl: "", - codebaseIndexEmbedderModelId: "custom-model", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - codebaseIndexEmbedderModelDimension: 1024, - } - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") return mockGlobalState - return undefined - }) - setupSecretMocks({ - codeIndexQdrantApiKey: "test-qdrant-key", - codebaseIndexOpenAiCompatibleApiKey: "test-openai-compatible-key", - }) - - const result = await configManager.loadConfiguration() - - expect(result.currentConfig).toEqual({ - isConfigured: true, - embedderProvider: "openai-compatible", - modelId: "custom-model", - modelDimension: 1024, - openAiOptions: { openAiNativeApiKey: "" }, - ollamaOptions: { ollamaBaseUrl: "" }, - openAiCompatibleOptions: { - baseUrl: "https://api.example.com/v1", - apiKey: "test-openai-compatible-key", - }, - qdrantUrl: "http://qdrant.local", - qdrantApiKey: "test-qdrant-key", - searchMinScore: 0.4, + codebaseIndexEmbedderProvider: "openai", + codebaseIndexQdrantUrl: "http://localhost:6333", }) - }) - - it("should handle missing modelDimension for OpenAI Compatible configuration", async () => { - const mockGlobalState = { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderBaseUrl: "", - codebaseIndexEmbedderModelId: "custom-model", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - // modelDimension is not set - } - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") return mockGlobalState + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" return undefined }) - setupSecretMocks({ - codeIndexQdrantApiKey: "test-qdrant-key", - codebaseIndexOpenAiCompatibleApiKey: "test-openai-compatible-key", - }) - const result = await configManager.loadConfiguration() + configManager = new CodeIndexConfigManager(mockContextProxy) - expect(result.currentConfig).toEqual({ - isConfigured: true, - embedderProvider: "openai-compatible", - modelId: "custom-model", - openAiOptions: { openAiNativeApiKey: "" }, - ollamaOptions: { ollamaBaseUrl: "" }, - openAiCompatibleOptions: { - baseUrl: "https://api.example.com/v1", - apiKey: "test-openai-compatible-key", - // modelDimension is undefined when not set - }, - qdrantUrl: "http://qdrant.local", - qdrantApiKey: "test-qdrant-key", - searchMinScore: 0.4, - }) + expect(configManager.isConfigured()).toBe(true) }) - it("should handle invalid modelDimension type for OpenAI Compatible configuration", async () => { - const mockGlobalState = { + it("should return false when OpenAI provider is missing API key", () => { + mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderBaseUrl: "", - codebaseIndexEmbedderModelId: "custom-model", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - codebaseIndexEmbedderModelDimension: "invalid-dimension", // Invalid type - } - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") return mockGlobalState - return undefined - }) - setupSecretMocks({ - codeIndexQdrantApiKey: "test-qdrant-key", - codebaseIndexOpenAiCompatibleApiKey: "test-openai-compatible-key", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexQdrantUrl: "http://localhost:6333", }) + mockContextProxy.getSecret.mockReturnValue(undefined) - const result = await configManager.loadConfiguration() + configManager = new CodeIndexConfigManager(mockContextProxy) - expect(result.currentConfig).toEqual({ - isConfigured: true, - embedderProvider: "openai-compatible", - modelId: "custom-model", - modelDimension: undefined, // Invalid dimension is converted to undefined - openAiOptions: { openAiNativeApiKey: "" }, - ollamaOptions: { ollamaBaseUrl: "" }, - openAiCompatibleOptions: { - baseUrl: "https://api.example.com/v1", - apiKey: "test-openai-compatible-key", - }, - geminiOptions: undefined, - qdrantUrl: "http://qdrant.local", - qdrantApiKey: "test-qdrant-key", - searchMinScore: 0.4, - }) + expect(configManager.isConfigured()).toBe(false) }) - it("should detect restart requirement when provider changes", async () => { - // Initial state - properly configured + it("should return false when OpenAI provider is missing Qdrant URL", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-large", }) - setupSecretMocks({ - codeIndexOpenAiKey: "test-openai-key", + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined }) - await configManager.loadConfiguration() - - // Change provider - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", - codebaseIndexEmbedderBaseUrl: "http://ollama.local", - codebaseIndexEmbedderModelId: "nomic-embed-text", - }) + configManager = new CodeIndexConfigManager(mockContextProxy) - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(true) + expect(configManager.isConfigured()).toBe(false) }) + }) - it("should detect restart requirement when vector dimensions change", async () => { - // Initial state with text-embedding-3-small (1536D) + describe("doesConfigChangeRequireRestart", () => { + it("should return true when enabling the feature", async () => { + // Initial state: disabled mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEnabled: false, codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - }) - setupSecretMocks({ - codeIndexOpenAiKey: "test-key", - codeIndexQdrantApiKey: "test-key", + codebaseIndexQdrantUrl: "http://localhost:6333", }) + mockContextProxy.getSecret.mockReturnValue(undefined) + configManager = new CodeIndexConfigManager(mockContextProxy) - await configManager.loadConfiguration() + // Get the initial snapshot + const { configSnapshot: previousSnapshot } = await configManager.loadConfiguration() - // Change to text-embedding-3-large (3072D) + // Update the internal state to enabled with proper configuration mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-large", + codebaseIndexQdrantUrl: "http://localhost:6333", + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined }) - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(true) + // Load the new configuration - this will internally call doesConfigChangeRequireRestart + const { requiresRestart } = await configManager.loadConfiguration() + + expect(requiresRestart).toBe(true) }) - it("should NOT require restart when models have same dimensions", async () => { - // Initial state with text-embedding-3-small (1536D) + it("should return true when disabling the feature", async () => { + // Initial state: enabled and configured mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexQdrantUrl: "http://localhost:6333", }) - setupSecretMocks({ - codeIndexOpenAiKey: "test-key", + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined }) + configManager = new CodeIndexConfigManager(mockContextProxy) - await configManager.loadConfiguration() + const previousSnapshot: PreviousConfigSnapshot = { + enabled: true, + configured: true, + embedderProvider: "openai", + openAiKey: "test-key", + qdrantUrl: "http://localhost:6333", + } - // Change to text-embedding-ada-002 (also 1536D) + // Update to disabled mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEnabled: false, codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-ada-002", + codebaseIndexQdrantUrl: "http://localhost:6333", }) - - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(false) - }) - - it("should detect restart requirement when transitioning to enabled+configured", async () => { - // Initial state - enabled but not configured - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined }) await configManager.loadConfiguration() - // Configure the feature + const result = configManager.doesConfigChangeRequireRestart(previousSnapshot) + expect(result).toBe(true) + }) + + it("should return false when enabled state does not change (both enabled)", async () => { + // Initial state: enabled and configured mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - }) - setupSecretMocks({ - codeIndexOpenAiKey: "test-key", - codeIndexQdrantApiKey: "test-key", - }) - - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(true) - }) - - describe("simplified restart detection", () => { - it("should detect restart requirement for API key changes", async () => { - // Initial state - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - }) - setupSecretMocks({ - codeIndexOpenAiKey: "old-key", - codeIndexQdrantApiKey: "old-key", - }) - - await configManager.loadConfiguration() - - // Change API key - setupSecretMocks({ - codeIndexOpenAiKey: "new-key", - codeIndexQdrantApiKey: "old-key", - }) - - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(true) - }) - - it("should detect restart requirement for Qdrant URL changes", async () => { - // Initial state - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://old-qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - }) - setupSecretMocks({ - codeIndexOpenAiKey: "test-key", - codeIndexQdrantApiKey: "test-key", - }) - - await configManager.loadConfiguration() - - // Change Qdrant URL - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://new-qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - }) - - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(true) - }) - - it("should handle unknown model dimensions safely", async () => { - // Initial state with known model - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - }) - setupSecretMocks({ - codeIndexOpenAiKey: "test-key", - codeIndexQdrantApiKey: "test-key", - }) - - await configManager.loadConfiguration() - - // Change to unknown model - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "unknown-model", - }) - - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(true) - }) - - it("should handle Ollama configuration changes", async () => { - // Initial state - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", - codebaseIndexEmbedderBaseUrl: "http://old-ollama.local", - codebaseIndexEmbedderModelId: "nomic-embed-text", - }) - - await configManager.loadConfiguration() - - // Change Ollama base URL - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", - codebaseIndexEmbedderBaseUrl: "http://new-ollama.local", - codebaseIndexEmbedderModelId: "nomic-embed-text", - }) - - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(true) - }) - - it("should handle OpenAI Compatible configuration changes", async () => { - // Initial state - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - codebaseIndexOpenAiCompatibleBaseUrl: "https://old-api.example.com/v1", - } - } - return undefined - }) - setupSecretMocks({ - codebaseIndexOpenAiCompatibleApiKey: "old-api-key", - codeIndexQdrantApiKey: "test-key", - }) - - await configManager.loadConfiguration() - - // Change OpenAI Compatible base URL - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - codebaseIndexOpenAiCompatibleBaseUrl: "https://new-api.example.com/v1", - } - } - return undefined - }) - - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(true) - }) - - it("should handle OpenAI Compatible API key changes", async () => { - // Initial state - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - } - } - return undefined - }) - setupSecretMocks({ - codebaseIndexOpenAiCompatibleApiKey: "old-api-key", - codeIndexQdrantApiKey: "test-key", - }) - - await configManager.loadConfiguration() - - // Change OpenAI Compatible API key - setupSecretMocks({ - codebaseIndexOpenAiCompatibleApiKey: "new-api-key", - codeIndexQdrantApiKey: "test-key", - }) - - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(true) - }) - - it("should handle OpenAI Compatible modelDimension changes", async () => { - // Initial state with modelDimension - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderModelId: "custom-model", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - codebaseIndexEmbedderModelDimension: 1024, - } - } - return undefined - }) - setupSecretMocks({ - codebaseIndexOpenAiCompatibleApiKey: "test-api-key", - codeIndexQdrantApiKey: "test-key", - }) - - await configManager.loadConfiguration() - - // Change modelDimension - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderModelId: "custom-model", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - codebaseIndexEmbedderModelDimension: 2048, - } - } - return undefined - }) - - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(true) - }) - - it("should not require restart when modelDimension remains the same", async () => { - // Initial state with modelDimension - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderModelId: "custom-model", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - codebaseIndexEmbedderModelDimension: 1024, - } - } - return undefined - }) - setupSecretMocks({ - codebaseIndexOpenAiCompatibleApiKey: "test-api-key", - codeIndexQdrantApiKey: "test-key", - }) - - await configManager.loadConfiguration() - - // Keep modelDimension the same, change unrelated setting - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderModelId: "custom-model", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - codebaseIndexEmbedderModelDimension: 1024, - codebaseIndexSearchMinScore: 0.5, // Changed unrelated setting - } - } - return undefined - }) - - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(false) - }) - - it("should require restart when modelDimension is added", async () => { - // Initial state without modelDimension - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderModelId: "custom-model", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - // modelDimension not set initially - } - } - return undefined - }) - setupSecretMocks({ - codebaseIndexOpenAiCompatibleApiKey: "test-api-key", - codeIndexQdrantApiKey: "test-key", - }) - - await configManager.loadConfiguration() - - // Add modelDimension - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderModelId: "custom-model", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - codebaseIndexEmbedderModelDimension: 1024, - } - } - return undefined - }) - - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(true) - }) - - it("should require restart when modelDimension is removed", async () => { - // Initial state with modelDimension - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderModelId: "custom-model", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - codebaseIndexEmbedderModelDimension: 1024, - } - } - return undefined - }) - setupSecretMocks({ - codebaseIndexOpenAiCompatibleApiKey: "test-api-key", - codeIndexQdrantApiKey: "test-key", - }) - - await configManager.loadConfiguration() - - // Remove modelDimension - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderModelId: "custom-model", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - // modelDimension removed - } - } - return undefined - }) - - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(true) + codebaseIndexQdrantUrl: "http://localhost:6333", }) - - it("should require restart when enabled and provider changes even if unconfigured", async () => { - // Initial state - enabled but not configured (missing API key) - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - }) - setupSecretMocks({}) - - await configManager.loadConfiguration() - - // Still enabled but change provider while remaining unconfigured - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", - codebaseIndexEmbedderBaseUrl: "http://ollama.local", - }) - - const result = await configManager.loadConfiguration() - // Should require restart because provider changed while enabled - expect(result.requiresRestart).toBe(true) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined }) + configManager = new CodeIndexConfigManager(mockContextProxy) - it("should not require restart when unconfigured remains unconfigured", async () => { - // Initial state - enabled but unconfigured (missing API key) - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - }) - setupSecretMocks({}) - - await configManager.loadConfiguration() - - // Still unconfigured but change model - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-large", - }) - - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(false) - }) + // Get initial configuration + const { configSnapshot: previousSnapshot } = await configManager.loadConfiguration() - describe("currentSearchMinScore priority system", () => { - it("should return user-configured score when set", async () => { - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - codebaseIndexSearchMinScore: 0.8, // User setting - }) - mockContextProxy.getSecret.mockImplementation((key: string) => { - if (key === "codeIndexOpenAiKey") return "test-key" - return undefined - }) - - await configManager.loadConfiguration() - expect(configManager.currentSearchMinScore).toBe(0.8) - }) - - it("should fall back to model-specific threshold when user setting is undefined", async () => { - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", - codebaseIndexEmbedderModelId: "nomic-embed-code", - // No codebaseIndexSearchMinScore - user hasn't configured it - }) - - await configManager.loadConfiguration() - // nomic-embed-code has a specific threshold of 0.15 - expect(configManager.currentSearchMinScore).toBe(0.15) - }) - - it("should fall back to default DEFAULT_SEARCH_MIN_SCORE when neither user setting nor model threshold exists", async () => { - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "unknown-model", // Model not in profiles - // No codebaseIndexSearchMinScore - }) - mockContextProxy.getSecret.mockImplementation((key: string) => { - if (key === "codeIndexOpenAiKey") return "test-key" - return undefined - }) - - await configManager.loadConfiguration() - // Should fall back to default DEFAULT_SEARCH_MIN_SCORE (0.4) - expect(configManager.currentSearchMinScore).toBe(0.4) - }) - - it("should respect user setting of 0 (edge case)", async () => { - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", - codebaseIndexEmbedderModelId: "nomic-embed-code", - codebaseIndexSearchMinScore: 0, // User explicitly sets 0 - }) - - await configManager.loadConfiguration() - // Should return 0, not fall back to model threshold (0.15) - expect(configManager.currentSearchMinScore).toBe(0) - }) - - it("should use model-specific threshold with openai-compatible provider", async () => { - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexEmbedderModelId: "nomic-embed-code", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - // No codebaseIndexSearchMinScore - } - } - return undefined - }) - mockContextProxy.getSecret.mockImplementation((key: string) => { - if (key === "codebaseIndexOpenAiCompatibleApiKey") return "test-api-key" - return undefined - }) - - await configManager.loadConfiguration() - // openai-compatible provider also has nomic-embed-code with 0.15 threshold - expect(configManager.currentSearchMinScore).toBe(0.15) - }) - - it("should use default model ID when modelId is not specified", async () => { - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - // No modelId specified - // No codebaseIndexSearchMinScore - }) - mockContextProxy.getSecret.mockImplementation((key: string) => { - if (key === "codeIndexOpenAiKey") return "test-key" - return undefined - }) - - await configManager.loadConfiguration() - // Should use default model (text-embedding-3-small) threshold (0.4) - expect(configManager.currentSearchMinScore).toBe(0.4) - }) - - it("should handle priority correctly: user > model > default", async () => { - // Test 1: User setting takes precedence - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", - codebaseIndexEmbedderModelId: "nomic-embed-code", // Has 0.15 threshold - codebaseIndexSearchMinScore: 0.9, // User overrides - }) - - await configManager.loadConfiguration() - expect(configManager.currentSearchMinScore).toBe(0.9) // User setting wins - - // Test 2: Model threshold when no user setting - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "ollama", - codebaseIndexEmbedderModelId: "nomic-embed-code", - // No user setting - }) - - const newManager = new CodeIndexConfigManager(mockContextProxy) - await newManager.loadConfiguration() - expect(newManager.currentSearchMinScore).toBe(0.15) // Model threshold - - // Test 3: Default when neither exists - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "custom-unknown-model", - // No user setting, unknown model - }) - - const anotherManager = new CodeIndexConfigManager(mockContextProxy) - await anotherManager.loadConfiguration() - expect(anotherManager.currentSearchMinScore).toBe(0.4) // Default - }) - }) + // Load again with same config - should not require restart + const { requiresRestart } = await configManager.loadConfiguration() - describe("currentSearchMaxResults", () => { - it("should return user setting when provided, otherwise default", async () => { - // Test 1: User setting takes precedence - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - codebaseIndexSearchMaxResults: 150, // User setting - }) - - await configManager.loadConfiguration() - expect(configManager.currentSearchMaxResults).toBe(150) // User setting - - // Test 2: Default when no user setting - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - // No user setting - }) - - const newManager = new CodeIndexConfigManager(mockContextProxy) - await newManager.loadConfiguration() - expect(newManager.currentSearchMaxResults).toBe(50) // Default (DEFAULT_MAX_SEARCH_RESULTS) - - // Test 3: Boundary values - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - codebaseIndexSearchMaxResults: 10, // Minimum allowed - }) - - const minManager = new CodeIndexConfigManager(mockContextProxy) - await minManager.loadConfiguration() - expect(minManager.currentSearchMaxResults).toBe(10) - - // Test 4: Maximum value - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - codebaseIndexSearchMaxResults: 200, // Maximum allowed - }) - - const maxManager = new CodeIndexConfigManager(mockContextProxy) - await maxManager.loadConfiguration() - expect(maxManager.currentSearchMaxResults).toBe(200) - }) - }) + expect(requiresRestart).toBe(false) }) - describe("empty/missing API key handling", () => { - it("should not require restart when API keys are consistently empty", async () => { - // Initial state with no API keys (undefined from secrets) - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - }) - setupSecretMocks({}) - - await configManager.loadConfiguration() - - // Change an unrelated setting while keeping API keys empty - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - codebaseIndexSearchMinScore: 0.5, // Changed unrelated setting - }) - - const result = await configManager.loadConfiguration() - // Should NOT require restart since API keys are consistently empty - expect(result.requiresRestart).toBe(false) - }) - - it("should not require restart when API keys transition from undefined to empty string", async () => { - // Initial state with undefined API keys - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, // Always enabled now - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - }) - setupSecretMocks({}) - - await configManager.loadConfiguration() - - // Change to empty string API keys (simulating what happens when secrets return "") - setupSecretMocks({ - codeIndexOpenAiKey: "", - codeIndexQdrantApiKey: "", - }) - - const result = await configManager.loadConfiguration() - // Should NOT require restart since undefined and "" are both "empty" - expect(result.requiresRestart).toBe(false) + it("should return false when enabled state does not change (both disabled)", async () => { + // Initial state: disabled + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: false, }) + mockContextProxy.getSecret.mockReturnValue(undefined) + configManager = new CodeIndexConfigManager(mockContextProxy) - it("should require restart when API key actually changes from empty to non-empty", async () => { - // Initial state with empty API key - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - }) - setupSecretMocks({ - codeIndexOpenAiKey: "", - codeIndexQdrantApiKey: "", - }) - - await configManager.loadConfiguration() - - // Add actual API key - setupSecretMocks({ - codeIndexOpenAiKey: "actual-api-key", - codeIndexQdrantApiKey: "", - }) - - const result = await configManager.loadConfiguration() - // Should require restart since we went from empty to actual key - expect(result.requiresRestart).toBe(true) - }) - }) + const previousSnapshot: PreviousConfigSnapshot = { + enabled: false, + configured: false, + embedderProvider: "openai", + } - describe("getRestartInfo public method", () => { - it("should provide restart info without loading configuration", async () => { - // Setup initial state - mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", - }) - setupSecretMocks({ - codeIndexOpenAiKey: "test-key", - codeIndexQdrantApiKey: "test-key", - }) - - await configManager.loadConfiguration() - - // Create a mock previous config - const mockPrevConfig = { - enabled: true, - configured: true, - embedderProvider: "openai" as const, - modelId: "text-embedding-3-large", // Different model with different dimensions - openAiKey: "test-key", - ollamaBaseUrl: undefined, - qdrantUrl: "http://qdrant.local", - qdrantApiKey: undefined, - } - - const requiresRestart = configManager.doesConfigChangeRequireRestart(mockPrevConfig) - expect(requiresRestart).toBe(true) - }) + // Same config, still disabled + const result = configManager.doesConfigChangeRequireRestart(previousSnapshot) + expect(result).toBe(false) }) - }) - describe("isConfigured", () => { - it("should validate OpenAI configuration correctly", async () => { + it("should return true when provider changes while enabled", async () => { + // Initial state: enabled with openai mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai", - }) - setupSecretMocks({ - codeIndexOpenAiKey: "test-key", - codeIndexQdrantApiKey: "test-key", + codebaseIndexEmbedderProvider: "ollama", + codebaseIndexOllamaBaseUrl: "http://localhost:11434", + codebaseIndexQdrantUrl: "http://localhost:6333", }) + mockContextProxy.getSecret.mockReturnValue(undefined) + configManager = new CodeIndexConfigManager(mockContextProxy) - await configManager.loadConfiguration() - expect(configManager.isFeatureConfigured).toBe(true) + const previousSnapshot: PreviousConfigSnapshot = { + enabled: true, + configured: true, + embedderProvider: "openai", + openAiKey: "test-key", + qdrantUrl: "http://localhost:6333", + } + + const result = configManager.doesConfigChangeRequireRestart(previousSnapshot) + expect(result).toBe(true) }) - it("should validate Ollama configuration correctly", async () => { + it("should return false when provider changes while disabled", async () => { + // Initial state: disabled with openai mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEnabled: false, codebaseIndexEmbedderProvider: "ollama", - codebaseIndexEmbedderBaseUrl: "http://ollama.local", }) + mockContextProxy.getSecret.mockReturnValue(undefined) + configManager = new CodeIndexConfigManager(mockContextProxy) - await configManager.loadConfiguration() - expect(configManager.isFeatureConfigured).toBe(true) - }) - - it("should validate OpenAI Compatible configuration correctly", async () => { - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", - } - } - return undefined - }) - setupSecretMocks({ - codebaseIndexOpenAiCompatibleApiKey: "test-api-key", - codeIndexQdrantApiKey: "test-key", - }) + const previousSnapshot: PreviousConfigSnapshot = { + enabled: false, + configured: false, + embedderProvider: "openai", + } - await configManager.loadConfiguration() - expect(configManager.isFeatureConfigured).toBe(true) + // Provider changed but feature is disabled + const result = configManager.doesConfigChangeRequireRestart(previousSnapshot) + expect(result).toBe(false) }) + }) - it("should return false when OpenAI Compatible base URL is missing", async () => { - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - } - } - if (key === "codebaseIndexOpenAiCompatibleBaseUrl") return "" - return undefined - }) - setupSecretMocks({ - codebaseIndexOpenAiCompatibleApiKey: "test-api-key", - }) - - await configManager.loadConfiguration() - expect(configManager.isFeatureConfigured).toBe(false) - }) + describe("loadConfiguration", () => { + it("should load configuration and return proper structure", async () => { + const mockConfigValues = { + codebaseIndexEnabled: true, + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-ada-002", + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexSearchMinScore: 0.5, + codebaseIndexSearchMaxResults: 20, + } - it("should return false when OpenAI Compatible API key is missing", async () => { - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "openai-compatible", - } - } - if (key === "codebaseIndexOpenAiCompatibleBaseUrl") return "https://api.example.com/v1" + mockContextProxy.getGlobalState.mockReturnValue(mockConfigValues) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + if (key === "codeIndexQdrantApiKey") return "qdrant-key" return undefined }) - setupSecretMocks({ - codebaseIndexOpenAiCompatibleApiKey: "", - }) - await configManager.loadConfiguration() - expect(configManager.isFeatureConfigured).toBe(false) - }) + const result = await configManager.loadConfiguration() - it("should validate Gemini configuration correctly", async () => { - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "gemini", - } - } - return undefined - }) - mockContextProxy.getSecret.mockImplementation((key: string) => { - if (key === "codebaseIndexGeminiApiKey") return "test-gemini-key" - return undefined - }) + // Verify the structure + expect(result).toHaveProperty("configSnapshot") + expect(result).toHaveProperty("currentConfig") + expect(result).toHaveProperty("requiresRestart") - await configManager.loadConfiguration() - expect(configManager.isFeatureConfigured).toBe(true) + // Verify current config reflects loaded values + expect(result.currentConfig.embedderProvider).toBe("openai") + expect(result.currentConfig.isConfigured).toBe(true) }) - it("should return false when Gemini API key is missing", async () => { - mockContextProxy.getGlobalState.mockImplementation((key: string) => { - if (key === "codebaseIndexConfig") { - return { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", - codebaseIndexEmbedderProvider: "gemini", - } - } - return undefined - }) - mockContextProxy.getSecret.mockImplementation((key: string) => { - if (key === "codebaseIndexGeminiApiKey") return "" - return undefined + it("should detect restart requirement when configuration changes", async () => { + // Initial state: disabled + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: false, + codebaseIndexEmbedderProvider: "openai", + codebaseIndexQdrantUrl: "http://localhost:6333", }) + mockContextProxy.getSecret.mockReturnValue(undefined) + configManager = new CodeIndexConfigManager(mockContextProxy) + // Get initial state await configManager.loadConfiguration() - expect(configManager.isFeatureConfigured).toBe(false) - }) - it("should return false when required values are missing", async () => { + // Change to enabled with proper configuration mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexEmbedderProvider: "openai", + codebaseIndexQdrantUrl: "http://localhost:6333", + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined }) - await configManager.loadConfiguration() - expect(configManager.isFeatureConfigured).toBe(false) + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) }) }) - describe("getter properties", () => { - beforeEach(async () => { + describe("getConfig", () => { + it("should return the current configuration", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-large", + codebaseIndexQdrantUrl: "http://localhost:6333", }) - setupSecretMocks({ - codeIndexOpenAiKey: "test-openai-key", - codeIndexQdrantApiKey: "test-qdrant-key", + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined }) - await configManager.loadConfiguration() - }) - - it("should return correct configuration via getConfig", () => { + configManager = new CodeIndexConfigManager(mockContextProxy) const config = configManager.getConfig() - expect(config).toEqual({ - isConfigured: true, - embedderProvider: "openai", - modelId: "text-embedding-3-large", - openAiOptions: { openAiNativeApiKey: "test-openai-key" }, - ollamaOptions: { ollamaBaseUrl: undefined }, - geminiOptions: undefined, - openAiCompatibleOptions: undefined, - qdrantUrl: "http://qdrant.local", - qdrantApiKey: "test-qdrant-key", - searchMinScore: 0.4, - searchMaxResults: 50, - }) - }) - - it("should return correct feature enabled state", () => { - expect(configManager.isFeatureEnabled).toBe(true) - }) - it("should return correct embedder provider", () => { - expect(configManager.currentEmbedderProvider).toBe("openai") - }) - - it("should return correct Qdrant configuration", () => { - expect(configManager.qdrantConfig).toEqual({ - url: "http://qdrant.local", - apiKey: "test-qdrant-key", - }) - }) - - it("should return correct model ID", () => { - expect(configManager.currentModelId).toBe("text-embedding-3-large") + expect(config).toHaveProperty("isConfigured") + expect(config).toHaveProperty("embedderProvider") + expect(config.embedderProvider).toBe("openai") }) }) - describe("initialization and restart prevention", () => { - it("should not require restart when configuration hasn't changed between calls", async () => { - // Setup initial configuration - start with enabled and configured to avoid initial transition restart + describe("isConfigured", () => { + it("should return true when OpenAI provider is properly configured", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexQdrantUrl: "http://localhost:6333", }) - setupSecretMocks({ - codeIndexOpenAiKey: "test-key", + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined }) - // First load - this will initialize the config manager with current state - await configManager.loadConfiguration() - - // Second load with same configuration - should not require restart - const secondResult = await configManager.loadConfiguration() - expect(secondResult.requiresRestart).toBe(false) + configManager = new CodeIndexConfigManager(mockContextProxy) + expect(configManager.isConfigured()).toBe(true) }) - it("should properly initialize with current config to prevent false restarts", async () => { - // Setup configuration + it("should return false when OpenAI provider is missing API key", () => { mockContextProxy.getGlobalState.mockReturnValue({ - codebaseIndexEnabled: true, // Always enabled now - codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEnabled: true, codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexQdrantUrl: "http://localhost:6333", }) - setupSecretMocks({ - codeIndexOpenAiKey: "test-key", - }) - - // Create a new config manager (simulating what happens in CodeIndexManager.initialize) - const newConfigManager = new CodeIndexConfigManager(mockContextProxy) + mockContextProxy.getSecret.mockReturnValue(undefined) - // Load configuration - should not require restart since the manager should be initialized with current config - const result = await newConfigManager.loadConfiguration() - expect(result.requiresRestart).toBe(false) + configManager = new CodeIndexConfigManager(mockContextProxy) + expect(configManager.isConfigured()).toBe(false) }) - it("should not require restart when settings are saved but code indexing config unchanged", async () => { - // This test simulates the scenario where handleSettingsChange() is called - // but code indexing settings haven't actually changed + it("should return true when Ollama provider is properly configured", () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderBaseUrl: "http://localhost:11434", + codebaseIndexQdrantUrl: "http://localhost:6333", + }) + mockContextProxy.getSecret.mockReturnValue(undefined) - // Setup initial state - enabled and configured + configManager = new CodeIndexConfigManager(mockContextProxy) + expect(configManager.isConfigured()).toBe(true) + }) + + it("should return false when Qdrant URL is missing", () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://qdrant.local", codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderModelId: "text-embedding-3-small", }) - setupSecretMocks({ - codeIndexOpenAiKey: "test-key", + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined }) - // First load to establish baseline - await configManager.loadConfiguration() - - // Simulate external settings change where code indexing config hasn't changed - // (this is what happens when other settings are saved) - const result = await configManager.loadConfiguration() - expect(result.requiresRestart).toBe(false) + configManager = new CodeIndexConfigManager(mockContextProxy) + expect(configManager.isConfigured()).toBe(false) }) }) }) diff --git a/src/services/code-index/config-manager.ts b/src/services/code-index/config-manager.ts index 245621a1bd9..f022aec7806 100644 --- a/src/services/code-index/config-manager.ts +++ b/src/services/code-index/config-manager.ts @@ -10,6 +10,7 @@ import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from ".. * Handles loading, validating, and providing access to configuration values. */ export class CodeIndexConfigManager { + private codebaseIndexEnabled: boolean = true private embedderProvider: EmbedderProvider = "openai" private modelId?: string private modelDimension?: number @@ -68,7 +69,7 @@ export class CodeIndexConfigManager { const geminiApiKey = this.contextProxy?.getSecret("codebaseIndexGeminiApiKey") ?? "" // Update instance variables with configuration - // Note: codebaseIndexEnabled is no longer used as the feature is always enabled + this.codebaseIndexEnabled = codebaseIndexEnabled ?? true this.qdrantUrl = codebaseIndexQdrantUrl this.qdrantApiKey = qdrantApiKey ?? "" this.searchMinScore = codebaseIndexSearchMinScore @@ -142,7 +143,7 @@ export class CodeIndexConfigManager { }> { // Capture the ACTUAL previous state before loading new configuration const previousConfigSnapshot: PreviousConfigSnapshot = { - enabled: true, // Feature is always enabled + enabled: this.codebaseIndexEnabled, configured: this.isConfigured(), embedderProvider: this.embedderProvider, modelId: this.modelId, @@ -243,19 +244,26 @@ export class CodeIndexConfigManager { const prevQdrantUrl = prev?.qdrantUrl ?? "" const prevQdrantApiKey = prev?.qdrantApiKey ?? "" - // 1. Transition from unconfigured to configured - // Since the feature is always enabled, we only check configuration status - if (!prevConfigured && nowConfigured) { + // 1. Transition from disabled/unconfigured to enabled/configured + if ((!prevEnabled || !prevConfigured) && this.codebaseIndexEnabled && nowConfigured) { + return true + } + + // 2. Transition from enabled to disabled + if (prevEnabled && !this.codebaseIndexEnabled) { return true } // 3. If wasn't ready before and isn't ready now, no restart needed - if (!prevConfigured && !nowConfigured) { + if ((!prevEnabled || !prevConfigured) && (!this.codebaseIndexEnabled || !nowConfigured)) { return false } // 4. CRITICAL CHANGES - Always restart for these - // Since feature is always enabled, we always check for critical changes + // Only check for critical changes if feature is enabled + if (!this.codebaseIndexEnabled) { + return false + } // Provider change if (prevProvider !== this.embedderProvider) { @@ -354,7 +362,7 @@ export class CodeIndexConfigManager { * Gets whether the code indexing feature is enabled */ public get isFeatureEnabled(): boolean { - return true + return this.codebaseIndexEnabled } /** diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index 6fa86b4e4fb..bbc96ced15c 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -303,6 +303,17 @@ export class CodeIndexManager { const isFeatureEnabled = this.isFeatureEnabled const isFeatureConfigured = this.isFeatureConfigured + // If feature is disabled, stop the service + if (!isFeatureEnabled) { + // Stop the orchestrator if it exists + if (this._orchestrator) { + this._orchestrator.stopWatcher() + } + // Set state to indicate service is disabled + this._stateManager.setSystemState("Standby", "Code indexing is disabled") + return + } + if (requiresRestart && isFeatureEnabled && isFeatureConfigured) { try { // Recreate services with new configuration diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index 1243b7efa2c..30d78a360a8 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -7,6 +7,7 @@ import { VSCodeDropdown, VSCodeOption, VSCodeLink, + VSCodeCheckbox, } from "@vscode/webview-ui-toolkit/react" import * as ProgressPrimitive from "@radix-ui/react-progress" import { vscode } from "@src/utils/vscode" @@ -213,6 +214,10 @@ export const CodeIndexPopover: React.FC = ({ } }, [open]) + // Use a ref to capture current settings for the save handler + const currentSettingsRef = useRef(currentSettings) + currentSettingsRef.current = currentSettings + // Listen for indexing status updates and save responses useEffect(() => { const handleMessage = (event: MessageEvent) => { @@ -227,9 +232,12 @@ export const CodeIndexPopover: React.FC = ({ } else if (event.data.type === "codeIndexSettingsSaved") { if (event.data.success) { setSaveStatus("saved") - // Don't update initial settings here - wait for the secret status response - // Request updated secret status after save - vscode.postMessage({ type: "requestCodeIndexSecretStatus" }) + // Update initial settings to match current settings after successful save + // This ensures hasUnsavedChanges becomes false + const savedSettings = { ...currentSettingsRef.current } + setInitialSettings(savedSettings) + // Don't request secret status immediately after save to avoid race conditions + // The secret status will be requested on next popover open // Reset status after 3 seconds setTimeout(() => { setSaveStatus("idle") @@ -284,14 +292,18 @@ export const CodeIndexPopover: React.FC = ({ return updated } - setCurrentSettings(updateWithSecrets) - setInitialSettings(updateWithSecrets) + // Only update settings if we're not in the middle of saving or just saved + // This prevents overwriting user changes after a save + if (saveStatus === "idle") { + setCurrentSettings(updateWithSecrets) + setInitialSettings(updateWithSecrets) + } } } window.addEventListener("message", handleMessage) return () => window.removeEventListener("message", handleMessage) - }, []) + }, [saveStatus]) // Generic comparison function that detects changes between initial and current settings const hasUnsavedChanges = useMemo(() => { @@ -494,6 +506,20 @@ export const CodeIndexPopover: React.FC = ({
+ {/* Enable/Disable Toggle */} +
+
+ updateSetting("codebaseIndexEnabled", e.target.checked)}> + {t("settings:codeIndex.enableLabel")} + + + + +
+
+ {/* Status Section */}

{t("settings:codeIndex.statusTitle")}

@@ -1049,44 +1075,46 @@ export const CodeIndexPopover: React.FC = ({ {/* Action Buttons */}
- {(indexingStatus.systemStatus === "Error" || - indexingStatus.systemStatus === "Standby") && ( - vscode.postMessage({ type: "startIndexing" })} - disabled={saveStatus === "saving" || hasUnsavedChanges}> - {t("settings:codeIndex.startIndexingButton")} - - )} - - {(indexingStatus.systemStatus === "Indexed" || - indexingStatus.systemStatus === "Error") && ( - - - - {t("settings:codeIndex.clearIndexDataButton")} - - - - - - {t("settings:codeIndex.clearDataDialog.title")} - - - {t("settings:codeIndex.clearDataDialog.description")} - - - - - {t("settings:codeIndex.clearDataDialog.cancelButton")} - - vscode.postMessage({ type: "clearIndexData" })}> - {t("settings:codeIndex.clearDataDialog.confirmButton")} - - - - - )} + {currentSettings.codebaseIndexEnabled && + (indexingStatus.systemStatus === "Error" || + indexingStatus.systemStatus === "Standby") && ( + vscode.postMessage({ type: "startIndexing" })} + disabled={saveStatus === "saving" || hasUnsavedChanges}> + {t("settings:codeIndex.startIndexingButton")} + + )} + + {currentSettings.codebaseIndexEnabled && + (indexingStatus.systemStatus === "Indexed" || + indexingStatus.systemStatus === "Error") && ( + + + + {t("settings:codeIndex.clearIndexDataButton")} + + + + + + {t("settings:codeIndex.clearDataDialog.title")} + + + {t("settings:codeIndex.clearDataDialog.description")} + + + + + {t("settings:codeIndex.clearDataDialog.cancelButton")} + + vscode.postMessage({ type: "clearIndexData" })}> + {t("settings:codeIndex.clearDataDialog.confirmButton")} + + + + + )}
Indexació de codi és una característica experimental que crea un índex de cerca semàntica del vostre projecte utilitzant embeddings d'IA. Això permet a Roo Code entendre millor i navegar per grans bases de codi trobant codi rellevant basat en significat en lloc de només paraules clau.", + "enableDescription": "Habilita la indexació de codi per millorar la cerca i la comprensió del context", "providerLabel": "Proveïdor d'embeddings", "selectProviderPlaceholder": "Seleccionar proveïdor", "openaiProvider": "OpenAI", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 8d57652ba2d..760541a5cd2 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -41,7 +41,7 @@ "description": "Konfiguriere Codebase-Indexierungseinstellungen, um semantische Suche in deinem Projekt zu aktivieren. <0>Mehr erfahren", "statusTitle": "Status", "enableLabel": "Codebase-Indexierung aktivieren", - "enableDescription": "<0>Codebase-Indexierung ist eine experimentelle Funktion, die einen semantischen Suchindex deines Projekts mit KI-Embeddings erstellt. Dies ermöglicht es Roo Code, große Codebasen besser zu verstehen und zu navigieren, indem relevanter Code basierend auf Bedeutung statt nur Schlüsselwörtern gefunden wird.", + "enableDescription": "Aktiviere die Code-Indizierung für eine verbesserte Suche und ein besseres Kontextverständnis", "settingsTitle": "Indexierungseinstellungen", "disabledMessage": "Codebase-Indexierung ist derzeit deaktiviert. Aktiviere sie in den globalen Einstellungen, um Indexierungsoptionen zu konfigurieren.", "providerLabel": "Embeddings-Anbieter", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index da40058b00d..c1b63fbb66f 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -41,7 +41,7 @@ "description": "Configure codebase indexing settings to enable semantic search of your project. <0>Learn more", "statusTitle": "Status", "enableLabel": "Enable Codebase Indexing", - "enableDescription": "<0>Codebase Indexing is an experimental feature that creates a semantic search index of your project using AI embeddings. This enables Roo Code to better understand and navigate large codebases by finding relevant code based on meaning rather than just keywords.", + "enableDescription": "Enable code indexing for improved search and context understanding", "settingsTitle": "Indexing Settings", "disabledMessage": "Codebase indexing is currently disabled. Enable it in the global settings to configure indexing options.", "providerLabel": "Embeddings Provider", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index b91d0e055f5..f7e9e8f1303 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -41,7 +41,7 @@ "description": "Configura los ajustes de indexación de código para habilitar búsqueda semántica en tu proyecto. <0>Más información", "statusTitle": "Estado", "enableLabel": "Habilitar indexación de código", - "enableDescription": "<0>La indexación de código es una función experimental que crea un índice de búsqueda semántica de tu proyecto usando embeddings de IA. Esto permite a Roo Code entender mejor y navegar grandes bases de código encontrando código relevante basado en significado en lugar de solo palabras clave.", + "enableDescription": "Habilita la indexación de código para mejorar la búsqueda y la comprensión del contexto", "settingsTitle": "Configuración de indexación", "disabledMessage": "La indexación de código está actualmente deshabilitada. Habilítala en la configuración global para configurar las opciones de indexación.", "providerLabel": "Proveedor de embeddings", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index d4940567c6e..e1d236bd790 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -41,7 +41,7 @@ "description": "Configurez les paramètres d'indexation de la base de code pour activer la recherche sémantique dans votre projet. <0>En savoir plus", "statusTitle": "Statut", "enableLabel": "Activer l'indexation de la base de code", - "enableDescription": "<0>L'indexation de la base de code est une fonctionnalité expérimentale qui crée un index de recherche sémantique de votre projet en utilisant des embeddings IA. Cela permet à Roo Code de mieux comprendre et naviguer dans de grandes bases de code en trouvant du code pertinent basé sur le sens plutôt que seulement sur des mots-clés.", + "enableDescription": "Activer l'indexation du code pour une recherche et une compréhension du contexte améliorées", "settingsTitle": "Paramètres d'indexation", "disabledMessage": "L'indexation de la base de code est actuellement désactivée. Activez-la dans les paramètres globaux pour configurer les options d'indexation.", "providerLabel": "Fournisseur d'embeddings", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 8665afc9909..c2b8d8593a2 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -39,7 +39,7 @@ "codeIndex": { "title": "कोडबेस इंडेक्सिंग", "enableLabel": "कोडबेस इंडेक्सिंग सक्षम करें", - "enableDescription": "<0>कोडबेस इंडेक्सिंग एक प्रयोगात्मक सुविधा है जो AI एम्बेडिंग का उपयोग करके आपके प्रोजेक्ट का सिमेंटिक सर्च इंडेक्स बनाती है। यह Roo Code को केवल कीवर्ड के बजाय अर्थ के आधार पर संबंधित कोड खोजकर बड़े कोडबेस को बेहतर तरीके से समझने और नेविगेट करने में सक्षम बनाता है।", + "enableDescription": "बेहतर खोज और संदर्भ समझने के लिए कोड इंडेक्सिंग सक्षम करें", "providerLabel": "एम्बेडिंग प्रदाता", "selectProviderPlaceholder": "प्रदाता चुनें", "openaiProvider": "OpenAI", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 199751e384e..24bba83fc71 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -39,7 +39,7 @@ "codeIndex": { "title": "Pengindeksan Codebase", "enableLabel": "Aktifkan Pengindeksan Codebase", - "enableDescription": "<0>Pengindeksan Codebase adalah fitur eksperimental yang membuat indeks pencarian semantik dari proyek kamu menggunakan AI embeddings. Ini memungkinkan Roo Code untuk lebih memahami dan menavigasi codebase besar dengan menemukan kode yang relevan berdasarkan makna daripada hanya kata kunci.", + "enableDescription": "Aktifkan pengindeksan kode untuk pencarian dan pemahaman konteks yang lebih baik", "providerLabel": "Provider Embeddings", "selectProviderPlaceholder": "Pilih provider", "openaiProvider": "OpenAI", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 48a4c8e4db4..7b1e26acc75 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -39,7 +39,7 @@ "codeIndex": { "title": "Indicizzazione del codice", "enableLabel": "Abilita indicizzazione del codice", - "enableDescription": "<0>L'indicizzazione del codice è una funzionalità sperimentale che crea un indice di ricerca semantica del tuo progetto utilizzando embedding AI. Questo permette a Roo Code di comprendere meglio e navigare grandi basi di codice trovando codice rilevante basato sul significato piuttosto che solo su parole chiave.", + "enableDescription": "Abilita l'indicizzazione del codice per una ricerca e una comprensione del contesto migliorate", "providerLabel": "Fornitore di embedding", "selectProviderPlaceholder": "Seleziona fornitore", "openaiProvider": "OpenAI", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 397ce67f62c..5114719bb20 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -39,7 +39,7 @@ "codeIndex": { "title": "コードベースのインデックス作成", "enableLabel": "コードベースのインデックス作成を有効化", - "enableDescription": "<0>コードベースのインデックス作成は、AIエンベディングを使用してプロジェクトのセマンティック検索インデックスを作成する実験的機能です。これにより、Roo Codeは単なるキーワードではなく意味に基づいて関連するコードを見つけることで、大規模なコードベースをより良く理解し、ナビゲートできるようになります。", + "enableDescription": "コードのインデックス作成を有効にして、検索とコンテキストの理解を向上させます", "providerLabel": "埋め込みプロバイダー", "selectProviderPlaceholder": "プロバイダーを選択", "openaiProvider": "OpenAI", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 746cea65ad4..59bedc403f0 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -39,7 +39,7 @@ "codeIndex": { "title": "코드베이스 인덱싱", "enableLabel": "코드베이스 인덱싱 활성화", - "enableDescription": "<0>코드베이스 인덱싱은 AI 임베딩을 사용하여 프로젝트의 의미론적 검색 인덱스를 생성하는 실험적 기능입니다. 이를 통해 Roo Code는 단순한 키워드가 아닌 의미를 기반으로 관련 코드를 찾아 대규모 코드베이스를 더 잘 이해하고 탐색할 수 있습니다.", + "enableDescription": "향상된 검색 및 컨텍스트 이해를 위해 코드 인덱싱 활성화", "providerLabel": "임베딩 제공자", "selectProviderPlaceholder": "제공자 선택", "openaiProvider": "OpenAI", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index c5315205cae..f3d6ed3b962 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -39,7 +39,7 @@ "codeIndex": { "title": "Codebase indexering", "enableLabel": "Codebase indexering inschakelen", - "enableDescription": "<0>Codebase indexering is een experimentele functie die een semantische zoekindex van je project creëert met behulp van AI-embeddings. Dit stelt Roo Code in staat om grote codebases beter te begrijpen en te navigeren door relevante code te vinden op basis van betekenis in plaats van alleen trefwoorden.", + "enableDescription": "Code-indexering inschakelen voor verbeterde zoekresultaten en contextbegrip", "providerLabel": "Embeddings provider", "selectProviderPlaceholder": "Selecteer provider", "openaiProvider": "OpenAI", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 1829c23ba4d..edf9a6bfb31 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -39,7 +39,7 @@ "codeIndex": { "title": "Indeksowanie kodu", "enableLabel": "Włącz indeksowanie kodu", - "enableDescription": "<0>Indeksowanie kodu to eksperymentalna funkcja, która tworzy semantyczny indeks wyszukiwania Twojego projektu przy użyciu osadzeń AI. Umożliwia to Roo Code lepsze zrozumienie i nawigację po dużych bazach kodu poprzez znajdowanie odpowiedniego kodu na podstawie znaczenia, a nie tylko słów kluczowych.", + "enableDescription": "Włącz indeksowanie kodu, aby poprawić wyszukiwanie i zrozumienie kontekstu", "providerLabel": "Dostawca osadzania", "selectProviderPlaceholder": "Wybierz dostawcę", "openaiProvider": "OpenAI", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 6e46cc8c3e9..6b7fd29a142 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -39,7 +39,7 @@ "codeIndex": { "title": "Indexação de Código", "enableLabel": "Ativar Indexação de Código", - "enableDescription": "<0>Indexação de Código é um recurso experimental que cria um índice de busca semântica do seu projeto usando embeddings de IA. Isso permite ao Roo Code entender melhor e navegar grandes bases de código encontrando código relevante baseado em significado ao invés de apenas palavras-chave.", + "enableDescription": "Ative a indexação de código para pesquisa e compreensão de contexto aprimoradas", "providerLabel": "Provedor de Embeddings", "selectProviderPlaceholder": "Selecionar provedor", "openaiProvider": "OpenAI", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index e0a897c2e5f..c3f410a1eaf 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -39,7 +39,7 @@ "codeIndex": { "title": "Индексация кодовой базы", "enableLabel": "Включить индексацию кодовой базы", - "enableDescription": "<0>Индексация кодовой базы — это экспериментальная функция, которая создает семантический поисковый индекс вашего проекта с использованием ИИ-эмбеддингов. Это позволяет Roo Code лучше понимать и навигировать по большим кодовым базам, находя релевантный код на основе смысла, а не только ключевых слов.", + "enableDescription": "Включите индексацию кода для улучшения поиска и понимания контекста", "providerLabel": "Провайдер эмбеддингов", "selectProviderPlaceholder": "Выберите провайдера", "openaiProvider": "OpenAI", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 486991ec0dd..bde23ae5f45 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -39,7 +39,7 @@ "codeIndex": { "title": "Kod Tabanı İndeksleme", "enableLabel": "Kod Tabanı İndekslemeyi Etkinleştir", - "enableDescription": "<0>Kod Tabanı İndeksleme, AI gömme teknolojisini kullanarak projenizin semantik arama indeksini oluşturan deneysel bir özelliktir. Bu, Roo Code'un sadece anahtar kelimeler yerine anlam temelinde ilgili kodu bularak büyük kod tabanlarını daha iyi anlamasını ve gezinmesini sağlar.", + "enableDescription": "Geliştirilmiş arama ve bağlam anlayışı için kod indekslemeyi etkinleştirin", "providerLabel": "Gömme Sağlayıcısı", "selectProviderPlaceholder": "Sağlayıcı seç", "openaiProvider": "OpenAI", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index e31355b4039..bf3948d4387 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -39,7 +39,7 @@ "codeIndex": { "title": "Lập chỉ mục mã nguồn", "enableLabel": "Bật lập chỉ mục mã nguồn", - "enableDescription": "<0>Lập chỉ mục mã nguồn là một tính năng thử nghiệm tạo ra chỉ mục tìm kiếm ngữ nghĩa cho dự án của bạn bằng cách sử dụng AI embeddings. Điều này cho phép Roo Code hiểu rõ hơn và điều hướng các codebase lớn bằng cách tìm mã liên quan dựa trên ý nghĩa thay vì chỉ từ khóa.", + "enableDescription": "Bật lập chỉ mục mã để cải thiện tìm kiếm và sự hiểu biết về ngữ cảnh", "providerLabel": "Nhà cung cấp nhúng", "selectProviderPlaceholder": "Chọn nhà cung cấp", "openaiProvider": "OpenAI", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 4b46b9af0a4..24c8a889542 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -41,7 +41,7 @@ "description": "配置代码库索引设置以启用项目的语义搜索。<0>了解更多", "statusTitle": "状态", "enableLabel": "启用代码库索引", - "enableDescription": "<0>代码库索引是一个实验性功能,使用 AI 嵌入为您的项目创建语义搜索索引。这使 Roo Code 能够通过基于含义而非仅仅关键词来查找相关代码,从而更好地理解和导航大型代码库。", + "enableDescription": "启用代码索引以改进搜索和上下文理解", "settingsTitle": "索引设置", "disabledMessage": "代码库索引当前已禁用。在全局设置中启用它以配置索引选项。", "providerLabel": "嵌入提供商", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 3e35097b1ec..ad36be57a89 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -39,7 +39,7 @@ "codeIndex": { "title": "程式碼庫索引", "enableLabel": "啟用程式碼庫索引", - "enableDescription": "<0>程式碼庫索引是一個實驗性功能,使用 AI 嵌入為您的專案建立語義搜尋索引。這使 Roo Code 能夠透過基於含義而非僅僅關鍵詞來尋找相關程式碼,從而更好地理解和導覽大型程式碼庫。", + "enableDescription": "啟用程式碼索引以改進搜尋和上下文理解", "providerLabel": "嵌入提供者", "selectProviderPlaceholder": "選擇提供者", "openaiProvider": "OpenAI", From 4270027578d6f0b2ff9a5c4abc975fc73d5a78e9 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Fri, 11 Jul 2025 10:11:44 -0500 Subject: [PATCH 2/4] fix: add VSCodeCheckbox mock to CodeIndexPopover validation tests --- .../chat/__tests__/CodeIndexPopover.validation.spec.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/webview-ui/src/components/chat/__tests__/CodeIndexPopover.validation.spec.tsx b/webview-ui/src/components/chat/__tests__/CodeIndexPopover.validation.spec.tsx index 4dd89288ea1..60399865f3a 100644 --- a/webview-ui/src/components/chat/__tests__/CodeIndexPopover.validation.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CodeIndexPopover.validation.spec.tsx @@ -103,6 +103,12 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ {children} ), + VSCodeCheckbox: ({ checked, onChange, children, ...rest }: any) => ( + + ), })) // Helper function to simulate input on form elements From c904a089cd802da4df3904f67776cd1a604433da Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Sat, 12 Jul 2025 10:43:27 -0500 Subject: [PATCH 3/4] fix(webview): resolve API key deletion and toggle state bugs This commit fixes two critical issues in the CodeIndexPopover component: 1. Resolves a bug where saved API keys (displayed as placeholders) were cleared from input fields when settings were saved. The logic now correctly preserves existing secrets on the backend. 2. Ensures the toggle state is properly persisted across save operations, providing reliable control over the indexing action buttons. --- src/services/code-index/manager.ts | 6 +++ .../src/components/chat/CodeIndexPopover.tsx | 39 +++++++++++-------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index bbc96ced15c..8a330e7952c 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -316,6 +316,12 @@ export class CodeIndexManager { if (requiresRestart && isFeatureEnabled && isFeatureConfigured) { try { + // Ensure cacheManager is initialized before recreating services + if (!this._cacheManager) { + this._cacheManager = new CacheManager(this.context, this.workspacePath) + await this._cacheManager.initialize() + } + // Recreate services with new configuration await this._recreateServices() } catch (error) { diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index 30d78a360a8..84703bcae26 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -73,6 +73,7 @@ interface LocalCodeIndexSettings { // Validation schema for codebase index settings const createValidationSchema = (provider: EmbedderProvider, t: any) => { const baseSchema = z.object({ + codebaseIndexEnabled: z.boolean(), codebaseIndexQdrantUrl: z .string() .min(1, t("settings:codeIndex.validation.qdrantUrlRequired")) @@ -236,20 +237,20 @@ export const CodeIndexPopover: React.FC = ({ // This ensures hasUnsavedChanges becomes false const savedSettings = { ...currentSettingsRef.current } setInitialSettings(savedSettings) - // Don't request secret status immediately after save to avoid race conditions - // The secret status will be requested on next popover open - // Reset status after 3 seconds - setTimeout(() => { - setSaveStatus("idle") - }, 3000) + // Also update current settings to maintain consistency + setCurrentSettings(savedSettings) + // Request secret status to ensure we have the latest state + // This is important to maintain placeholder display after save + + vscode.postMessage({ type: "requestCodeIndexSecretStatus" }) + + setSaveStatus("idle") } else { setSaveStatus("error") setSaveError(event.data.error || t("settings:codeIndex.saveError")) // Clear error message after 5 seconds - setTimeout(() => { - setSaveStatus("idle") - setSaveError(null) - }, 5000) + setSaveStatus("idle") + setSaveError(null) } } } @@ -292,9 +293,9 @@ export const CodeIndexPopover: React.FC = ({ return updated } - // Only update settings if we're not in the middle of saving or just saved - // This prevents overwriting user changes after a save - if (saveStatus === "idle") { + // Only update settings if we're not in the middle of saving + // After save is complete (saved status), we still want to update to maintain consistency + if (saveStatus === "idle" || saveStatus === "saved") { setCurrentSettings(updateWithSecrets) setInitialSettings(updateWithSecrets) } @@ -429,20 +430,26 @@ export const CodeIndexPopover: React.FC = ({ setSaveStatus("saving") setSaveError(null) - // Prepare settings to save - include all fields except secrets with placeholder values + // Prepare settings to save const settingsToSave: any = {} // Iterate through all current settings for (const [key, value] of Object.entries(currentSettings)) { - // Skip secret fields that still have placeholder value + // For secret fields with placeholder, don't send the placeholder + // but also don't send an empty string - just skip the field + // This tells the backend to keep the existing secret if (value === SECRET_PLACEHOLDER) { + // Skip sending placeholder values - backend will preserve existing secrets continue } - // Include all other fields + // Include all other fields, including empty strings (which clear secrets) settingsToSave[key] = value } + // Always include codebaseIndexEnabled to ensure it's persisted + settingsToSave.codebaseIndexEnabled = currentSettings.codebaseIndexEnabled + // Save settings to backend vscode.postMessage({ type: "saveCodeIndexSettingsAtomic", From 6cabfa0133fd4b8c1f2ed5d072160b462b29c711 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Sat, 12 Jul 2025 11:36:34 -0500 Subject: [PATCH 4/4] restore deleted tests --- .../__tests__/config-manager.spec.ts | 1279 ++++++++++++++++- 1 file changed, 1259 insertions(+), 20 deletions(-) diff --git a/src/services/code-index/__tests__/config-manager.spec.ts b/src/services/code-index/__tests__/config-manager.spec.ts index 5c9794f4ee5..6d0e59e827a 100644 --- a/src/services/code-index/__tests__/config-manager.spec.ts +++ b/src/services/code-index/__tests__/config-manager.spec.ts @@ -9,8 +9,8 @@ import { PreviousConfigSnapshot } from "../interfaces/config" vi.mock("../../../core/config/ContextProxy") describe("CodeIndexConfigManager", () => { - let configManager: CodeIndexConfigManager let mockContextProxy: any + let configManager: CodeIndexConfigManager beforeEach(() => { // Reset mocks @@ -19,15 +19,36 @@ describe("CodeIndexConfigManager", () => { // Setup mock ContextProxy mockContextProxy = { getGlobalState: vi.fn(), - updateGlobalState: vi.fn(), + getSecret: vi.fn().mockReturnValue(undefined), refreshSecrets: vi.fn().mockResolvedValue(undefined), - getSecret: vi.fn(), + updateGlobalState: vi.fn(), } - // Create a new instance for each test configManager = new CodeIndexConfigManager(mockContextProxy) }) + // Helper function to setup secret mocking + const setupSecretMocks = (secrets: Record) => { + // Mock sync secret access + mockContextProxy.getSecret.mockImplementation((key: string) => { + return secrets[key] || undefined + }) + + // Mock refreshSecrets to update the getSecret mock with new values + mockContextProxy.refreshSecrets.mockImplementation(async () => { + // In real implementation, this would refresh from VSCode storage + // For tests, we just keep the existing mock behavior + }) + } + + describe("constructor", () => { + it("should initialize with ContextProxy", () => { + expect(configManager).toBeDefined() + expect(configManager.isFeatureEnabled).toBe(true) + expect(configManager.currentEmbedderProvider).toBe("openai") + }) + }) + describe("isFeatureEnabled", () => { it("should return false when codebaseIndexEnabled is false", async () => { mockContextProxy.getGlobalState.mockReturnValue({ @@ -61,49 +82,1267 @@ describe("CodeIndexConfigManager", () => { }) }) + describe("loadConfiguration", () => { + it("should load default configuration when no state exists", async () => { + mockContextProxy.getGlobalState.mockReturnValue(undefined) + mockContextProxy.getSecret.mockReturnValue(undefined) + + const result = await configManager.loadConfiguration() + + expect(result.currentConfig).toEqual({ + isConfigured: false, + embedderProvider: "openai", + modelId: undefined, + openAiOptions: { openAiNativeApiKey: "" }, + ollamaOptions: { ollamaBaseUrl: "" }, + qdrantUrl: "http://localhost:6333", + qdrantApiKey: "", + searchMinScore: 0.4, + }) + expect(result.requiresRestart).toBe(false) + }) + + it("should load configuration from globalState and secrets", async () => { + const mockGlobalState = { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderBaseUrl: "", + codebaseIndexEmbedderModelId: "text-embedding-3-large", + } + mockContextProxy.getGlobalState.mockReturnValue(mockGlobalState) + + // Mock both sync and async secret access + setupSecretMocks({ + codeIndexOpenAiKey: "test-openai-key", + codeIndexQdrantApiKey: "test-qdrant-key", + }) + + const result = await configManager.loadConfiguration() + + expect(result.currentConfig).toEqual({ + isConfigured: true, + embedderProvider: "openai", + modelId: "text-embedding-3-large", + openAiOptions: { openAiNativeApiKey: "test-openai-key" }, + ollamaOptions: { ollamaBaseUrl: "" }, + qdrantUrl: "http://qdrant.local", + qdrantApiKey: "test-qdrant-key", + searchMinScore: 0.4, + }) + }) + + it("should load OpenAI Compatible configuration from globalState and secrets", async () => { + const mockGlobalState = { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderBaseUrl: "", + codebaseIndexEmbedderModelId: "text-embedding-3-large", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + } + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") return mockGlobalState + return undefined + }) + + setupSecretMocks({ + codeIndexQdrantApiKey: "test-qdrant-key", + codebaseIndexOpenAiCompatibleApiKey: "test-openai-compatible-key", + }) + + const result = await configManager.loadConfiguration() + + expect(result.currentConfig).toEqual({ + isConfigured: true, + embedderProvider: "openai-compatible", + modelId: "text-embedding-3-large", + openAiOptions: { openAiNativeApiKey: "" }, + ollamaOptions: { ollamaBaseUrl: "" }, + openAiCompatibleOptions: { + baseUrl: "https://api.example.com/v1", + apiKey: "test-openai-compatible-key", + }, + qdrantUrl: "http://qdrant.local", + qdrantApiKey: "test-qdrant-key", + searchMinScore: 0.4, + }) + }) + + it("should load OpenAI Compatible configuration with modelDimension from globalState", async () => { + const mockGlobalState = { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderBaseUrl: "", + codebaseIndexEmbedderModelId: "custom-model", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + codebaseIndexEmbedderModelDimension: 1024, + } + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") return mockGlobalState + return undefined + }) + setupSecretMocks({ + codeIndexQdrantApiKey: "test-qdrant-key", + codebaseIndexOpenAiCompatibleApiKey: "test-openai-compatible-key", + }) + + const result = await configManager.loadConfiguration() + + expect(result.currentConfig).toEqual({ + isConfigured: true, + embedderProvider: "openai-compatible", + modelId: "custom-model", + modelDimension: 1024, + openAiOptions: { openAiNativeApiKey: "" }, + ollamaOptions: { ollamaBaseUrl: "" }, + openAiCompatibleOptions: { + baseUrl: "https://api.example.com/v1", + apiKey: "test-openai-compatible-key", + }, + qdrantUrl: "http://qdrant.local", + qdrantApiKey: "test-qdrant-key", + searchMinScore: 0.4, + }) + }) + + it("should handle missing modelDimension for OpenAI Compatible configuration", async () => { + const mockGlobalState = { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderBaseUrl: "", + codebaseIndexEmbedderModelId: "custom-model", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + // modelDimension is not set + } + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") return mockGlobalState + return undefined + }) + setupSecretMocks({ + codeIndexQdrantApiKey: "test-qdrant-key", + codebaseIndexOpenAiCompatibleApiKey: "test-openai-compatible-key", + }) + + const result = await configManager.loadConfiguration() + + expect(result.currentConfig).toEqual({ + isConfigured: true, + embedderProvider: "openai-compatible", + modelId: "custom-model", + openAiOptions: { openAiNativeApiKey: "" }, + ollamaOptions: { ollamaBaseUrl: "" }, + openAiCompatibleOptions: { + baseUrl: "https://api.example.com/v1", + apiKey: "test-openai-compatible-key", + // modelDimension is undefined when not set + }, + qdrantUrl: "http://qdrant.local", + qdrantApiKey: "test-qdrant-key", + searchMinScore: 0.4, + }) + }) + + it("should handle invalid modelDimension type for OpenAI Compatible configuration", async () => { + const mockGlobalState = { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderBaseUrl: "", + codebaseIndexEmbedderModelId: "custom-model", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + codebaseIndexEmbedderModelDimension: "invalid-dimension", // Invalid type + } + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") return mockGlobalState + return undefined + }) + setupSecretMocks({ + codeIndexQdrantApiKey: "test-qdrant-key", + codebaseIndexOpenAiCompatibleApiKey: "test-openai-compatible-key", + }) + + const result = await configManager.loadConfiguration() + + expect(result.currentConfig).toEqual({ + isConfigured: true, + embedderProvider: "openai-compatible", + modelId: "custom-model", + modelDimension: undefined, // Invalid dimension is converted to undefined + openAiOptions: { openAiNativeApiKey: "" }, + ollamaOptions: { ollamaBaseUrl: "" }, + openAiCompatibleOptions: { + baseUrl: "https://api.example.com/v1", + apiKey: "test-openai-compatible-key", + }, + geminiOptions: undefined, + qdrantUrl: "http://qdrant.local", + qdrantApiKey: "test-qdrant-key", + searchMinScore: 0.4, + }) + }) + + it("should detect restart requirement when provider changes", async () => { + // Initial state - properly configured + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-large", + }) + setupSecretMocks({ + codeIndexOpenAiKey: "test-openai-key", + }) + + await configManager.loadConfiguration() + + // Change provider + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderBaseUrl: "http://ollama.local", + codebaseIndexEmbedderModelId: "nomic-embed-text", + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + it("should detect restart requirement when vector dimensions change", async () => { + // Initial state with text-embedding-3-small (1536D) + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + }) + setupSecretMocks({ + codeIndexOpenAiKey: "test-key", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + + // Change to text-embedding-3-large (3072D) + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-large", + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + it("should NOT require restart when models have same dimensions", async () => { + // Initial state with text-embedding-3-small (1536D) + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + }) + setupSecretMocks({ + codeIndexOpenAiKey: "test-key", + }) + + await configManager.loadConfiguration() + + // Change to text-embedding-ada-002 (also 1536D) + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-ada-002", + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(false) + }) + + it("should detect restart requirement when transitioning to enabled+configured", async () => { + // Initial state - enabled but not configured + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + }) + + await configManager.loadConfiguration() + + // Configure the feature + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + }) + setupSecretMocks({ + codeIndexOpenAiKey: "test-key", + codeIndexQdrantApiKey: "test-key", + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + describe("simplified restart detection", () => { + it("should detect restart requirement for API key changes", async () => { + // Initial state + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + }) + setupSecretMocks({ + codeIndexOpenAiKey: "old-key", + codeIndexQdrantApiKey: "old-key", + }) + + await configManager.loadConfiguration() + + // Change API key + setupSecretMocks({ + codeIndexOpenAiKey: "new-key", + codeIndexQdrantApiKey: "old-key", + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + it("should detect restart requirement for Qdrant URL changes", async () => { + // Initial state + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://old-qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + }) + setupSecretMocks({ + codeIndexOpenAiKey: "test-key", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + + // Change Qdrant URL + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://new-qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + it("should handle unknown model dimensions safely", async () => { + // Initial state with known model + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + }) + setupSecretMocks({ + codeIndexOpenAiKey: "test-key", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + + // Change to unknown model + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "unknown-model", + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + it("should handle Ollama configuration changes", async () => { + // Initial state + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderBaseUrl: "http://old-ollama.local", + codebaseIndexEmbedderModelId: "nomic-embed-text", + }) + + await configManager.loadConfiguration() + + // Change Ollama base URL + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderBaseUrl: "http://new-ollama.local", + codebaseIndexEmbedderModelId: "nomic-embed-text", + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + it("should handle OpenAI Compatible configuration changes", async () => { + // Initial state + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexOpenAiCompatibleBaseUrl: "https://old-api.example.com/v1", + } + } + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "old-api-key", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + + // Change OpenAI Compatible base URL + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexOpenAiCompatibleBaseUrl: "https://new-api.example.com/v1", + } + } + return undefined + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + it("should handle OpenAI Compatible API key changes", async () => { + // Initial state + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + } + } + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "old-api-key", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + + // Change OpenAI Compatible API key + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "new-api-key", + codeIndexQdrantApiKey: "test-key", + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + it("should handle OpenAI Compatible modelDimension changes", async () => { + // Initial state with modelDimension + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "custom-model", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + codebaseIndexEmbedderModelDimension: 1024, + } + } + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "test-api-key", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + + // Change modelDimension + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "custom-model", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + codebaseIndexEmbedderModelDimension: 2048, + } + } + return undefined + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + it("should not require restart when modelDimension remains the same", async () => { + // Initial state with modelDimension + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "custom-model", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + codebaseIndexEmbedderModelDimension: 1024, + } + } + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "test-api-key", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + + // Keep modelDimension the same, change unrelated setting + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "custom-model", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + codebaseIndexEmbedderModelDimension: 1024, + codebaseIndexSearchMinScore: 0.5, // Changed unrelated setting + } + } + return undefined + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(false) + }) + + it("should require restart when modelDimension is added", async () => { + // Initial state without modelDimension + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "custom-model", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + // modelDimension not set initially + } + } + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "test-api-key", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + + // Add modelDimension + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "custom-model", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + codebaseIndexEmbedderModelDimension: 1024, + } + } + return undefined + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + it("should require restart when modelDimension is removed", async () => { + // Initial state with modelDimension + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "custom-model", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + codebaseIndexEmbedderModelDimension: 1024, + } + } + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "test-api-key", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + + // Remove modelDimension + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "custom-model", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + // modelDimension removed + } + } + return undefined + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + it("should require restart when enabled and provider changes even if unconfigured", async () => { + // Initial state - enabled but not configured (missing API key) + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + }) + setupSecretMocks({}) + + await configManager.loadConfiguration() + + // Still enabled but change provider while remaining unconfigured + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderBaseUrl: "http://ollama.local", + }) + + const result = await configManager.loadConfiguration() + // Should require restart because provider changed while enabled + expect(result.requiresRestart).toBe(true) + }) + + it("should not require restart when unconfigured remains unconfigured", async () => { + // Initial state - enabled but unconfigured (missing API key) + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + }) + setupSecretMocks({}) + + await configManager.loadConfiguration() + + // Still unconfigured but change model + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-large", + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(false) + }) + + describe("currentSearchMinScore priority system", () => { + it("should return user-configured score when set", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexSearchMinScore: 0.8, // User setting + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined + }) + + await configManager.loadConfiguration() + expect(configManager.currentSearchMinScore).toBe(0.8) + }) + + it("should fall back to model-specific threshold when user setting is undefined", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderModelId: "nomic-embed-code", + // No codebaseIndexSearchMinScore - user hasn't configured it + }) + + await configManager.loadConfiguration() + // nomic-embed-code has a specific threshold of 0.15 + expect(configManager.currentSearchMinScore).toBe(0.15) + }) + + it("should fall back to default DEFAULT_SEARCH_MIN_SCORE when neither user setting nor model threshold exists", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "unknown-model", // Model not in profiles + // No codebaseIndexSearchMinScore + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined + }) + + await configManager.loadConfiguration() + // Should fall back to default DEFAULT_SEARCH_MIN_SCORE (0.4) + expect(configManager.currentSearchMinScore).toBe(0.4) + }) + + it("should respect user setting of 0 (edge case)", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderModelId: "nomic-embed-code", + codebaseIndexSearchMinScore: 0, // User explicitly sets 0 + }) + + await configManager.loadConfiguration() + // Should return 0, not fall back to model threshold (0.15) + expect(configManager.currentSearchMinScore).toBe(0) + }) + + it("should use model-specific threshold with openai-compatible provider", async () => { + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "nomic-embed-code", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + // No codebaseIndexSearchMinScore + } + } + return undefined + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codebaseIndexOpenAiCompatibleApiKey") return "test-api-key" + return undefined + }) + + await configManager.loadConfiguration() + // openai-compatible provider also has nomic-embed-code with 0.15 threshold + expect(configManager.currentSearchMinScore).toBe(0.15) + }) + + it("should use default model ID when modelId is not specified", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + // No modelId specified + // No codebaseIndexSearchMinScore + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined + }) + + await configManager.loadConfiguration() + // Should use default model (text-embedding-3-small) threshold (0.4) + expect(configManager.currentSearchMinScore).toBe(0.4) + }) + + it("should handle priority correctly: user > model > default", async () => { + // Test 1: User setting takes precedence + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderModelId: "nomic-embed-code", // Has 0.15 threshold + codebaseIndexSearchMinScore: 0.9, // User overrides + }) + + await configManager.loadConfiguration() + expect(configManager.currentSearchMinScore).toBe(0.9) // User setting wins + + // Test 2: Model threshold when no user setting + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderModelId: "nomic-embed-code", + // No user setting + }) + + const newManager = new CodeIndexConfigManager(mockContextProxy) + await newManager.loadConfiguration() + expect(newManager.currentSearchMinScore).toBe(0.15) // Model threshold + + // Test 3: Default when neither exists + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "custom-unknown-model", + // No user setting, unknown model + }) + + const anotherManager = new CodeIndexConfigManager(mockContextProxy) + await anotherManager.loadConfiguration() + expect(anotherManager.currentSearchMinScore).toBe(0.4) // Default + }) + }) + + describe("currentSearchMaxResults", () => { + it("should return user setting when provided, otherwise default", async () => { + // Test 1: User setting takes precedence + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexSearchMaxResults: 150, // User setting + }) + + await configManager.loadConfiguration() + expect(configManager.currentSearchMaxResults).toBe(150) // User setting + + // Test 2: Default when no user setting + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + // No user setting + }) + + const newManager = new CodeIndexConfigManager(mockContextProxy) + await newManager.loadConfiguration() + expect(newManager.currentSearchMaxResults).toBe(50) // Default (DEFAULT_MAX_SEARCH_RESULTS) + + // Test 3: Boundary values + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexSearchMaxResults: 10, // Minimum allowed + }) + + const minManager = new CodeIndexConfigManager(mockContextProxy) + await minManager.loadConfiguration() + expect(minManager.currentSearchMaxResults).toBe(10) + + // Test 4: Maximum value + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexSearchMaxResults: 200, // Maximum allowed + }) + + const maxManager = new CodeIndexConfigManager(mockContextProxy) + await maxManager.loadConfiguration() + expect(maxManager.currentSearchMaxResults).toBe(200) + }) + }) + }) + + describe("empty/missing API key handling", () => { + it("should not require restart when API keys are consistently empty", async () => { + // Initial state with no API keys (undefined from secrets) + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + }) + setupSecretMocks({}) + + await configManager.loadConfiguration() + + // Change an unrelated setting while keeping API keys empty + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexSearchMinScore: 0.5, // Changed unrelated setting + }) + + const result = await configManager.loadConfiguration() + // Should NOT require restart since API keys are consistently empty + expect(result.requiresRestart).toBe(false) + }) + + it("should not require restart when API keys transition from undefined to empty string", async () => { + // Initial state with undefined API keys + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, // Always enabled now + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + }) + setupSecretMocks({}) + + await configManager.loadConfiguration() + + // Change to empty string API keys (simulating what happens when secrets return "") + setupSecretMocks({ + codeIndexOpenAiKey: "", + codeIndexQdrantApiKey: "", + }) + + const result = await configManager.loadConfiguration() + // Should NOT require restart since undefined and "" are both "empty" + expect(result.requiresRestart).toBe(false) + }) + + it("should require restart when API key actually changes from empty to non-empty", async () => { + // Initial state with empty API key + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + }) + setupSecretMocks({ + codeIndexOpenAiKey: "", + codeIndexQdrantApiKey: "", + }) + + await configManager.loadConfiguration() + + // Add actual API key + setupSecretMocks({ + codeIndexOpenAiKey: "actual-api-key", + codeIndexQdrantApiKey: "", + }) + + const result = await configManager.loadConfiguration() + // Should require restart since we went from empty to actual key + expect(result.requiresRestart).toBe(true) + }) + }) + + describe("getRestartInfo public method", () => { + it("should provide restart info without loading configuration", async () => { + // Setup initial state + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + }) + setupSecretMocks({ + codeIndexOpenAiKey: "test-key", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + + // Create a mock previous config + const mockPrevConfig = { + enabled: true, + configured: true, + embedderProvider: "openai" as const, + modelId: "text-embedding-3-large", // Different model with different dimensions + openAiKey: "test-key", + ollamaBaseUrl: undefined, + qdrantUrl: "http://qdrant.local", + qdrantApiKey: undefined, + } + + const requiresRestart = configManager.doesConfigChangeRequireRestart(mockPrevConfig) + expect(requiresRestart).toBe(true) + }) + }) + }) + describe("isConfigured", () => { - it("should return true when OpenAI provider is properly configured", () => { + it("should validate OpenAI configuration correctly", async () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", codebaseIndexEmbedderProvider: "openai", - codebaseIndexQdrantUrl: "http://localhost:6333", + }) + setupSecretMocks({ + codeIndexOpenAiKey: "test-key", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + expect(configManager.isFeatureConfigured).toBe(true) + }) + + it("should validate Ollama configuration correctly", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "ollama", + codebaseIndexEmbedderBaseUrl: "http://ollama.local", + }) + + await configManager.loadConfiguration() + expect(configManager.isFeatureConfigured).toBe(true) + }) + + it("should validate OpenAI Compatible configuration correctly", async () => { + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + } + } + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "test-api-key", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + expect(configManager.isFeatureConfigured).toBe(true) + }) + + it("should return false when OpenAI Compatible base URL is missing", async () => { + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + } + } + if (key === "codebaseIndexOpenAiCompatibleBaseUrl") return "" + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "test-api-key", + }) + + await configManager.loadConfiguration() + expect(configManager.isFeatureConfigured).toBe(false) + }) + + it("should return false when OpenAI Compatible API key is missing", async () => { + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + } + } + if (key === "codebaseIndexOpenAiCompatibleBaseUrl") return "https://api.example.com/v1" + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "", + }) + + await configManager.loadConfiguration() + expect(configManager.isFeatureConfigured).toBe(false) + }) + + it("should validate Gemini configuration correctly", async () => { + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "gemini", + } + } + return undefined }) mockContextProxy.getSecret.mockImplementation((key: string) => { - if (key === "codeIndexOpenAiKey") return "test-key" + if (key === "codebaseIndexGeminiApiKey") return "test-gemini-key" return undefined }) - configManager = new CodeIndexConfigManager(mockContextProxy) + await configManager.loadConfiguration() + expect(configManager.isFeatureConfigured).toBe(true) + }) - expect(configManager.isConfigured()).toBe(true) + it("should return false when Gemini API key is missing", async () => { + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "gemini", + } + } + return undefined + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codebaseIndexGeminiApiKey") return "" + return undefined + }) + + await configManager.loadConfiguration() + expect(configManager.isFeatureConfigured).toBe(false) }) - it("should return false when OpenAI provider is missing API key", () => { + it("should return false when required values are missing", async () => { mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, codebaseIndexEmbedderProvider: "openai", - codebaseIndexQdrantUrl: "http://localhost:6333", }) - mockContextProxy.getSecret.mockReturnValue(undefined) - configManager = new CodeIndexConfigManager(mockContextProxy) + await configManager.loadConfiguration() + expect(configManager.isFeatureConfigured).toBe(false) + }) + }) - expect(configManager.isConfigured()).toBe(false) + describe("getter properties", () => { + beforeEach(async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-large", + }) + setupSecretMocks({ + codeIndexOpenAiKey: "test-openai-key", + codeIndexQdrantApiKey: "test-qdrant-key", + }) + + await configManager.loadConfiguration() }) - it("should return false when OpenAI provider is missing Qdrant URL", () => { + it("should return correct configuration via getConfig", () => { + const config = configManager.getConfig() + expect(config).toEqual({ + isConfigured: true, + embedderProvider: "openai", + modelId: "text-embedding-3-large", + openAiOptions: { openAiNativeApiKey: "test-openai-key" }, + ollamaOptions: { ollamaBaseUrl: undefined }, + geminiOptions: undefined, + openAiCompatibleOptions: undefined, + qdrantUrl: "http://qdrant.local", + qdrantApiKey: "test-qdrant-key", + searchMinScore: 0.4, + searchMaxResults: 50, + }) + }) + + it("should return correct feature enabled state", () => { + expect(configManager.isFeatureEnabled).toBe(true) + }) + + it("should return correct embedder provider", () => { + expect(configManager.currentEmbedderProvider).toBe("openai") + }) + + it("should return correct Qdrant configuration", () => { + expect(configManager.qdrantConfig).toEqual({ + url: "http://qdrant.local", + apiKey: "test-qdrant-key", + }) + }) + + it("should return correct model ID", () => { + expect(configManager.currentModelId).toBe("text-embedding-3-large") + }) + }) + + describe("initialization and restart prevention", () => { + it("should not require restart when configuration hasn't changed between calls", async () => { + // Setup initial configuration - start with enabled and configured to avoid initial transition restart mockContextProxy.getGlobalState.mockReturnValue({ codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", }) - mockContextProxy.getSecret.mockImplementation((key: string) => { - if (key === "codeIndexOpenAiKey") return "test-key" - return undefined + setupSecretMocks({ + codeIndexOpenAiKey: "test-key", }) - configManager = new CodeIndexConfigManager(mockContextProxy) + // First load - this will initialize the config manager with current state + await configManager.loadConfiguration() - expect(configManager.isConfigured()).toBe(false) + // Second load with same configuration - should not require restart + const secondResult = await configManager.loadConfiguration() + expect(secondResult.requiresRestart).toBe(false) + }) + + it("should properly initialize with current config to prevent false restarts", async () => { + // Setup configuration + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, // Always enabled now + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + }) + setupSecretMocks({ + codeIndexOpenAiKey: "test-key", + }) + + // Create a new config manager (simulating what happens in CodeIndexManager.initialize) + const newConfigManager = new CodeIndexConfigManager(mockContextProxy) + + // Load configuration - should not require restart since the manager should be initialized with current config + const result = await newConfigManager.loadConfiguration() + expect(result.requiresRestart).toBe(false) + }) + + it("should not require restart when settings are saved but code indexing config unchanged", async () => { + // This test simulates the scenario where handleSettingsChange() is called + // but code indexing settings haven't actually changed + + // Setup initial state - enabled and configured + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + }) + setupSecretMocks({ + codeIndexOpenAiKey: "test-key", + }) + + // First load to establish baseline + await configManager.loadConfiguration() + + // Simulate external settings change where code indexing config hasn't changed + // (this is what happens when other settings are saved) + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(false) }) })