fix(gateway): scope sidebar messaging sessions to the active profile - #71526
fix(gateway): scope sidebar messaging sessions to the active profile#71526FrendoWu wants to merge 2 commits into
Conversation
187ad12 to
decee48
Compare
Related: #56635, #63618, #44157, and #42934 address the same messaging-sidebar profile-scope gap with different scopes. This patch additionally threads messaging_profile through the batched sidebar API and filters stale rows during a profile switch, so it is a competing implementation rather than a duplicate. |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing both the batched route and the independent messaging reads; current main still has the all-profile behavior in hermes_cli/web_routers/profiles.py:301-303, apps/desktop/src/app/session/hooks/use-session-list-actions.ts:84, and apps/desktop/electron/main.ts:9930.
Problems
- The new scope makes
$messagingPlatformTotalsscope-specific, but it is neither reset nor keyed by profile.loadMoreMessagingForPlatformretains exact totals inuse-session-list-actions.ts:119-120; the sidebar consumes them for the displayed total andhasMoreinapps/desktop/src/app/chat/sidebar/index.tsx:924-932. A profile switch can therefore show the prior profile's count and pagination state.
Suggested changes
- Invalidate or profile-key those totals on scope changes and cover a switch after a per-platform load-more.
- Salvage the backend edit into
hermes_cli/web_routers/profiles.py; commit27b1377b4c5284d0fc16ed5df4c27c507b86559dmoved this handler out ofweb_server.py.
Automated hermes-sweeper review.
| // Messaging conversations are stored in the owning profile's state.db, and | ||
| // every messaging read windows a shared row budget — so an unscoped fetch | ||
| // lets a busy profile crowd the others out of the window. Scope them like | ||
| // recents/cron: a concrete profile sees only its own platform conversations, |
There was a problem hiding this comment.
messagingPlatformTotals remains global even though this makes fetched rows profile-scoped. A per-platform load-more in profile A stores A's exact total, and after switching to B the sidebar still uses that value for its count and hasMore. Reset or key these totals by messagingProfile, and add a profile-switch regression test.
There was a problem hiding this comment.
Fixed by keying the map rather than resetting it — pushed as a fresh commit on current main (the old base was ~425 commits behind and had gone dirty).
$messagingPlatformTotals is now keyed profile:source, built by a shared messagingTotalsKey / messagingProfileFor pair in store/profile.ts so the writer (loadMoreMessagingForPlatform) and the reader (the sidebar's messagingGroups memo) can't disagree about which profile a cached value belongs to. Two notes on the details:
- Keying beats resetting here, because a reset throws away A's resolved total on every switch and drops
hasMoreback to the coarse$messagingTruncatedflag until something re-pages. Keyed, A→B→A restores A's real count. It also needs no new effect or subscription: a scope change reads a different slot, so there's nothing to invalidate. (A reset hung off$activeGatewayProfilewould also have missed the$showAllProfilestoggle, which changes the effective messaging profile without touching the gateway.) - The sidebar derives the scope from
$profileScope, notshowAllProfiles— the latter ismultiProfile && profileScope === ALL_PROFILES, so on a single-profile install it disagrees with what the hook actually sent asmessaging_profile.
Worth recording that the low-reading direction was the worse one: a stale total that's too high shows a phantom "load more" and self-heals after one wasted round-trip, but too low makes known > ordered.length false, which overrides $messagingTruncated and suppresses a legitimate "load more" — and nothing triggers a per-platform fetch, so that one never self-heals.
Backend salvaged as you suggested. The edit is now in hermes_cli/web_routers/profiles.py per 27b1377b4c; the web_server.py re-export means TestClient(app) still serves the route, so no mount change was needed.
Tests — the profile-switch regression you asked for is in use-session-list-actions.test.tsx, using a real rerender-driven switch after a per-platform load-more (the previous messaging test rendered two independent hook instances, so it proved nothing about state carried across a switch). It asserts the new profile doesn't inherit the count and the old profile's survives. I verified it fails without the keying. That file also now resets $messagingPlatformTotals / $messagingTruncated between tests — neither was reset before, which made these assertions order-dependent.
On the backend side there was no test to extend: the sidebar-handler tests were removed in the two pruning waves (6b81590c55, 39975613b1) and there's no tests/hermes_cli/web_routers/. So tests/hermes_cli/test_web_server_sidebar_sessions.py is new, following the isolated_profiles + client fixture idiom from test_web_server_messaging_profiles.py. It covers scoping, the all/omitted unified view, that messaging.total narrows with the scope, and that the cron/recents windows are unaffected. Two of the four fail without the gate.
One semantic change to flag: messaging.total now means "rows for the requested profile". That's what the desktop resolves a section's exact count from, and no consumer reads it as a cross-profile figure (SidebarSessionSlice only declares sessions + profiles_truncated), so I left it as-is rather than adding a second field.
Green locally: full ui vitest project (3125 tests), electron project (867), all three tsc projects, eslint, prettier, and scripts/run_tests.sh on the new file plus the adjacent profile/web-server suites.
Left out on purpose
Two adjacent leaks in the same family that I kept out to hold this diff to the review ask — happy to fold either in if you'd rather:
messagingVisible/messagingLoadMorePending(sidebar/index.tsx) are React-local and also survive a profile switch, so a busy profile's expanded reveal cap carries into a quieter one.store/gateway-switch.test.tsnever asserts the three messaging stores thatwipeSessionListsForGatewaySwitchclears — pre-existing coverage gap.- Minor:
loadMoreMessagingForPlatform'sloadedcount filters$messagingSessionsby platform only, so mid-switch it can briefly count the previous profile's rows. Self-corrects on the next refresh.
decee48 to
49a7459
Compare
Messaging-platform conversations (WeChat/Telegram/…) live in the owning
profile's state.db like everything else, but the sidebar read them
cross-profile while every messaging read shares one bounded
`messaging_limit` window. A profile with many conversations filled that
window from the union and crowded the quieter profiles out of it, so their
per-platform sections looked truncated (or empty) no matter which profile
was selected.
Thread `messaging_profile` through the batched sidebar endpoint, the
per-slice legacy fallback, and the Electron remote-splice path, so a
concrete profile windows only its own rows and `all` keeps the unified
view. Omitting the param keeps the cross-profile default, so an older
desktop against a newer backend is unaffected.
Key $messagingPlatformTotals by `profile:source`
------------------------------------------------
Scoping the fetch makes each per-platform total profile-specific, and that
map was keyed by source alone with no eraser on a profile switch
(wipeSessionListsForGatewaySwitch fires on a gateway-mode switch, not a
profile one). So a resolved total leaked across a switch, and the sidebar
derives both a section's count and its "load more" affordance from it:
* too high → a phantom "load more" on a section with nothing left.
Self-heals after one wasted round-trip.
* too low → `hasMore: false`, which overrides the coarse
$messagingTruncated flag and SUPPRESSES a legitimate "load more".
Nothing triggers a per-platform fetch, so this one never self-heals.
Keying rather than clearing also means switching A→B→A keeps A's resolved
total instead of discarding it. The key is built by a shared
messagingTotalsKey/messagingProfileFor pair in store/profile.ts so the
fetcher and the sidebar cannot disagree about which profile a cached value
belongs to — the sidebar derives the scope from $profileScope, not from the
`multiProfile && …` display flag, which diverges on a single-profile
install.
Tests
-----
* use-session-list-actions.test.tsx: a real rerender-driven profile
switch after a per-platform load-more, asserting the new profile does
not inherit the count and the old profile's survives. Also resets
$messagingPlatformTotals / $messagingTruncated between tests — neither
was reset, which made these assertions order-dependent.
* tests/hermes_cli/test_web_server_sidebar_sessions.py (new): scoping,
the `all`/omitted unified view, that `messaging.total` narrows with the
scope, and that cron/recents windows are unaffected.
Note `messaging.total` now means "rows for the requested profile". That is
what the desktop resolves a section's exact count from, and no consumer
reads it as a cross-profile figure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
49a7459 to
cee22bd
Compare
check-attribution flagged this address as an unmapped contributor email, which fails the required `All required checks pass` gate. Adds the one-file-per-email mapping the check asks for; the frozen AUTHOR_MAP in scripts/release.py is left untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This PR has never had CI run on itEvery workflow run on this branch is stuck at Rebased onto current mainThe previous base was 45 commits behind. Rebased cleanly onto Fork CIhttps://github.com/FrendoWu/hermes-agent/actions/runs/30638792399 — A blocker that was invisible until CI could run
This would have failed the required gate even if the workflows had been approved. Fixed as the check instructs, in its own commit: added (The same one-line mapping commit appears on #71530. By design Local validation on the rebased branch
Review feedbackBoth points from the earlier review are addressed on this branch: AskCould a maintainer approve workflow runs on this branch so upstream CI reports? The gate passes in an identical environment on the fork. |
|
Resolved on main by #87566, which consolidated this PR's fix with your Co-authored-by credit preserved in the merged commits. Thank you! |
Symptom
The desktop sidebar renders three lists: recents, cron jobs, and one collapsible section per messaging platform (WeChat, Telegram, ...). Switching profiles moves the first two but not the third — every profile shows every profile's messaging conversations merged together.
Root cause
Two places hardcode the cross-profile fetch:
use-session-list-actions.tspasses'all'as the profile for both messaging reads, with[]dependency arrays, so they don't even re-run when the profile scope changes./api/profiles/sessions/sidebarhas no messaging profile parameter. The recents slice is gated onrecents_profile, but the messaging slice accumulates unconditionally across every profile.Why client-side filtering isn't enough
The messaging slice windows a shared row budget (
messaging_limit, default 100) before returning. With 118 and 61 active conversations across two profiles, the backend returns the 100 most recent of the union, so filtering client-side silently truncates the quieter profile's sections — it would look like a fix while hiding rows. The fetch has to carry the profile.Fix
hermes_cli/web_server.py: addmessaging_profile(default"all") and gate the messaging slice the same wayrecents_profilegates recents at the line above.profileScope === ALL_PROFILES ? 'all' : profileScopeexpression that cron and recents already use. Also filter in themessagingGroupsmemo, because a profile switch doesn't wipe$messagingSessions(only a gateway-mode switch does) and the previous profile's rows would otherwise linger until the next refresh.messagingProfileis optional onSidebarSessionsRequest, so existing callers keep the cross-profile default and an older backend that doesn't know the query param ignores it and degrades to current behaviour.Deliberately unchanged: the cron sessions slice stays cross-profile. It only resolves pinned cron rows and drives no visible section, so scoping it could make another profile's pinned row unresolvable.
How to test
Requires two profiles with messaging conversations in each.
In the app: pick a profile in the sidebar rail; its WeChat/Telegram sections should list only that profile's conversations, and the row count should match what that profile actually has rather than a truncated slice of the union. Toggle "All profiles" and the merged view returns. Per-platform "load more" stays scoped.
Tests
tests/hermes_cli/test_web_server.py::test_profiles_sessions_sidebar_scopes_messaging_to_profile— two profiles each with aweixinsession;messaging_profile=<name>returns only that profile's row, whilealland the omitted param both return the union (back-compat for an older desktop).apps/desktop/src/hermes.test.ts— the request carriesmessaging_profile, and omittingmessagingProfilestill sendsmessaging_profile=all.apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx— both messaging reads and the batched call carry the active scope;ALL_PROFILESmaps toall.Results on this branch:
tests/hermes_cli/test_web_server.py517 passed; desktop--project ui2198 passed,--project electron725 passed.Platforms
macOS 27 (arm64), Python 3.11.
Note for anyone running the Python suite locally: prefix it with
HERMES_HOME=$(mktemp -d).tests/gatewayisolatessessions_dirbutSessionDB()without an explicit path falls back to the realHERMES_HOME, so an unprefixed run writes fixture sessions into~/.hermes/state.db.