@@ -6,7 +6,8 @@ import { runLocalLlmTest, compareLocalLlmModels } from '../services/localLlmPlay
66import { listModels , listVisionModels , listToolUseModels } from '../services/localLlm.js' ;
77import { enrichCatalogWithVariants } from '../services/huggingFaceCatalog.js' ;
88import { getLoadedModels , unloadModel } from '../services/ollamaManager.js' ;
9- import { getLoadedModels as getLoadedLmStudioModels } from '../services/lmStudioManager.js' ;
9+ import { getLoadedModels as getLoadedLmStudioModels , getLastLoadedModelsError as getLmStudioResidencyError } from '../services/lmStudioManager.js' ;
10+ import { getSettings } from '../services/settings.js' ;
1011import { localLlmCompareSchema , localLlmTestSchema } from '../lib/validation.js' ;
1112import { errorEvents } from '../lib/errorHandler.js' ;
1213
@@ -54,6 +55,12 @@ vi.mock('../services/huggingFaceCatalog.js', () => ({
5455 enrichCatalogWithVariants : vi . fn ( async ( catalog ) => catalog ) ,
5556} ) ) ;
5657
58+ // /loaded reads getSettings() to honor a user's intentionally-disabled backends,
59+ // so mock it (defaults to no backends disabled; the disabled-case test flips it).
60+ vi . mock ( '../services/settings.js' , ( ) => ( {
61+ getSettings : vi . fn ( async ( ) => ( { } ) ) ,
62+ } ) ) ;
63+
5764function makeApp ( ) {
5865 const app = express ( ) ;
5966 app . use ( express . json ( ) ) ;
@@ -245,8 +252,8 @@ describe('local LLM memory-management routes', () => {
245252 } ) ;
246253
247254 it ( 'GET /loaded reports models both local backends currently have resident' , async ( ) => {
248- // Mirror the real getLoadedModels() field set so the fixture documents the
249- // pass-through contract and would catch any future field-stripping.
255+ // Mirror the real getLoadedModels() field set so the fixture documents the
256+ // pass-through contract and would catch any future field-stripping.
250257 const resident = { id : 'llama3.2' , name : 'llama3.2' , size : 4096 , sizeVram : 4096 , expiresAt : null } ;
251258 const lmStudioResident = { id : 'example/lmstudio' , state : 'loaded' } ;
252259 getLoadedModels . mockResolvedValue ( [ resident ] ) ;
@@ -255,10 +262,50 @@ describe('local LLM memory-management routes', () => {
255262 const res = await request ( makeApp ( ) ) . get ( '/api/local-llm/loaded' ) ;
256263
257264 expect ( res . status ) . toBe ( 200 ) ;
258- expect ( res . body ) . toEqual ( { ollama : [ resident ] , lmstudio : [ lmStudioResident ] , sourceErrors : [ ] } ) ;
265+ expect ( res . body ) . toEqual ( { ollama : [ resident ] , lmstudio : [ lmStudioResident ] , sourceErrors : [ ] , disabled : [ ] } ) ;
259266 expect ( getLoadedModels ) . toHaveBeenCalledTimes ( 1 ) ;
260267 expect ( getLoadedLmStudioModels ) . toHaveBeenCalledWith ( true ) ;
261- } ) ;
268+ } ) ;
269+
270+ it ( 'GET /loaded keeps a failed disabled backend in sourceErrors but names it disabled' , async ( ) => {
271+ // "Mark disabled" only silences the availability NAG — it is not evidence the
272+ // backend holds no memory. So /loaded must still probe a disabled backend AND
273+ // still surface its failed residency in sourceErrors (the panel's
274+ // "Free everything" guard keys off that), while separately naming it in
275+ // `disabled` so the banner can stay quiet about it.
276+ getSettings . mockResolvedValueOnce ( { localLlm : { lmstudio : { disabled : true } } } ) ;
277+ getLmStudioResidencyError . mockReturnValueOnce ( 'LM Studio is unavailable' ) ;
278+ getLoadedLmStudioModels . mockResolvedValue ( [ { id : 'example/lmstudio' , state : 'loaded' } ] ) ;
279+
280+ const res = await request ( makeApp ( ) ) . get ( '/api/local-llm/loaded' ) ;
281+
282+ expect ( res . status ) . toBe ( 200 ) ;
283+ // Residency is honored — the backend is still probed…
284+ expect ( getLoadedLmStudioModels ) . toHaveBeenCalledWith ( true ) ;
285+ expect ( res . body . lmstudio ) . toEqual ( [ { id : 'example/lmstudio' , state : 'loaded' } ] ) ;
286+ // …and its failed probe keeps the "unknown residency" status sourceErrors so
287+ // "Free everything" can't claim it freed a model it never saw.
288+ expect ( res . body . sourceErrors ) . toContain ( 'lmstudio' ) ;
289+ // The disabled flag is the signal the panel uses to hold the banner.
290+ expect ( res . body . disabled ) . toEqual ( [ 'lmstudio' ] ) ;
291+ expect ( getSettings ) . toHaveBeenCalled ( ) ;
292+ } ) ;
293+
294+ it ( 'GET /loaded still reports an enabled backend whose residency probe fails' , async ( ) => {
295+ // An enabled backend that fails its residency probe surfaces in sourceErrors
296+ // and is NOT in `disabled`, so the panel both shows the nag and keeps its
297+ // "excluded from Free everything" guard.
298+ getSettings . mockResolvedValueOnce ( { } ) ;
299+ getLmStudioResidencyError . mockReturnValueOnce ( 'LM Studio is unavailable' ) ;
300+
301+ const res = await request ( makeApp ( ) ) . get ( '/api/local-llm/loaded' ) ;
302+
303+ expect ( res . status ) . toBe ( 200 ) ;
304+ expect ( res . body . sourceErrors ) . toContain ( 'lmstudio' ) ;
305+ // An enabled backend is not in the disabled list.
306+ expect ( res . body . disabled ) . not . toContain ( 'lmstudio' ) ;
307+ expect ( getLoadedLmStudioModels ) . toHaveBeenCalledWith ( true ) ;
308+ } ) ;
262309
263310 it ( 'POST /unload evicts a resident model and echoes the service result' , async ( ) => {
264311 // Real unloadModel() success shape is { unloaded: true, model } — NOT modelId
0 commit comments