@@ -60,6 +60,10 @@ describe("CodeIndexConfigManager", () => {
6060 expect ( configManager . currentEmbedderProvider ) . toBe ( "openai" )
6161 } )
6262
63+ it ( "returns the original context proxy" , ( ) => {
64+ expect ( configManager . getContextProxy ( ) ) . toBe ( mockContextProxy )
65+ } )
66+
6367 it ( "loads Bedrock as the embedder provider with its optional profile" , ( ) => {
6468 mockContextProxy . getGlobalState . mockReturnValue ( {
6569 codebaseIndexEnabled : true ,
@@ -81,6 +85,34 @@ describe("CodeIndexConfigManager", () => {
8185 } )
8286 } )
8387
88+ describe ( "model dimension parsing" , ( ) => {
89+ it . each ( [
90+ { raw : undefined , expected : undefined , warns : false } ,
91+ { raw : null , expected : undefined , warns : false } ,
92+ { raw : 1536 , expected : 1536 , warns : false } ,
93+ { raw : "768" , expected : 768 , warns : false } ,
94+ { raw : 0 , expected : undefined , warns : true } ,
95+ { raw : - 1 , expected : undefined , warns : true } ,
96+ { raw : "invalid" , expected : undefined , warns : true } ,
97+ { raw : NaN , expected : undefined , warns : true } ,
98+ ] ) ( "parses $raw with warning=$warns" , async ( { raw, expected, warns } ) => {
99+ const warn = vi . spyOn ( console , "warn" ) . mockImplementation ( ( ) => { } )
100+ try {
101+ mockContextProxy . getGlobalState . mockReturnValue ( { codebaseIndexEmbedderModelDimension : raw } )
102+ const { currentConfig } = await configManager . loadConfiguration ( )
103+ expect ( currentConfig . modelDimension ) . toBe ( expected )
104+ expect ( warn ) . toHaveBeenCalledTimes ( warns ? 1 : 0 )
105+ if ( warns ) {
106+ expect ( warn ) . toHaveBeenCalledWith (
107+ `Invalid codebaseIndexEmbedderModelDimension value: ${ raw } . Must be a positive number.` ,
108+ )
109+ }
110+ } finally {
111+ warn . mockRestore ( )
112+ }
113+ } )
114+ } )
115+
84116 describe ( "isFeatureEnabled" , ( ) => {
85117 it ( "should return false when codebaseIndexEnabled is false" , async ( ) => {
86118 mockContextProxy . getGlobalState . mockReturnValue ( {
@@ -192,6 +224,7 @@ describe("CodeIndexConfigManager", () => {
192224 qdrantUrl : "http://localhost:6333" ,
193225 qdrantApiKey : "" ,
194226 searchMinScore : 0.4 ,
227+ searchMaxResults : 50 ,
195228 } )
196229 expect ( result . requiresRestart ) . toBe ( false )
197230 } )
@@ -1553,6 +1586,59 @@ describe("CodeIndexConfigManager", () => {
15531586 } )
15541587
15551588 describe ( "doesConfigChangeRequireRestart" , ( ) => {
1589+ it . each ( [
1590+ { missing : "enabled" , expected : true } ,
1591+ { missing : "configured" , expected : true } ,
1592+ { missing : "embedderProvider" , expected : false } ,
1593+ ] as const ) (
1594+ "defaults missing previous $missing when comparing a ready configuration" ,
1595+ async ( { missing, expected } ) => {
1596+ mockContextProxy . getGlobalState . mockReturnValue ( {
1597+ codebaseIndexEnabled : true ,
1598+ codebaseIndexEmbedderProvider : providerIdentifiers . openai ,
1599+ codebaseIndexQdrantUrl : "http://localhost:6333" ,
1600+ } )
1601+ setupSecretMocks ( { codeIndexOpenAiKey : "test-key" } )
1602+ configManager = new CodeIndexConfigManager ( mockContextProxy )
1603+ const { configSnapshot } = await configManager . loadConfiguration ( )
1604+ const previous = { ...configSnapshot }
1605+ // Required in the type, but the comparison deliberately tolerates incomplete runtime snapshots.
1606+ Reflect . deleteProperty ( previous , missing )
1607+
1608+ expect ( configManager . doesConfigChangeRequireRestart ( previous ) ) . toBe ( expected )
1609+ } ,
1610+ )
1611+
1612+ it ( "normalizes absent connection values on either side of a defensive comparison" , ( ) => {
1613+ const previous : PreviousConfigSnapshot = {
1614+ enabled : true ,
1615+ configured : true ,
1616+ embedderProvider : providerIdentifiers . openai ,
1617+ }
1618+ // Normal loads fill every connection value; exercise the helper's defensive defaults directly.
1619+ expect ( configManager [ "_hasConnectionSettingsChanged" ] ( previous , { ...previous , openAiKey : "" } ) ) . toBe ( false )
1620+ expect ( configManager [ "_hasConnectionSettingsChanged" ] ( { ...previous , openAiKey : "" } , previous ) ) . toBe ( false )
1621+ expect (
1622+ configManager [ "_hasConnectionSettingsChanged" ] ( previous , { ...previous , qdrantApiKey : "new-key" } ) ,
1623+ ) . toBe ( true )
1624+ } )
1625+
1626+ it ( "normalizes missing optional snapshot credentials in the previous load result" , async ( ) => {
1627+ // The snapshot type permits absent credentials even though normal readers initialize them.
1628+ configManager [ "config" ] = Object . freeze ( {
1629+ ...configManager [ "config" ] ,
1630+ openAiOptions : undefined ,
1631+ qdrantApiKey : undefined ,
1632+ } )
1633+
1634+ const { configSnapshot, requiresRestart } = await configManager . loadConfiguration ( )
1635+
1636+ expect ( configSnapshot . openAiKey ) . toBe ( "" )
1637+ expect ( configSnapshot . qdrantApiKey ) . toBe ( "" )
1638+ expect ( configSnapshot . configured ) . toBe ( false )
1639+ expect ( requiresRestart ) . toBe ( false )
1640+ } )
1641+
15561642 it ( "should return true when enabling the feature" , async ( ) => {
15571643 // Initial state: disabled
15581644 mockContextProxy . getGlobalState . mockReturnValue ( {
@@ -1773,6 +1859,69 @@ describe("CodeIndexConfigManager", () => {
17731859 } )
17741860
17751861 describe ( "getConfig" , ( ) => {
1862+ it ( "publishes frozen configuration and options for every provider" , async ( ) => {
1863+ mockContextProxy . getGlobalState . mockReturnValue ( {
1864+ codebaseIndexEnabled : true ,
1865+ codebaseIndexEmbedderProvider : providerIdentifiers . openai ,
1866+ codebaseIndexQdrantUrl : "http://localhost:6333" ,
1867+ codebaseIndexOpenAiCompatibleBaseUrl : "https://example.com/v1" ,
1868+ codebaseIndexSearchMaxResults : 23 ,
1869+ } )
1870+ mockContextProxy . getSecret . mockReturnValue ( "test-key" )
1871+
1872+ const { currentConfig } = await configManager . loadConfiguration ( )
1873+ expect ( currentConfig ) . toEqual ( configManager . getConfig ( ) )
1874+ expect ( currentConfig . searchMaxResults ) . toBe ( 23 )
1875+ expect ( Object . isFrozen ( currentConfig ) ) . toBe ( true )
1876+ expect ( Reflect . set ( currentConfig , "modelId" , "changed" ) ) . toBe ( false )
1877+ for ( const options of [
1878+ currentConfig . openAiOptions ,
1879+ currentConfig . ollamaOptions ,
1880+ currentConfig . openAiCompatibleOptions ,
1881+ currentConfig . geminiOptions ,
1882+ currentConfig . mistralOptions ,
1883+ currentConfig . vercelAiGatewayOptions ,
1884+ currentConfig . bedrockOptions ,
1885+ currentConfig . openRouterOptions ,
1886+ ] ) {
1887+ expect ( options ) . toBeDefined ( )
1888+ if ( ! options ) throw new Error ( "Expected provider options" )
1889+ expect ( Object . isFrozen ( options ) ) . toBe ( true )
1890+ for ( const key of Object . keys ( options ) ) {
1891+ expect ( Reflect . set ( options , key , "changed" ) ) . toBe ( false )
1892+ }
1893+ }
1894+ expect ( configManager . getConfig ( ) ) . toEqual ( currentConfig )
1895+ } )
1896+
1897+ it ( "preserves previously returned snapshots across reloads" , async ( ) => {
1898+ mockContextProxy . getSecret . mockReturnValue ( "old-key" )
1899+ const { currentConfig : previous } = await configManager . loadConfiguration ( )
1900+ mockContextProxy . getSecret . mockReturnValue ( "new-key" )
1901+ const { currentConfig : current , configSnapshot } = await configManager . loadConfiguration ( )
1902+
1903+ expect ( previous . openAiOptions ?. openAiNativeApiKey ) . toBe ( "old-key" )
1904+ expect ( current . openAiOptions ?. openAiNativeApiKey ) . toBe ( "new-key" )
1905+ expect ( current . openAiOptions ) . not . toBe ( previous . openAiOptions )
1906+ expect ( configSnapshot . openAiKey ) . toBe ( "old-key" )
1907+ } )
1908+
1909+ it ( "keeps the current snapshot when building the next snapshot fails" , async ( ) => {
1910+ const previous = configManager . getConfig ( )
1911+ mockContextProxy . getGlobalState . mockReturnValue ( {
1912+ codebaseIndexEnabled : true ,
1913+ codebaseIndexQdrantUrl : "http://changed:6333" ,
1914+ get codebaseIndexEmbedderModelDimension ( ) : number {
1915+ throw new Error ( "Invalid stored dimension" )
1916+ } ,
1917+ } )
1918+
1919+ await expect ( configManager . loadConfiguration ( ) ) . rejects . toThrow ( "Invalid stored dimension" )
1920+ expect ( configManager . getConfig ( ) ) . toEqual ( previous )
1921+ expect ( configManager . isFeatureEnabled ) . toBe ( false )
1922+ expect ( configManager . getConfig ( ) . openAiOptions ) . toBe ( previous . openAiOptions )
1923+ } )
1924+
17761925 it ( "should return the current configuration" , ( ) => {
17771926 mockContextProxy . getGlobalState . mockReturnValue ( {
17781927 codebaseIndexEnabled : true ,
@@ -2240,6 +2389,32 @@ describe("CodeIndexConfigManager", () => {
22402389 expect ( result . currentConfig . isConfigured ) . toBe ( true )
22412390 } )
22422391
2392+ it ( "keeps an explicitly empty Bedrock region unconfigured and restarts when restored" , async ( ) => {
2393+ const settings = {
2394+ codebaseIndexEnabled : true ,
2395+ codebaseIndexQdrantUrl : "http://qdrant.local" ,
2396+ codebaseIndexEmbedderProvider : providerIdentifiers . bedrock ,
2397+ codebaseIndexBedrockRegion : "" ,
2398+ codebaseIndexBedrockProfile : "test-profile" ,
2399+ }
2400+ mockContextProxy . getGlobalState . mockReturnValue ( settings )
2401+ configManager = new CodeIndexConfigManager ( mockContextProxy )
2402+
2403+ const unchanged = await configManager . loadConfiguration ( )
2404+ expect ( unchanged . currentConfig . bedrockOptions ) . toBeUndefined ( )
2405+ expect ( unchanged . currentConfig . isConfigured ) . toBe ( false )
2406+ expect ( unchanged . configSnapshot . bedrockRegion ) . toBe ( "" )
2407+ expect ( unchanged . configSnapshot . bedrockProfile ) . toBe ( "" )
2408+ expect ( unchanged . requiresRestart ) . toBe ( false )
2409+
2410+ mockContextProxy . getGlobalState . mockReturnValue ( { ...settings , codebaseIndexBedrockRegion : "eu-west-1" } )
2411+ const restored = await configManager . loadConfiguration ( )
2412+ expect ( restored . currentConfig . bedrockOptions ) . toEqual ( { region : "eu-west-1" , profile : "test-profile" } )
2413+ expect ( restored . currentConfig . isConfigured ) . toBe ( true )
2414+ expect ( restored . requiresRestart ) . toBe ( true )
2415+ expect ( unchanged . currentConfig . bedrockOptions ) . toBeUndefined ( )
2416+ } )
2417+
22432418 it ( "should return false from isConfigured for Bedrock when the Qdrant URL is missing" , ( ) => {
22442419 mockContextProxy . getGlobalState . mockReturnValue ( {
22452420 codebaseIndexEnabled : true ,
@@ -2288,17 +2463,14 @@ describe("CodeIndexConfigManager", () => {
22882463 // private field to a value outside the union.
22892464 mockContextProxy . getGlobalState . mockReturnValue ( {
22902465 codebaseIndexEnabled : true ,
2466+ codebaseIndexQdrantUrl : "http://localhost:6333" ,
22912467 } )
22922468 mockContextProxy . getSecret . mockReturnValue ( undefined )
22932469
22942470 configManager = new CodeIndexConfigManager ( mockContextProxy )
2295- // The defensive `return false` is unreachable through the public API because
2296- // EmbedderProvider is a closed union, so exercise it by forcing the private field
2297- // to a value outside the union. `embedderProvider` is TypeScript `private`, not
2298- // `#`-private, so a runtime property write reaches it. The double assertion is a
2299- // last resort: the private field is not part of the public type surface, and
2300- // `as any` is avoided to keep the file's no-explicit-any suppression budget flat.
2301- ; ( configManager as unknown as Record < string , unknown > ) [ "embedderProvider" ] = "not-a-provider"
2471+ // Replace the snapshot with deliberately invalid runtime data to exercise
2472+ // the defensive branch without mutating the frozen production snapshot.
2473+ Reflect . set ( configManager , "config" , { ...configManager [ "config" ] , embedderProvider : "not-a-provider" } )
23022474 expect ( configManager . isConfigured ( ) ) . toBe ( false )
23032475 } )
23042476 } )
0 commit comments