From cee22bdb318eb44612572196270b6fd199a1dcc3 Mon Sep 17 00:00:00 2001 From: frendo Date: Fri, 31 Jul 2026 00:37:21 +0800 Subject: [PATCH 1/2] fix(gateway): scope sidebar messaging sessions to the active profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/desktop/electron/main.ts | 3 +- apps/desktop/src/app/chat/sidebar/index.tsx | 33 +++- .../hooks/use-session-list-actions.test.tsx | 111 +++++++++++-- .../session/hooks/use-session-list-actions.ts | 72 ++++++--- apps/desktop/src/hermes.test.ts | 24 ++- apps/desktop/src/hermes.ts | 25 +-- apps/desktop/src/store/profile.ts | 17 ++ apps/desktop/src/store/session.ts | 10 +- hermes_cli/web_routers/profiles.py | 32 ++-- .../test_web_server_sidebar_sessions.py | 149 ++++++++++++++++++ 10 files changed, 410 insertions(+), 66 deletions(-) create mode 100644 tests/hermes_cli/test_web_server_sidebar_sessions.py diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 41686883f71d..e87cb1b87a40 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -9988,7 +9988,8 @@ async function interceptSessionRequestForRemote(request) { const cronSp = sliceParams('cron_limit', '50', { profile: 'all', source: 'cron' }) - const messagingSp = sliceParams('messaging_limit', '100', { profile: 'all' }) + const messagingProfile = (searchParams.get('messaging_profile') || 'all').trim() || 'all' + const messagingSp = sliceParams('messaging_limit', '100', { profile: messagingProfile }) const messagingExclude = searchParams.get('messaging_exclude') if (messagingExclude) { diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index a2bb3455bae9..1103745e79f6 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -63,7 +63,15 @@ import { toggleSidebarMessagingOpen, unpinSession } from '@/store/layout' -import { $newChatProfile, $profiles, $profileScope, ALL_PROFILES, normalizeProfileKey } from '@/store/profile' +import { + $newChatProfile, + $profiles, + $profileScope, + ALL_PROFILES, + messagingProfileFor, + messagingTotalsKey, + normalizeProfileKey +} from '@/store/profile' import { $activeProjectId, $projects, @@ -884,20 +892,33 @@ export function ChatSidebar({ // Each messaging platform is its own self-managed section: split the // separately-fetched messaging slice by source, newest platform first, rows - // within a platform by recency. Per-platform totals (when a "load more" has - // resolved them) drive the count + whether more remain on disk. + // within a platform by recency. Per-platform totals for the active profile + // (when a "load more" has resolved them) drive the count + whether more + // remain on disk. const messagingGroups = useMemo(() => { if (!messagingSessions.length) { return [] } + // The fetch is already profile-scoped, but a profile switch doesn't wipe + // $messagingSessions (only a gateway-mode switch does), so the previous + // profile's rows would linger until the next refresh lands. Filtering here + // makes the switch instant; it's a no-op once the scoped rows arrive. + const visibleMessaging = showAllProfiles + ? messagingSessions + : messagingSessions.filter(s => normalizeProfileKey(s.profile) === profileScope) + + // Totals are cached per (profile, source), so read the slot belonging to the + // scope this render is showing rather than whichever profile resolved it last. + const messagingProfile = messagingProfileFor(profileScope) + const bySource = new Map() // Rows this platform owns that the Pinned section is showing instead. The // backend's per-platform total counts them, so discount it or "load more" // promises rows that will never appear. const pinnedBySource = new Map() - for (const session of messagingSessions) { + for (const session of visibleMessaging) { const sourceId = normalizeSessionSource(session.source) if (!sourceId) { @@ -918,7 +939,7 @@ export function ChatSidebar({ return [...bySource.entries()] .map(([sourceId, list]) => { const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a)) - const known = messagingPlatformTotals[sourceId] + const known = messagingPlatformTotals[messagingTotalsKey(messagingProfile, sourceId)] const unpinnedKnown = known == null ? null : Math.max(0, known - (pinnedBySource.get(sourceId) ?? 0)) const total = Math.max(ordered.length, unpinnedKnown ?? 0) @@ -934,7 +955,7 @@ export function ChatSidebar({ } }) .sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0])) - }, [messagingSessions, messagingPlatformTotals, messagingTruncated, isPinnedSession]) + }, [messagingSessions, messagingPlatformTotals, messagingTruncated, isPinnedSession, profileScope, showAllProfiles]) // ALL-profiles view: one collapsible group per profile, color on the header // (not on every row). Default profile floats to the top, the rest alpha. diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx index 8b32a8b85ee6..854da87800de 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx @@ -4,11 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SessionInfo, SidebarSessionsResponse } from '@/hermes' import { $cronSessions, + $messagingPlatformTotals, $messagingSessions, $sessions, $sessionsLoading, setCronSessions, + setMessagingPlatformTotals, setMessagingSessions, + setMessagingTruncated, setSessions, setSessionsLoading } from '@/store/session' @@ -70,21 +73,27 @@ vi.mock('@/store/projects', () => ({ $removedSessionIds: { get: () => removed.ids } })) -beforeEach(() => { - listSidebarSessions.mockReset() - listAllProfileSessions.mockReset() - removed.ids = new Set() +// $messagingPlatformTotals / $messagingTruncated are module-level atoms that +// nothing in the app clears on a profile switch, so leaving them dirty between +// tests makes the profile-scope assertions below order-dependent. +const resetSessionStores = () => { setSessions([]) setCronSessions([]) setMessagingSessions([]) + setMessagingPlatformTotals({}) + setMessagingTruncated(false) setSessionsLoading(false) +} + +beforeEach(() => { + listSidebarSessions.mockReset() + listAllProfileSessions.mockReset() + removed.ids = new Set() + resetSessionStores() }) afterEach(() => { - setSessions([]) - setCronSessions([]) - setMessagingSessions([]) - setSessionsLoading(false) + resetSessionStores() }) describe('refreshSessions identity + loading hygiene', () => { @@ -223,7 +232,8 @@ describe('refreshSessions batches slices into one request', () => { expect.objectContaining({ recentsProfile: 'work', recentsExclude: expect.arrayContaining(['cron']), - messagingExclude: expect.arrayContaining(['cron']) + messagingExclude: expect.arrayContaining(['cron']), + messagingProfile: 'work' }) ) }) @@ -248,4 +258,87 @@ describe('refreshSessions batches slices into one request', () => { expect(getCronJobs).toHaveBeenLastCalledWith('all') }) + + // Messaging conversations live in the owning profile's state.db and every + // messaging read windows a shared row budget, so an unscoped fetch let a busy + // profile crowd the quieter ones out of the window — the sidebar's WeChat / + // Telegram sections showed a truncated union no matter which profile was + // selected. + it('scopes the messaging slices to the active profile (all → unified view)', async () => { + listSidebarSessions.mockResolvedValue(sidebar({ sessions: [] })) + listAllProfileSessions.mockResolvedValue({ sessions: [], total: 0 }) + + const scoped = renderHook(() => useSessionListActions({ profileScope: 'work' })) + + await act(async () => { + await scoped.result.current.refreshMessagingSessions() + }) + + // 5th positional arg of listAllProfileSessions is the profile. + expect(listAllProfileSessions.mock.calls.at(-1)?.[4]).toBe('work') + + await act(async () => { + await scoped.result.current.loadMoreMessagingForPlatform('weixin') + }) + + expect(listAllProfileSessions.mock.calls.at(-1)?.[4]).toBe('work') + + const unified = renderHook(() => useSessionListActions({ profileScope: '__all__' })) + + await act(async () => { + await unified.result.current.refreshMessagingSessions() + }) + + expect(listAllProfileSessions.mock.calls.at(-1)?.[4]).toBe('all') + + await act(async () => { + await unified.result.current.refreshSessions() + }) + + expect(listSidebarSessions).toHaveBeenLastCalledWith(expect.objectContaining({ messagingProfile: 'all' })) + }) + + // Regression: per-platform totals are what drive a section's count and its + // "load more" affordance, and scoping the fetch made each total profile- + // specific. Keyed by source alone they leaked across a profile switch — too + // high showed a phantom "load more", too low SUPPRESSED a real one (a known + // total overrides the coarse truncation flag, and nothing re-fetches to + // correct it). Nothing clears these on a profile switch, so the key has to + // carry the profile. + it('keeps per-platform totals separate across a profile switch', async () => { + listSidebarSessions.mockResolvedValue(sidebar({ sessions: [] })) + listAllProfileSessions.mockResolvedValue({ + sessions: [row('wx-1', { profile: 'work', source: 'weixin' })], + total: 42 + }) + + const { rerender, result } = renderHook(({ profileScope }) => useSessionListActions({ profileScope }), { + initialProps: { profileScope: 'work' } + }) + + await act(async () => { + await result.current.loadMoreMessagingForPlatform('weixin') + }) + + expect($messagingPlatformTotals.get()['work:weixin']).toBe(42) + + // Switch profiles: the quieter profile must not inherit work's count, and + // work's resolved total must survive so switching back doesn't re-fetch. + rerender({ profileScope: 'other' }) + + expect($messagingPlatformTotals.get()['other:weixin']).toBeUndefined() + expect($messagingPlatformTotals.get()['work:weixin']).toBe(42) + + // The next profile resolves its own slot, side by side with work's. + listAllProfileSessions.mockResolvedValue({ + sessions: [row('wx-2', { profile: 'other', source: 'weixin' })], + total: 3 + }) + + await act(async () => { + await result.current.loadMoreMessagingForPlatform('weixin') + }) + + expect($messagingPlatformTotals.get()).toMatchObject({ 'other:weixin': 3, 'work:weixin': 42 }) + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index 611484e0d739..8be4b8646b56 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -10,7 +10,7 @@ import { } from '@/lib/session-source' import { setCronJobs } from '@/store/cron' import { $pinnedSessionIds, $sessionsLimit, bumpSessionsLimit, SIDEBAR_SESSIONS_PAGE_SIZE } from '@/store/layout' -import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile' +import { ALL_PROFILES, messagingProfileFor, messagingTotalsKey, normalizeProfileKey } from '@/store/profile' import { $removedSessionIds } from '@/store/projects' import { $messagingSessions, @@ -75,13 +75,20 @@ interface UseSessionListActionsArgs { export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) { const refreshSessionsRequestRef = useRef(0) + // 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, + // ALL_PROFILES keeps the unified view. + const messagingProfile = messagingProfileFor(profileScope) + // Messaging-platform sessions as their own slice, fetched separately from // local recents so each platform renders a self-managed section and never // competes with local chats for the recents page budget. One combined fetch // seeds every platform; the sidebar splits the rows per source. const refreshMessagingSessions = useCallback(async () => { try { - const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { + const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', messagingProfile, { excludeSources: MESSAGING_EXCLUDED_SOURCES }) @@ -96,29 +103,42 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg } catch { // Non-fatal: the messaging sections just stay empty/stale. } - }, []) + }, [messagingProfile]) // Page a single platform's section independently (mirrors the per-profile // pager): fetch that source's next window and merge it back in place, leaving // every other platform's rows untouched. Resolves the platform's exact total. - const loadMoreMessagingForPlatform = useCallback(async (platform: string) => { - const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform - const loaded = $messagingSessions.get().filter(inPlatform).length - - const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', { - source: platform - }) - - const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform) - - setMessagingSessions(prev => [ - ...prev.filter(s => !inPlatform(s)), - ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep()) - ]) - - const total = result.total ?? incoming.length - setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) })) - }, []) + const loadMoreMessagingForPlatform = useCallback( + async (platform: string) => { + const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform + const loaded = $messagingSessions.get().filter(inPlatform).length + + const result = await listAllProfileSessions( + loaded + SIDEBAR_SESSIONS_PAGE_SIZE, + 1, + 'exclude', + 'recent', + messagingProfile, + { source: platform } + ) + + const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform) + + setMessagingSessions(prev => [ + ...prev.filter(s => !inPlatform(s)), + ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep()) + ]) + + const total = result.total ?? incoming.length + // Stamp the total against the profile it was counted in — the same platform + // has a different count per profile, and switching profiles must not inherit + // this one. + const totalsKey = messagingTotalsKey(messagingProfile, platform) + + setMessagingPlatformTotals(prev => ({ ...prev, [totalsKey]: Math.max(total, incoming.length) })) + }, + [messagingProfile] + ) // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created // synchronously (agent tool call or the cron UI), so refreshing here right @@ -161,8 +181,9 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg // the same rows tagged profile="default". // Scope recents to the active profile (not always 'all') so a profile // with few recent sessions isn't windowed out of the cross-profile - // recency page — the empty-history-on-profile-switch bug. Cron + messaging - // stay cross-profile. + // recency page — the empty-history-on-profile-switch bug. Messaging is + // scoped the same way (see messagingProfile); only cron stays + // cross-profile. const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope // Batched: one request opens each profile DB once and returns all three @@ -174,7 +195,8 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg recentsExclude: SIDEBAR_EXCLUDED_SOURCES, cronLimit: CRON_SECTION_LIMIT, messagingLimit: MESSAGING_SECTION_LIMIT, - messagingExclude: MESSAGING_EXCLUDED_SOURCES + messagingExclude: MESSAGING_EXCLUDED_SOURCES, + messagingProfile }) if (refreshSessionsRequestRef.current === requestId) { @@ -236,7 +258,7 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg // Cron *jobs* are a distinct API (getCronJobs), not a session slice. void refreshCronJobs() - }, [profileScope, refreshCronJobs]) + }, [messagingProfile, profileScope, refreshCronJobs]) const loadMoreSessions = useCallback(async () => { bumpSessionsLimit() diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index a1dc61c6095f..3d6d1b494712 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -81,19 +81,39 @@ describe('Hermes REST helpers', () => { recentsExclude: ['cron', 'tool'], cronLimit: 50, messagingLimit: 100, - messagingExclude: ['cron', 'desktop'] + messagingExclude: ['cron', 'desktop'], + messagingProfile: 'work' }) expect(api).toHaveBeenCalledWith( expect.objectContaining({ path: '/api/profiles/sessions/sidebar?recents_profile=work&recents_limit=30&cron_limit=50' + - '&messaging_limit=100&recents_exclude=cron%2Ctool&messaging_exclude=cron%2Cdesktop', + '&messaging_limit=100&messaging_profile=work&recents_exclude=cron%2Ctool' + + '&messaging_exclude=cron%2Cdesktop', timeoutMs: 60_000 }) ) }) + // messagingProfile is optional so existing callers keep the cross-profile + // default; omitting it must still send an explicit messaging_profile=all + // rather than dropping the param. + it('defaults the messaging slice to all profiles when no messagingProfile is given', async () => { + api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } }) + + await listSidebarSessions({ + recentsProfile: 'work', + recentsLimit: 30, + recentsExclude: [], + cronLimit: 50, + messagingLimit: 100, + messagingExclude: [] + }) + + expect(api.mock.calls.at(-1)?.[0].path).toContain('messaging_profile=all') + }) + it('defaults missing sidebar slices to empty session arrays', async () => { api.mockResolvedValue({}) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 9088b1e7a7a2..f69919f8291c 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -424,11 +424,12 @@ export async function listAllProfileSessions( } } -// Batched sidebar slices in one request: recents (scoped to the active profile), -// cron, and messaging. The backend opens each profile's state.db once and runs -// all three filtered queries, replacing three separate listAllProfileSessions -// calls that each reopened + re-counted every profile DB per refresh. Electron -// splices remote profiles per slice (see interceptSessionRequestForRemote). +// Batched sidebar slices in one request: recents and messaging (each scoped to +// the active profile) plus cron. The backend opens each profile's state.db +// once and runs all three filtered queries, replacing three separate +// listAllProfileSessions calls that each reopened + re-counted every profile +// DB per refresh. Electron splices remote profiles per slice (see +// interceptSessionRequestForRemote). export interface SidebarSessionSlice { sessions: SessionInfo[] /** Per-profile "the window came back full, more rows exist on disk" flags — @@ -465,6 +466,11 @@ export interface SidebarSessionsRequest { cronLimit: number messagingLimit: number messagingExclude: string[] + // Messaging conversations live in the owning profile's state.db, so the + // per-platform sections scope like recents does: a concrete profile key + // windows just that profile's rows, 'all' keeps the unified view. Optional + // so callers that don't care about scoping keep the cross-profile default. + messagingProfile?: 'all' | (string & {}) } // The batched /sidebar endpoint shipped later than the per-slice route, so a @@ -504,8 +510,8 @@ function isEndpointMissingError(err: unknown): boolean { // Compatibility fallback: reassemble the three sidebar slices from the // per-slice endpoint, mirroring the batched route's semantics (min_messages=1, -// archived excluded, recency order; recents scoped to the caller's profile, -// cron + messaging cross-profile). Rides the same Electron remote-splice +// archived excluded, recency order; recents + messaging scoped to the caller's +// profiles, cron cross-profile). Rides the same Electron remote-splice // interception as the pre-batching desktop, so remote profiles stay correct. async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise { const [recents, cron, messaging] = await Promise.all([ @@ -513,7 +519,7 @@ async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise + profileScope === ALL_PROFILES ? 'all' : normalizeProfileKey(profileScope) + +// Per-platform conversation totals are per (profile, source): the same platform +// holds a different number of conversations in every profile, so a source-only +// key hands the next profile the previous one's count — a phantom "load more" +// when it reads high, and a *suppressed* one when it reads low (a known total +// overrides the coarse truncation flag, and nothing re-fetches to correct it). +export const messagingTotalsKey = (messagingProfile: string, sourceId: string): string => + `${messagingProfile}:${sourceId}` + // Switch the active context to `name`: leave "All profiles" mode, point new // chats at it, and swap the single live gateway onto its backend (which moves // $activeGatewayProfile → name, so $profileScope follows). diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 19e56b2c1b12..1fbde98766e5 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -377,9 +377,13 @@ export const CRON_SECTION_LIMIT = 50 // platform that exceeds this cap gets its own per-platform "load more". export const $messagingSessions = atom([]) export const MESSAGING_SECTION_LIMIT = 100 -// Exact per-platform conversation totals, keyed by source id. Empty until a -// per-platform "load more" fetch resolves it (the combined seed fetch only -// knows the aggregate), so sections fall back to their loaded count. +// Exact per-platform conversation totals, keyed `profile:source` (build the key +// with messagingTotalsKey). Empty until a per-platform "load more" fetch +// resolves one (the combined seed fetch only knows the aggregate), so sections +// fall back to their loaded count. The profile has to be part of the key +// because the messaging reads are profile-scoped: a source-only key hands the +// next profile the previous one's count, and a profile switch has no other +// eraser (wipeSessionListsForGatewaySwitch fires on gateway mode, not profile). export const $messagingPlatformTotals = atom>({}) // True when the combined seed fetch hit MESSAGING_SECTION_LIMIT, so at least // one platform may have more rows on disk than were loaded. diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py index 2eefe3407f9a..ef03abfbff4c 100644 --- a/hermes_cli/web_routers/profiles.py +++ b/hermes_cli/web_routers/profiles.py @@ -200,17 +200,18 @@ def get_profiles_sessions_sidebar( cron_limit: int = 50, messaging_limit: int = 100, messaging_exclude: str = None, + messaging_profile: str = "all", ): """Batched sidebar session slices — one profile-DB open per refresh. The desktop sidebar needs three source-scoped windows per refresh: recents - (local chats, scoped to the active profile), cron sessions (all profiles), - and messaging-platform sessions (all profiles). Served as three separate - ``/api/profiles/sessions`` calls they reopened every profile's ``state.db`` - three times and re-counted each refresh. This opens each DB once and runs - the three filtered queries together, returning the three windows in one - payload. Read-only and process-light, same row projection and 300s active - heuristic as ``/api/profiles/sessions``. + (local chats, scoped to ``recents_profile``), cron sessions (all profiles), + and messaging-platform sessions (scoped to ``messaging_profile``). Served + as three separate ``/api/profiles/sessions`` calls they reopened every + profile's ``state.db`` three times and re-counted each refresh. This opens + each DB once and runs the three filtered queries together, returning the + three windows in one payload. Read-only and process-light, same row + projection and 300s active heuristic as ``/api/profiles/sessions``. The caller passes the source taxonomy (``recents_exclude`` / ``messaging_exclude`` CSV, ``source=cron`` is implicit) so this stays @@ -221,7 +222,8 @@ def get_profiles_sessions_sidebar( from hermes_state import SessionDB from hermes_cli import profiles as profiles_mod - # cron + messaging are cross-profile; recents is scoped to recents_profile. + # cron is cross-profile; recents and messaging are scoped to their own + # profile params. # Scan every profile once regardless (each DB opened a single time). try: infos = profiles_mod.list_profiles() @@ -233,6 +235,13 @@ def get_profiles_sessions_sidebar( targets.append(("default", profiles_mod.get_profile_dir("default"))) recents_scope = (recents_profile or "all").strip() or "all" + # Messaging-platform conversations (WeChat/Telegram/…) live in the owning + # profile's state.db like everything else, so the sidebar's per-platform + # sections must window ONE profile's rows — not the union. Unscoped, a + # profile with more conversations crowds the others out of the shared + # `messaging_limit` window, so the quieter profile's sections looked + # truncated (or empty) no matter which profile was selected. + messaging_scope = (messaging_profile or "all").strip() or "all" recents_exclude_list = [s for s in (recents_exclude or "").split(",") if s.strip()] messaging_exclude_list = [s for s in (messaging_exclude or "").split(",") if s.strip()] @@ -298,9 +307,10 @@ def _slice(db, *, source=None, exclude=None, cap): recents_truncated[name] = unpinned_count >= recents_cap recents_rows.extend(_tag(profile_rows, name)) cron_rows.extend(_tag(_slice(db, source="cron", cap=cron_cap), name)) - messaging_rows.extend( - _tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name) - ) + if messaging_scope == "all" or name == messaging_scope: + messaging_rows.extend( + _tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name) + ) except Exception as exc: errors.append({"profile": name, "error": str(exc)}) finally: diff --git a/tests/hermes_cli/test_web_server_sidebar_sessions.py b/tests/hermes_cli/test_web_server_sidebar_sessions.py new file mode 100644 index 000000000000..076797a00e5b --- /dev/null +++ b/tests/hermes_cli/test_web_server_sidebar_sessions.py @@ -0,0 +1,149 @@ +"""Regression tests for the batched sidebar endpoint's profile scoping. + +``/api/profiles/sessions/sidebar`` returns three windows per refresh: recents, +cron, and messaging-platform conversations. Recents was scoped to +``recents_profile`` from the start, but messaging was unconditionally +cross-profile — and every messaging read shares one bounded ``messaging_limit`` +window. So a profile with many conversations filled the window from the union +and crowded the quieter profiles out of it, leaving their WeChat / Telegram +sections looking truncated (or empty) no matter which profile was selected. + +These tests pin ``messaging_profile``: a concrete profile windows only its own +rows, ``all`` keeps the unified view, and omitting the param keeps the old +cross-profile default so an older desktop against a newer backend is unaffected. +""" +import pytest + + +_MESSAGING_EXCLUDE = "cron,cli,codex,desktop,gateway,local,tui" + + +@pytest.fixture +def isolated_profiles(monkeypatch, _isolate_hermes_home): + """Isolated default home + one named profile, each with its own state.db.""" + from hermes_constants import get_hermes_home + from hermes_cli import profiles + + default_home = get_hermes_home() + profiles_root = default_home / "profiles" + worker_home = profiles_root / "worker" + for home in (default_home, worker_home): + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("{}\n", encoding="utf-8") + + monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home) + monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root) + return {"default": default_home, "worker": worker_home} + + +@pytest.fixture +def client(monkeypatch, isolated_profiles): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + import hermes_state + from hermes_constants import get_hermes_home + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db") + c = TestClient(app) + c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + return c + + +def _seed_conversation(db_path, session_id, source="weixin"): + """One messaging conversation with a message, so min_messages=1 keeps it.""" + from hermes_state import SessionDB + + db = SessionDB(db_path=db_path) + try: + db.create_session(session_id=session_id, source=source) + db.append_message(session_id, role="user", content="hi") + finally: + db.close() + + +def _sidebar_query(**extra): + params = { + "recents_profile": "worker", + "recents_limit": "20", + # Mirrors the desktop's taxonomy: recents is local chats only, so cron + # and every messaging source are excluded from it. + "recents_exclude": "cron,weixin", + "cron_limit": "50", + "messaging_limit": "100", + "messaging_exclude": _MESSAGING_EXCLUDE, + **extra, + } + return "/api/profiles/sessions/sidebar?" + "&".join( + f"{k}={v}" for k, v in params.items() + ) + + +def _messaging_ids(response): + assert response.status_code == 200 + return {s["id"] for s in response.json()["messaging"]["sessions"]} + + +def test_messaging_slice_windows_only_the_requested_profile(client, isolated_profiles): + """A concrete ``messaging_profile`` returns that profile's rows alone.""" + _seed_conversation(isolated_profiles["default"] / "state.db", "wx-default") + _seed_conversation(isolated_profiles["worker"] / "state.db", "wx-worker") + + assert _messaging_ids(client.get(_sidebar_query(messaging_profile="worker"))) == { + "wx-worker" + } + assert _messaging_ids(client.get(_sidebar_query(messaging_profile="default"))) == { + "wx-default" + } + + +def test_messaging_slice_stays_unified_for_all_and_when_omitted( + client, isolated_profiles +): + """``all`` keeps the union, and so does omitting the param entirely — + back-compat for an older desktop that sends no ``messaging_profile``.""" + _seed_conversation(isolated_profiles["default"] / "state.db", "wx-default") + _seed_conversation(isolated_profiles["worker"] / "state.db", "wx-worker") + + both = {"wx-default", "wx-worker"} + assert both <= _messaging_ids(client.get(_sidebar_query(messaging_profile="all"))) + assert both <= _messaging_ids(client.get(_sidebar_query())) + + +def test_messaging_total_counts_only_the_scoped_rows(client, isolated_profiles): + """``messaging.total`` is what the desktop resolves a platform section's + exact count from, so it has to narrow with the scope — otherwise a scoped + section advertises the union's count and offers a "load more" that can + never deliver.""" + _seed_conversation(isolated_profiles["default"] / "state.db", "wx-default-1") + _seed_conversation(isolated_profiles["default"] / "state.db", "wx-default-2") + _seed_conversation(isolated_profiles["worker"] / "state.db", "wx-worker") + + scoped = client.get(_sidebar_query(messaging_profile="worker")) + assert scoped.status_code == 200 + assert scoped.json()["messaging"]["total"] == 1 + + unified = client.get(_sidebar_query(messaging_profile="all")) + assert unified.status_code == 200 + assert unified.json()["messaging"]["total"] == 3 + + +def test_messaging_scope_does_not_narrow_the_other_slices(client, isolated_profiles): + """Only messaging moves: cron stays cross-profile and recents keeps + following ``recents_profile``, so scoping one slice can't silently + re-window the others.""" + _seed_conversation(isolated_profiles["default"] / "state.db", "cron-default", source="cron") + _seed_conversation(isolated_profiles["worker"] / "state.db", "cron-worker", source="cron") + _seed_conversation(isolated_profiles["worker"] / "state.db", "local-worker", source="desktop") + + response = client.get(_sidebar_query(messaging_profile="worker")) + assert response.status_code == 200 + data = response.json() + + # cron is deliberately unscoped by messaging_profile. + assert {s["id"] for s in data["cron"]["sessions"]} == {"cron-default", "cron-worker"} + # recents still honors recents_profile=worker. + assert {s["id"] for s in data["recents"]["sessions"]} == {"local-worker"} From c9479ebc0e0cb40390f12ba426388ab261588576 Mon Sep 17 00:00:00 2001 From: frendo Date: Fri, 31 Jul 2026 22:28:38 +0800 Subject: [PATCH 2/2] chore(contributors): map frendo.wu@gmail.com -> FrendoWu 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) --- contributors/emails/frendo.wu@gmail.com | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 contributors/emails/frendo.wu@gmail.com diff --git a/contributors/emails/frendo.wu@gmail.com b/contributors/emails/frendo.wu@gmail.com new file mode 100644 index 000000000000..873edd16b620 --- /dev/null +++ b/contributors/emails/frendo.wu@gmail.com @@ -0,0 +1,2 @@ +FrendoWu +# PRs #71530, #71526