fix(desktop): end the #67603 model-switch dup, cross-profile session bleed, and [System:] bubble - #69861
Merged
Merged
Conversation
…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>
This was referenced Jul 23, 2026
Contributor
૮ >ﻌ< ა ci reviewran on 62bfba5 ℹ️ InfoDesktop E2E visual evidence · View test artifacts · View job1 visual diff. inline evidence is publishing... |
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>
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.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
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).preserveLocalPendingTurnMessagespaired 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.mainby the compression-eralatestAuthoritativeUsertext guard (fix(desktop): prevent stale optimistic tails after compression #69682) — so fix(desktop): stop model-switch markers duplicating user messages #68665's original tests pass with and without its fix.main.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 omittedprofile, 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 inhermes --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
resolveStoredSessionresolver (via a smallresolveSessionProfilehelper) so it works even for sessions outside the paginated sidebar window (the cache-miss gap the lead flagged on #63590):session.resumecalls (stale runtime id, session-not-found, wedged loop, redirect) inuse-prompt-actions;resumeTile), a sibling path that also read messages and resumed with no profile;Also — the cosmetic half of #63590: the remembered-session key
hermes.desktop.lastSessionIdwas 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.Symptom 3 — the
[System:]marker rendered as a fake user bubbleEven 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 insession["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.profileis carried.rememberedSessionProfileowner 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 failedscripts/run_tests.sh tests/test_tui_gateway_server.py -k history_to_messages: 6 passedtsc --noEmit(app + electron + e2e): cleaneslinton changed files: clean