Skip to content

fix(desktop): re-seed composer when profile default model changes externally - #50198

Open
DavidMetcalfe wants to merge 3 commits into
NousResearch:mainfrom
DavidMetcalfe:fix/desktop-composer-sync-profile-default
Open

fix(desktop): re-seed composer when profile default model changes externally#50198
DavidMetcalfe wants to merge 3 commits into
NousResearch:mainfrom
DavidMetcalfe:fix/desktop-composer-sync-profile-default

Conversation

@DavidMetcalfe

Copy link
Copy Markdown
Contributor

Summary

Fixes the user-visible symptom in #50013: the Desktop composer's model label silently desynchronises from model.default in the active profile's ~/.hermes/profiles/<name>/config.yaml. After this lands, a Dashboard Models page edit, hermes model, hermes config set, or another Hermes client on the same profile will reach the Desktop composer within one polling interval.

Root cause

apps/desktop/src/store/session.ts:238-239 hydrates $currentModel from localStorage["hermes.desktop.composer.model"] at module load. apps/desktop/src/app/session/hooks/use-model-controls.ts:55-57 only re-seeds from /api/model/info when localStorage is empty — so once the user picks anything, that pick sticks forever, even if config.yaml is rewritten on the same profile by any other surface. No config-file watcher exists in apps/desktop/src (grep -r 'chokidar\|fs.watch' apps/desktop/src returns 0 hits).

Fix

New hook apps/desktop/src/app/session/hooks/use-model-profile-sync.ts:

  • 30 s poll against GET /api/model/info (same cadence as the existing CRON_POLL_INTERVAL_MS pattern in desktop-controller.tsx:147).
  • Takes a baseline on the first tick after gateway open.
  • On subsequent ticks, re-seeds the composer only when:
    • no live session is active (existing in-flight footer guard), AND
    • the server's value changed since the last tick, AND
    • the composer is still showing the previous baseline (i.e. the user never made an explicit divergent pick)
  • Wired into desktop-controller.tsx next to the existing gateway-open refreshCurrentModel() call (line 857) so the lifecycle matches the rest of the model-sync plumbing.

Why the "divergent pick" check uses the server's last-seen value, not the composer's value

The test contract pinned in use-model-controls.test.tsx is "a user pick is sacred — never clobber a pick". A naive "compare to $currentModel" check would lose that invariant: if the user picks X (different from server default Y) and then Y changes to Z externally, you'd want X to win. By comparing the server's value to the server's last-seen value, we only act when the server changed — and we only act on the composer when the composer was still showing the old server default, which by construction means the user hasn't picked.

The first-run empty-composer seed remains refreshCurrentModel's job (called on boot and gateway open). This hook owns drift after that seed.

Tests

8 new tests in use-model-profile-sync.test.tsx:

  • first tick takes baseline, does not write to composer
  • re-seeds when server drifts and composer followed baseline
  • never overwrites an explicit user pick, even after server drifts
  • no-op while a live session is active (footer owns the model label)
  • baseline preserved when backend call fails (transient)
  • no write when server value unchanged
  • empty server value recorded as new baseline without clobbering composer
  • hook mounts + unmounts without crashing (real render(<Harness/>) smoke)

All 5 existing use-model-controls tests still pass. The exported syncProfileDefaultTick helper is what the tests drive directly — avoids fighting fake timers around the polling machinery.

Full suite: 6 pre-existing failures on origin/main (Windows path separators, Electron boot backoff), unchanged by this PR. npx tsc -p . --noEmit clean.

Scope

This is the smallest piece of #50013's two-part proposal:

  1. This PR. Re-seed the Desktop composer when the profile default changes (polling fallback — ships without backend protocol work).
  2. Tracked separately by the umbrella issue [Feature]: Add profile support to the Hermes Dashboard. #13547 and the existing PR fix(dashboard): persist and scope profile switching #13823: bind the Dashboard to the user's active profile (currently dashboard is profile-bound at startup with no UI selector — needs the supervisor/per-profile-worker pattern or similar).

The polling approach here is the smallest viable patch and the same pattern as the existing cron-jobs poll. The WebSocket push variant called out in #50013 is left for a follow-up — it needs a new gateway method, a renderer subscription path, and event-bus work in web_server.py. Polling covers the user-visible symptom today.

Reproducing the bug pre-fix

$ grep -A2 '^model:' ~/.hermes/profiles/coder/config.yaml
  default: minimax/minimax-m3
  provider: kilocode

$ grep -A2 '^model:' ~/.hermes/config.yaml
  default: xiaomi/mimo-v2.5-pro
  provider: kilocode

# Desktop composer (Electron LevelDB): hermes.desktop.composer.model = "minimax/minimax-m3"
# Dashboard (running against default profile): main model shows mimo-v2.5-pro

# Now: hermes model --profile coder anthropic/claude-sonnet-4.7
# - Dashboard (default profile): unchanged (it doesn't watch coder)
# - Desktop composer: STAYS minimax/minimax-m3 (the bug)

Post-fix:

# Same edit; within one polling interval the Desktop composer
# reflects anthropic/claude-sonnet-4.7.

@alt-glitch alt-glitch added type/bug Something isn't working comp/tui Terminal UI (ui-tui/ + tui_gateway/) P3 Low — cosmetic, nice to have comp/desktop Electron desktop app (apps/desktop/*) and removed comp/tui Terminal UI (ui-tui/ + tui_gateway/) labels Jun 21, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the focused cross-surface sync proposal. Current main still skips a re-seed once the composer has a value (apps/desktop/src/app/session/hooks/use-model-controls.ts:49-50), but it now explicitly defines that value as sticky UI state rather than the profile default (apps/desktop/src/store/session.ts:14-18). This needs a maintainer decision before salvage.

Problems

  • use-model-profile-sync.ts:81-85 infers that an equal value was not explicitly selected. selectModel persists any picker choice (use-model-controls.ts:77-91), so an explicit choice equal to the old default would be overwritten on the next external default change.
  • use-model-profile-sync.ts:88-90 leaves the prior provider in place when the server returns a new model with an empty provider. The existing seed path writes an empty string provider too (use-model-controls.ts:59-65).

Suggested changes

  • Resolve the sticky-composer versus externally authoritative-default policy first.
  • If syncing is chosen, retain explicit-vs-auto-seeded provenance and always write the returned provider; add coverage for both cases.

Automated hermes-sweeper review.

const composerModel = $currentModel.get()
const composerProvider = $currentProvider.get()

const composerFollowedBaseline =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Equality with the prior default is not provenance: selectModel persists an explicit picker selection even when it equals the default. A user who explicitly reselects that same model/provider will satisfy this predicate and be overwritten by a later external default change. Track whether the value was auto-seeded instead.

if (composerFollowedBaseline && serverModel) {
setCurrentModel(serverModel)

if (serverProvider) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not retain the previous provider when the server returns a non-empty model with an empty provider. This produces a model/provider pair that the server did not report; mirror refreshCurrentModel and write serverProvider unconditionally when reseeding.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 14, 2026
@DavidMetcalfe

Copy link
Copy Markdown
Contributor Author

Addressed both findings from the review.

Finding 1 — explicit-pick provenance

Added $currentModelExplicitlySet atom to session.ts, persisted to localStorage (via storedBoolean / persistBoolean — same pattern as $currentFastMode). Set to true by selectModel, cleared by refreshCurrentModel(force=true). The sync hook checks this flag before the value comparison, so a picker selection equal to the old default survives an external change and a page reload.

Finding 2 — empty provider write

Removed the if (serverProvider) guard. Now unconditionally writes setCurrentProvider(serverProvider), matching the existing seed path in refreshCurrentModel.

Tests

Two new cases: "does not overwrite a picker selection equal to the previous default" (flag=true, composer==baseline) and "writes an empty provider when the model is non-empty." All 9 existing unit tests pass.

Review (Flash + GPT-OSS cross-vendor)

Both accept — no BLOCKERs or SHOULD-FIXs. First pass found a persistence BLOCKER (flag was in-memory only, now fixed); second pass is clean.

…n sync

Addresses maintainer review feedback on PR NousResearch#50198:

1. Adds `$currentModelExplicitlySet` atom set by `selectModel` and cleared
   by `refreshCurrentModel(force=true)`. The sync hook checks this flag so
   a picker selection equal to the old default (explicit pick, not passive
   follower) survives an external default change.
2. Always writes the provider returned by the server, even when empty,
   matching the existing seed path in `use-model-controls.ts:69`.

Two new tests cover both cases. All 9 existing unit tests pass.
Flash review BLOCKER: $currentModelExplicitlySet was in-memory only, so a page
reload would forget a picker selection equal to the default. Persisted via new
COMPOSER_MODEL_EXPLICITLY_SET_KEY + setCurrentModelExplicitlySet() setter
pattern (matching setCurrentFastMode).
desktop-controller.tsx was retired on main (369d0ee). The hook now
lives alongside useBackgroundSync in wiring.tsx, where refreshCurrentModel
is already called on gateway-open — same lifecycle, same gatewayState
guard.
@DavidMetcalfe
DavidMetcalfe force-pushed the fix/desktop-composer-sync-profile-default branch from 8f98db6 to cee536a Compare July 16, 2026 02:22
@teknium1 teknium1 added the area/profiles Multi-profile isolation, HERMES_HOME scoping label Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants