Skip to content

fix(gateway): scope sidebar messaging sessions to the active profile - #71526

Closed
FrendoWu wants to merge 2 commits into
NousResearch:mainfrom
FrendoWu:fix/sidebar-messaging-profile-scope
Closed

fix(gateway): scope sidebar messaging sessions to the active profile#71526
FrendoWu wants to merge 2 commits into
NousResearch:mainfrom
FrendoWu:fix/sidebar-messaging-profile-scope

Conversation

@FrendoWu

Copy link
Copy Markdown
Contributor

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.ts passes '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/sidebar has no messaging profile parameter. The recents slice is gated on recents_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: add messaging_profile (default "all") and gate the messaging slice the same way recents_profile gates recents at the line above.
  • Desktop: pass the active profile scope, reusing the exact profileScope === ALL_PROFILES ? 'all' : profileScope expression that cron and recents already use. Also filter in the messagingGroups memo, 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.

messagingProfile is optional on SidebarSessionsRequest, 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.

# backend, directly
curl "http://127.0.0.1:<port>/api/profiles/sessions/sidebar?messaging_limit=100&messaging_profile=<profile>"
# -> every messaging.sessions[*].profile equals <profile>
# omitting the param, or messaging_profile=all, returns the union as before

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 a weixin session; messaging_profile=<name> returns only that profile's row, while all and the omitted param both return the union (back-compat for an older desktop).
  • apps/desktop/src/hermes.test.ts — the request carries messaging_profile, and omitting messagingProfile still sends messaging_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_PROFILES maps to all.

Results on this branch: tests/hermes_cli/test_web_server.py 517 passed; desktop --project ui 2198 passed, --project electron 725 passed.

Platforms

macOS 27 (arm64), Python 3.11.

Note for anyone running the Python suite locally: prefix it with HERMES_HOME=$(mktemp -d). tests/gateway isolates sessions_dir but SessionDB() without an explicit path falls back to the real HERMES_HOME, so an unprefixed run writes fixture sessions into ~/.hermes/state.db.

@FrendoWu
FrendoWu requested a review from a team July 25, 2026 19:00
@FrendoWu
FrendoWu force-pushed the fix/sidebar-messaging-profile-scope branch from 187ad12 to decee48 Compare July 25, 2026 19:00
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/desktop Electron desktop app (apps/desktop/*) comp/cli CLI entry point, hermes_cli/, setup wizard area/sessions Session lifecycle, resume, persistence, history area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 25, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

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 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 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 $messagingPlatformTotals scope-specific, but it is neither reset nor keyed by profile. loadMoreMessagingForPlatform retains exact totals in use-session-list-actions.ts:119-120; the sidebar consumes them for the displayed total and hasMore in apps/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; commit 27b1377b4c5284d0fc16ed5df4c27c507b86559d moved this handler out of web_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,

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 hasMore back to the coarse $messagingTruncated flag 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 $activeGatewayProfile would also have missed the $showAllProfiles toggle, which changes the effective messaging profile without touching the gateway.)
  • The sidebar derives the scope from $profileScope, not showAllProfiles — the latter is multiProfile && profileScope === ALL_PROFILES, so on a single-profile install it disagrees with what the hook actually sent as messaging_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.ts never asserts the three messaging stores that wipeSessionListsForGatewaySwitch clears — pre-existing coverage gap.
  • Minor: loadMoreMessagingForPlatform's loaded count filters $messagingSessions by platform only, so mid-switch it can briefly count the previous profile's rows. Self-corrects on the next refresh.

@teknium1 teknium1 added 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 30, 2026
@FrendoWu
FrendoWu force-pushed the fix/sidebar-messaging-profile-scope branch from decee48 to 49a7459 Compare July 30, 2026 16:37
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>
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>
@FrendoWu

Copy link
Copy Markdown
Contributor Author

This PR has never had CI run on it

Every workflow run on this branch is stuck at action_required — fork PRs wait for a maintainer to approve workflows, so the required All required checks pass gate has never reported. That, not a review objection, is why the PR shows as BLOCKED. Since I can't approve my own fork's runs, I ran the full pipeline inside my fork, where no approval is needed.

Rebased onto current main

The previous base was 45 commits behind. Rebased cleanly onto 98105f31f — no conflicts. PR content is unchanged apart from the attribution fix below.

Fork CI

https://github.com/FrendoWu/hermes-agent/actions/runs/30638792399success: 36 passed, 10 skipped, 0 failures, including All required checks pass.

A blocker that was invisible until CI could run

Check contributors / check-attribution failed, independently of code quality:

⚠️  New contributor email(s) without a mapping:
  frendo.wu@gmail.com (Frendo)

This would have failed the required gate even if the workflows had been approved. Fixed as the check instructs, in its own commit: added contributors/emails/frendo.wu@gmail.comFrendoWu via scripts/add_contributor.py; the frozen AUTHOR_MAP in scripts/release.py is untouched. check-attribution is now success.

(The same one-line mapping commit appears on #71530. By design contributors/emails/ is one file per email specifically so additions never conflict, so both PRs stay independently mergeable.)

Local validation on the rebased branch

  • vitest on the touched desktop suites (use-session-list-actions.test.tsx, hermes.test.ts): 30 passed.
  • tsc --noEmit for both the renderer (tsconfig.json) and electron (tsconfig.electron.json) projects: clean.
  • pytest tests/hermes_cli/test_web_server_sidebar_sessions.py: 4 passed. ruff check clean.

Review feedback

Both points from the earlier review are addressed on this branch: $messagingPlatformTotals is keyed by profile rather than reset, and the backend edit sits in hermes_cli/web_routers/profiles.py (the post-27b1377b4 location), not web_server.py.

Ask

Could a maintainer approve workflow runs on this branch so upstream CI reports? The gate passes in an identical environment on the fork.

@teknium1

Copy link
Copy Markdown
Contributor

Resolved on main by #87566, which consolidated this PR's fix with your Co-authored-by credit preserved in the merged commits. Thank you!

@teknium1 teknium1 closed this Aug 16, 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 area/sessions Session lifecycle, resume, persistence, history comp/cli CLI entry point, hermes_cli/, setup wizard 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