Skip to content

fix(desktop): end the #67603 model-switch dup, cross-profile session bleed, and [System:] bubble - #69861

Merged
OutThisLife merged 3 commits into
mainfrom
bb/salvage-67603-desktop-session
Jul 23, 2026
Merged

fix(desktop): end the #67603 model-switch dup, cross-profile session bleed, and [System:] bubble#69861
OutThisLife merged 3 commits into
mainfrom
bb/salvage-67603-desktop-session

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Supersedes #68665 and #63590. Closes #67603.

Consolidates every session-reconciliation symptom reported in #67603 into one fix, preserving credit for both original authors (@Dolverin, @oliviaaaa7788). Intended to be the last PR on the model-switch/session UI for a while — it closes the whole class at the source rather than one call path.

Symptom 1 — duplicated user bubble after a model switch (was #68665)

The gateway persists model-switch / personality notices as role=user [System: …] rows (tui_gateway/server.py) so strict OpenAI-compatible providers don't reject a non-leading system message (#48338). preserveLocalPendingTurnMessages paired local optimistic rows to the stored transcript by user-role ordinal, so a stored marker between two real user turns shifted every later ordinal and the optimistic row was re-appended at the bottom.

Fix: exclude [System: bookkeeping markers from ordinal pairing on both sides. Covers the whole marker class (model-switch and personality).

Symptom 2 — one session appearing under two profiles (was #63590)

The main resume path already resolves a session's owning profile via resolveStoredSession (cache → active backend → cross-profile probe). But several other resume paths omitted profile, so the gateway fell back to the launch-profile DB and forked the conversation into the wrong profile — the session then shows under both profiles (and in hermes --tui, since both read the same DB it was written into), and continued turns persist to the wrong profile while the original keeps only its early copy (exactly Franku's "cut off to the first Hermes response on one profile, full on the other").

Every session-routing call now carries the owning profile, resolved through the existing resolveStoredSession resolver (via a small resolveSessionProfile helper) so it works even for sessions outside the paginated sidebar window (the cache-miss gap the lead flagged on #63590):

  • the four recovery session.resume calls (stale runtime id, session-not-found, wedged loop, redirect) in use-prompt-actions;
  • the tiled / split-pane cold resume (resumeTile), a sibling path that also read messages and resumed with no profile;
  • the uncached right-click branch.

Also — the cosmetic half of #63590: the remembered-session key hermes.desktop.lastSessionId was global, so a relaunch/cold start under profile B tried to restore profile A's last session. It's now keyed per profile (default keeps the legacy unsuffixed key for back-compat), read back for the active profile, and cleared under the session's owner.

Note: this stops the mis-route going forward. A conversation already cloned into the wrong profile on an affected install is persisted data in two profile DBs — this PR can't retroactively un-fork it (that's a manual sessions cleanup), but no new turns will bleed.

Symptom 3 — the [System:] marker rendered as a fake user bubble

Even with ordinal pairing fixed, _history_to_messages (the one display projection every client reads) passed the [System: …] rows straight through, so on resume/reload they rendered as a user bubble the user never sent — on desktop, TUI, CLI, and web.

Fix: drop those markers in _history_to_messages. The raw marker stays in session["history"] for the model, so inference is unchanged; only the display loses a row that never belonged to the user. This also removes the stored marker from the payload the desktop reconciles against — killing the ordinal shift from Symptom 1 at the source — with the desktop-side exclusion kept as a fallback for older backends.

Tests

  • preserveLocalPendingTurnMessages: discriminating two-switch marker test (fails before, passes after) + a no-over-correction test for a genuinely uncommitted turn.
  • Recovery resume: cache-hit and cross-profile cache-miss coverage asserting profile is carried.
  • Tile resume: carries the owning profile onto both prefetch and the resume RPC (cross-profile + default).
  • Branch: resolves + swaps to the parent's profile when the branched session isn't cached.
  • Remembered session id: per-profile isolation, legacy default-key back-compat, single-profile clear; rememberedSessionProfile owner resolution.
  • _history_to_messages: hides model-switch and personality markers; keeps a real user turn that merely mentions the token.

Checks

  • vitest run (full desktop suite): 2669 passed, 3 skipped, 0 failed
  • scripts/run_tests.sh tests/test_tui_gateway_server.py -k history_to_messages: 6 passed
  • tsc --noEmit (app + electron + e2e): clean
  • eslint on changed files: clean

…wning profile

Fixes two Desktop session-reconciliation symptoms from #67603.

Symptom 1 — duplicated user bubble after a model switch. The gateway
persists model-switch / personality notices as role=user `[System: …]`
rows (tui_gateway/server.py) so strict OpenAI-compatible providers don't
reject a non-leading system message (#48338). `preserveLocalPendingTurnMessages`
paired local optimistic rows with the stored transcript by user-role
ordinal, so a marker between two real user turns shifted every later
ordinal and the optimistic row was re-appended at the bottom. The single
trailing-marker case is already covered by the compression-era
`latestAuthoritativeUser` guard, but two switches around one turn (marker
before AND after the committed prompt) still duplicated it. Exclude
`[System:` bookkeeping markers from ordinal pairing on both sides.

Symptom 2 — a session appearing under two profiles. The main resume path
already resolves a session's owning profile via `resolveStoredSession`
(cache → active backend → cross-profile probe), but the recovery
`session.resume` calls (stale runtime id, session-not-found, wedged loop,
redirect) omitted `profile`, so the gateway fell back to the launch-profile
DB and forked the conversation into the wrong profile. Route every recovery
resume — and an uncached right-click branch — through the same resolver so
the profile is carried even for sessions outside the paginated sidebar
window (the cache-miss gap).

Tests: discriminating two-switch marker test (fails before, passes after);
cache-hit + cross-profile cache-miss coverage for the recovery resume and
for branching an uncached session.

Supersedes #68665 and #63590.
Closes #67603.

Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 62bfba5

ℹ️ Info

Desktop E2E visual evidence · View test artifacts · View job

1 visual diff.

inline evidence is publishing...

OutThisLife and others added 2 commits July 22, 2026 23:53
A single global `hermes.desktop.lastSessionId` key remembered ONE session
across every profile, so a relaunch or cold start under profile B would try
to restore a session owned by profile A — reinforcing the impression that a
conversation had bled between profiles (#67603, second symptom).

Key the remembered id by the session's owning profile (resolved from the
session row's `profile`, falling back to the active gateway profile), read it
back for the active profile on restore, and clear an exhausted session under
its owner. The default profile keeps the original unsuffixed key so existing
installs' remembered session survives the upgrade.

Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
Model-switch and personality notices are persisted as role=user `[System: …]`
rows so strict providers accept them mid-history, but they are model-facing
runtime metadata, not user turns. `_history_to_messages` — the single display
projection every client reads — passed them straight through, so on resume or
reload they rendered as a fake user bubble in the desktop, TUI, CLI, and web
transcripts.

Drop them in that projection. The raw marker stays in `session["history"]`
for the model, so nothing changes for inference; only the display loses a row
that never belonged to the user. This also removes the stored marker from the
payload the desktop reconciles against, killing the ordinal shift that
duplicated the optimistic prompt (#67603) at its source — the desktop-side
marker exclusion remains as a fallback for older backends.

Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/desktop Electron desktop app (apps/desktop/*) comp/tui Terminal UI (ui-tui/ + tui_gateway/) sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 23, 2026
@OutThisLife OutThisLife changed the title fix(desktop): stop model-switch dup + route recovery resumes to the owning profile fix(desktop): end the #67603 model-switch dup, cross-profile session bleed, and [System:] bubble Jul 23, 2026
@OutThisLife
OutThisLife merged commit e59dcf4 into main Jul 23, 2026
47 checks passed
@OutThisLife
OutThisLife deleted the bb/salvage-67603-desktop-session branch July 23, 2026 05:05
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…ile session bleed, and [System:] bubble (NousResearch#69861)

* fix(desktop): stop model-switch dup + route recovery resumes to the owning profile

Fixes two Desktop session-reconciliation symptoms from NousResearch#67603.

Symptom 1 — duplicated user bubble after a model switch. The gateway
persists model-switch / personality notices as role=user `[System: …]`
rows (tui_gateway/server.py) so strict OpenAI-compatible providers don't
reject a non-leading system message (NousResearch#48338). `preserveLocalPendingTurnMessages`
paired local optimistic rows with the stored transcript by user-role
ordinal, so a marker between two real user turns shifted every later
ordinal and the optimistic row was re-appended at the bottom. The single
trailing-marker case is already covered by the compression-era
`latestAuthoritativeUser` guard, but two switches around one turn (marker
before AND after the committed prompt) still duplicated it. Exclude
`[System:` bookkeeping markers from ordinal pairing on both sides.

Symptom 2 — a session appearing under two profiles. The main resume path
already resolves a session's owning profile via `resolveStoredSession`
(cache → active backend → cross-profile probe), but the recovery
`session.resume` calls (stale runtime id, session-not-found, wedged loop,
redirect) omitted `profile`, so the gateway fell back to the launch-profile
DB and forked the conversation into the wrong profile. Route every recovery
resume — and an uncached right-click branch — through the same resolver so
the profile is carried even for sessions outside the paginated sidebar
window (the cache-miss gap).

Tests: discriminating two-switch marker test (fails before, passes after);
cache-hit + cross-profile cache-miss coverage for the recovery resume and
for branching an uncached session.

Supersedes NousResearch#68665 and NousResearch#63590.
Closes NousResearch#67603.

Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>

* fix(desktop): scope the remembered session id per profile

A single global `hermes.desktop.lastSessionId` key remembered ONE session
across every profile, so a relaunch or cold start under profile B would try
to restore a session owned by profile A — reinforcing the impression that a
conversation had bled between profiles (NousResearch#67603, second symptom).

Key the remembered id by the session's owning profile (resolved from the
session row's `profile`, falling back to the active gateway profile), read it
back for the active profile on restore, and clear an exhausted session under
its owner. The default profile keeps the original unsuffixed key so existing
installs' remembered session survives the upgrade.

Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>

* fix(gateway): hide [System:] bookkeeping markers from every transcript

Model-switch and personality notices are persisted as role=user `[System: …]`
rows so strict providers accept them mid-history, but they are model-facing
runtime metadata, not user turns. `_history_to_messages` — the single display
projection every client reads — passed them straight through, so on resume or
reload they rendered as a fake user bubble in the desktop, TUI, CLI, and web
transcripts.

Drop them in that projection. The raw marker stays in `session["history"]`
for the model, so nothing changes for inference; only the display loses a row
that never belonged to the user. This also removes the stored marker from the
payload the desktop reconciles against, killing the ordinal shift that
duplicated the optimistic prompt (NousResearch#67603) at its source — the desktop-side
marker exclusion remains as a fallback for older backends.

Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>

---------

Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>
pierrenode added a commit to pierrenode/hermes-agent that referenced this pull request Aug 11, 2026
…ume preview

_display_resumed_history() (the CLI's "Previous Conversation" recap shown
on /resume and hermes --resume) is a separate display projection from
tui_gateway/server.py::_history_to_messages (the one desktop/TUI/web
read). It special-cases the display_kind values it happens to know
about, but two classes of bookkeeping/scaffolding rows fell through to
the plain role=="user" branch and rendered as fake You: bubbles:

1. Personality-change/clear marker — tui_gateway.server's
   _set_personality() writes a plain role=user "[System: ...]" row with
   no display_kind. _history_to_messages already hides it via
   _is_display_hidden_marker() (a role + "[System:" prefix check,
   independent of display_kind); the CLI's recap never picked up the
   same check.
2. /skill invocations — persisted expanded (activation note plus the
   entire skill body). _history_to_messages already collapses this onto
   the invocation the user typed via describe_skill_invocation(); the
   CLI's recap showed up to 300 raw characters of the expanded body as
   if the user had written it.

This is the third time this general bug class has been closed without
covering the CLI path — NousResearch#68665 / NousResearch#69861 fixed the duplicate-bubble
symptom on desktop, and a same-day sibling commit widened
_history_to_messages for "desktop, TUI, CLI, and web transcripts" even
though that function is never on the CLI's `hermes --resume` code path.

Fix:
- Personality marker: same role == "user" + "[System:" prefix check as
  _is_display_hidden_marker(), added to _display_resumed_history()'s
  history loop. Positioned after the display_kind special cases (so
  model_switch's "◈ model changed" event line is unaffected) and before
  the plain role == "user" handling.
- Skill invocations: describe_skill_invocation() (from
  agent.skill_commands, the same helper tui_gateway/server.py uses) is
  called on role=="user" content; a match renders as "◈ skill invoked:
  /work — fix the leak" instead of the expanded body, consistent with
  the existing model-switch / async-delegation event lines.

Deliberately not importing tui_gateway.server to reuse
_is_display_hidden_marker directly — that module is not otherwise on
the CLI's import graph and is a large gateway-oriented module. The
duplicated prefix check is commented to point back at
_is_display_hidden_marker so the two stay in sync if the marker
wording/role ever changes.

Tests: 4 regression tests (personality-change marker, personality-cleared
marker, skill invocation with instruction, bare skill invocation) in
tests/cli/test_resume_display.py — all assert the raw scaffolding never
appears and the collapsed/hidden form renders correctly, while real
conversation turns around them still render. Mutation-verified: each
fix reverted independently, confirmed both fail with the exact bug's
failure mode.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists 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.

Desktop: user messages duplicated and stacked at bottom after model switch

2 participants