-
Notifications
You must be signed in to change notification settings - Fork 12
fix(llm): load persisted settings lazily, not in the constructor #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Anurag-Wednesday
wants to merge
1
commit into
main
Choose a base branch
from
fix/llm-lazy-settings-load
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+143
−1
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| // Regression: LLMService must read its persisted state LAZILY, not in the constructor. | ||
| // | ||
| // `llm` is a module-level singleton (`export const llm = new LLMService()`), so it is | ||
| // constructed while index.ts's IMPORTS are still evaluating — which under ESM finishes | ||
| // BEFORE index.ts's own body runs `unifyUserDataPath()` → `app.setPath('userData', …)`. | ||
| // Any path resolved during construction therefore points at the PRE-override profile. | ||
| // | ||
| // Two real consequences, both of which these tests pin: | ||
| // 1. Production: the canonical-dir migration ("My Memories" / "my-memories" → | ||
| // "Off Grid AI Desktop") has not run yet at construction, so the user's saved | ||
| // settings and active model were silently missed and replaced by defaults. | ||
| // 2. E2E/harness: an OFFGRID_USER_DATA temp profile was ignored entirely — which is | ||
| // what made `settings-sections.spec.ts` "resource mode survives a relaunch" fail. | ||
| // A probe confirmed the constructor resolving the REAL profile while | ||
| // OFFGRID_USER_DATA pointed at the temp dir. | ||
| // | ||
| // Writes never had the bug: `persist()` goes through the `settingsFile` getter, which | ||
| // resolves late. These tests assert the READ side now behaves the same way, by doing | ||
| // what production does — construct FIRST, point the data dir somewhere SECOND. | ||
| import { describe, it, expect, beforeEach, afterEach } from 'vitest' | ||
| import fs from 'fs' | ||
| import os from 'os' | ||
| import path from 'path' | ||
| import { LLMService } from '../llm' | ||
| import { configureRuntime } from '../runtime-env' | ||
|
|
||
| let tmp: string | ||
|
|
||
| /** Write an llm-settings.json into the models dir of a data dir, as `persist()` would. */ | ||
| const seedSettings = (dataDir: string, settings: Record<string, unknown>): void => { | ||
| const modelsDir = path.join(dataDir, 'models') | ||
| fs.mkdirSync(modelsDir, { recursive: true }) | ||
| fs.writeFileSync(path.join(modelsDir, 'llm-settings.json'), JSON.stringify(settings)) | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-llm-lazy-')) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| // Release the override so a later test isn't pinned to a deleted temp dir. | ||
| configureRuntime({ dataDir: undefined }) | ||
| fs.rmSync(tmp, { recursive: true, force: true }) | ||
| }) | ||
|
|
||
| describe('LLMService reads persisted settings lazily (not at construction)', () => { | ||
| it('picks up a data dir configured AFTER the instance was constructed', () => { | ||
| // Construct FIRST — mirrors the module-level singleton being built during imports. | ||
| const svc = new LLMService() | ||
| // ...then point the runtime at the profile, as index.ts's body does later. | ||
| seedSettings(tmp, { performanceMode: 'extreme', temperature: 0.42 }) | ||
| configureRuntime({ dataDir: tmp }) | ||
|
|
||
| const s = svc.getSettings() | ||
| expect(s.performanceMode).toBe('extreme') | ||
| expect(s.temperature).toBe(0.42) | ||
| }) | ||
|
|
||
| it('survives the relaunch shape: persisted mode is read back by a fresh instance', () => { | ||
| // What settings-sections.spec.ts "resource mode survives a relaunch" exercises: | ||
| // one process writes the mode, the next process constructs and must read it back. | ||
| seedSettings(tmp, { performanceMode: 'conservative' }) | ||
| const relaunched = new LLMService() | ||
| configureRuntime({ dataDir: tmp }) | ||
|
|
||
| expect(relaunched.getSettings().performanceMode).toBe('conservative') | ||
| }) | ||
|
|
||
| it('loads once and does not re-read after the first access', () => { | ||
| seedSettings(tmp, { performanceMode: 'conservative' }) | ||
| const svc = new LLMService() | ||
| configureRuntime({ dataDir: tmp }) | ||
| expect(svc.getSettings().performanceMode).toBe('conservative') | ||
|
|
||
| // A later on-disk edit must NOT leak in: the load is once-only, so in-memory state | ||
| // stays authoritative until something explicitly persists. This guards against | ||
| // turning the lazy guard into a read-on-every-call, which would re-read the file | ||
| // on every getSettings and clobber unsaved in-memory changes. | ||
| seedSettings(tmp, { performanceMode: 'extreme' }) | ||
| expect(svc.getSettings().performanceMode).toBe('conservative') | ||
| }) | ||
|
|
||
| it('falls back to defaults when the profile has no settings file', () => { | ||
| const svc = new LLMService() | ||
| configureRuntime({ dataDir: tmp }) // seeded with nothing | ||
| expect(svc.getSettings().performanceMode).toBe('balanced') | ||
| }) | ||
|
|
||
| // The direct guard on the defect, stated behaviourally rather than by spying on fs: | ||
| // if construction reads eagerly, it reads the profile configured AT THAT MOMENT. | ||
| // Point the runtime at profile A, construct, then switch to profile B before first | ||
| // use — a lazy reader returns B, an eager one returns A. This is the exact shape of | ||
| // the production bug (construct during imports, real profile chosen afterwards). | ||
| it('reads the profile configured at FIRST USE, not the one present at construction', () => { | ||
| const other = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-llm-lazy-other-')) | ||
| try { | ||
| seedSettings(other, { performanceMode: 'extreme' }) // profile A | ||
| seedSettings(tmp, { performanceMode: 'conservative' }) // profile B | ||
|
|
||
| configureRuntime({ dataDir: other }) // A is current... | ||
| const svc = new LLMService() // ...at construction | ||
| configureRuntime({ dataDir: tmp }) // the override lands afterwards | ||
|
|
||
| // Eager construction would have pinned 'extreme' from profile A. | ||
| expect(svc.getSettings().performanceMode).toBe('conservative') | ||
| } finally { | ||
| fs.rmSync(other, { recursive: true, force: true }) | ||
| } | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Add a regression test for lazy active-model resolution.
These tests only assert settings loaded through
getSettings(). They do not assert thatensureLoaded()readsactive-model.jsonfrom the profile selected at first use.An implementation that omits
resolveModel()fromensureLoaded()would pass this suite. Seed different active-model files before and after construction, then assert thatlaunchArgs()uses the primary model from the profile active at first use.As per coding guidelines, “Every approved behavior change must add a regression or integration test in the same change, covering branches, conditions, and error paths rather than deferring tests.”
🤖 Prompt for AI Agents
Source: Coding guidelines