diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 81eeaaf8fc..37df5c0d61 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -152,13 +152,14 @@ function trackInboundActivity(roster) { /** Last good cron list, same idea as the roster snapshot. */ const $lastJobs = atom([]) -/** User pref: hide canonical "Bot Chat" sessions from the global Sessions - * sidebar (they always remain in the Bots roster). Persisted via ctx.storage. - * Default ON — Bot Chats are plugin-owned forever-chats, not scratch sessions, - * so keeping them out of the shared recents list is the expected behavior. - * Backed by the core generic `hidden` session flag (session.create hidden:true - * / session.set_hidden); older gateways ignore it and Bot Chats stay visible. */ -const $hideBotChats = atom(true) +// Bot Mode sessions are ALWAYS hidden from the global Sessions sidebar: +// canonical Bot Chats are plugin-owned forever-chats and group-chat member +// sessions are room plumbing — neither is a scratch conversation, and a +// 6-member room would otherwise dump six identical "Group: ..." rows into +// recents. Backed by the core generic `hidden` session flag (session.create +// hidden:true / session.set_hidden); the Bots pane browses them via +// session.list include_hidden. Older gateways ignore the flag and the +// sessions simply stay visible there. /** Bot the Routines tile is scoped to. Follows the live gateway profile * (the bot you're actually chatting with) and roster clicks. */ @@ -268,27 +269,25 @@ async function saveBotMeta(name, patch) { return { serverPersisted: serverOutcome === 'persisted', serverOutcome } } -/** Flip the "hide Bot Chats from the sidebar" pref, persist it, and reconcile - * every known canonical chat via the core session.set_hidden RPC so the change - * applies to already-created Bot Chats (not just future ones). Feature-detected: - * older gateways lack session.set_hidden and simply keep the chats visible. */ -async function setHideBotChats(hidden) { - $hideBotChats.set(hidden) - - try { - Promise.resolve(pluginCtx?.storage?.set?.('hide-bot-chats', hidden)).catch(() => undefined) - } catch { - /* storage unavailable — pref holds for this window only */ - } - - const meta = $botMeta.get() - const ids = Object.values(meta) +/** One-time reconciliation: Bot Mode sessions are always hidden, but rooms + * and Bot Chats created before this policy (or while the old pref was off) + * left visible rows behind. On every plugin load, sweep every session id we + * own — canonical chats from bot meta plus each group room's member + * sessions — through the core session.set_hidden RPC. Idempotent (the DB + * setter is a no-op on already-hidden rows) and feature-detected: older + * gateways lack session.set_hidden and simply keep the rows visible. */ +function hideOwnedBotSessions() { + const canonical = Object.values($botMeta.get()) .map(m => m && m.chat) .filter(Boolean) + const rooms = Object.values($groupChats.get()) + .flatMap(room => Object.values(room?.sessions || {})) + .filter(sid => Boolean(sid) && sid !== true) + const ids = [...new Set([...canonical, ...rooms])] - await Promise.all( + return Promise.all( ids.map(sid => - Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden })).catch(() => undefined) + Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden: true })).catch(() => undefined) ) ) } @@ -2526,7 +2525,8 @@ async function ensureRemoteCanonicalChat(route, profile) { const created = await host.requestProfile(route, 'session.create', { profile, title: 'Bot Chat', - ...($hideBotChats.get() ? { hidden: true } : {}) + // Bot Mode sessions are always hidden from the global sidebar. + hidden: true }) return { runtime: created?.session_id || null, stored: created?.stored_session_id || null } @@ -2744,10 +2744,11 @@ function createCanonicalChat(name) { const res = await host.request('session.create', { profile: name, title: 'Bot Chat', - // Born hidden from the global sidebar when the pref is on. Core applies - // this via the generic `hidden` flag (deferred as pending_hidden until the - // row exists); older gateways ignore the unknown param and it stays visible. - ...($hideBotChats.get() ? { hidden: true } : {}) + // Always born hidden from the global sidebar — Bot Mode sessions are + // plugin-owned. Core applies this via the generic `hidden` flag + // (deferred as pending_hidden until the row exists); older gateways + // ignore the unknown param and it stays visible. + hidden: true }) const sid = res?.stored_session_id const runtime = res?.session_id @@ -3353,7 +3354,8 @@ async function ensureGroupChatSession(group, member) { const created = await requestForBot(member, 'session.create', { profile: member.name, title, - ...($hideBotChats.get() ? { hidden: true } : {}) + // Room member sessions are plumbing — always hidden from the sidebar. + hidden: true }) const stored = created?.stored_session_id || null @@ -6673,7 +6675,9 @@ function useProfileSessions(botName, gatewayGeneration) { return useQuery({ queryKey: [ID, 'profile-sessions', botName, gatewayGeneration], enabled: Boolean(botName), - queryFn: () => host.request('session.list', { profile: botName, limit: PROFILE_SESSION_LIST_LIMIT }), + // include_hidden: this browser exists precisely to see the profile's own + // (always-hidden) Bot Mode sessions alongside its regular ones. + queryFn: () => host.request('session.list', { profile: botName, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true }), refetchInterval: 8000, staleTime: 4000, retry: false @@ -7306,7 +7310,6 @@ function BotsPane() { const [deleting, setDeleting] = useState(null) const [grouping, setGrouping] = useState(null) const [query, setQuery] = useState('') - const hideBotChats = useValue($hideBotChats) const activityToasts = useValue($activityToasts) const sessionsWorkspaceName = useValue($botSessionsWorkspace) const groupChatName = useValue($groupChatWorkspace) @@ -7424,16 +7427,6 @@ function BotsPane() { children: jsx(Codicon, { name: activityToasts ? 'bell' : 'bell-slash' }) }) }), - jsx(Tip, { - label: hideBotChats ? 'Bot Chats hidden from Sessions — click to show' : 'Bot Chats shown in Sessions — click to hide', - children: jsx('button', { - type: 'button', - className: - 'flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground', - onClick: () => void setHideBotChats(!hideBotChats), - children: jsx(Codicon, { name: hideBotChats ? 'eye-closed' : 'eye' }) - }) - }), jsxs(DropdownMenu, { children: [ jsx(Tip, { @@ -7792,18 +7785,9 @@ export default { /* no storage on this shell — defaults stay */ } - // Hydrate the "hide Bot Chats from the sidebar" pref (default ON). - try { - Promise.resolve(ctx.storage?.get?.('hide-bot-chats')) - .then(value => { - if (typeof value === 'boolean') { - $hideBotChats.set(value) - } - }) - .catch(() => undefined) - } catch { - /* no storage — default (hide) stays */ - } + // Bot Mode sessions are always hidden now — the old "hide Bot Chats" + // pref is gone (its stored key is simply ignored). The reconciliation + // sweep below hides any rows born visible under the old pref. // Hydrate the activity-toast pref (default OFF). try { @@ -7855,6 +7839,25 @@ export default { }) host.state.gateway.listen(handleSessionsGatewayTransition) + // Reconciliation sweep: hide every Bot Mode session we know about, on + // load and again on each reconnect (a swap can land on a gateway whose + // rows were created before the always-hidden policy). Deferred a tick so + // the meta/room storage hydrates above have landed; idempotent after that. + // (Feature-guarded: bare vm test harnesses have no setTimeout global.) + const scheduleHideSweep = () => { + try { + setTimeout(() => void hideOwnedBotSessions(), 0) + } catch { + void hideOwnedBotSessions() + } + } + host.state.gateway.listen(state => { + if (state === 'open') { + scheduleHideSweep() + } + }) + scheduleHideSweep() + ctx.register({ id: 'pane', area: 'panes', diff --git a/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs index 4f5a2c41b6..3b068d2750 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs @@ -3,14 +3,16 @@ import { readFileSync } from 'node:fs' import test from 'node:test' import vm from 'node:vm' -// #46: canonical "Bot Chat" sessions can be hidden from the global Sessions -// sidebar via the core generic `hidden` session flag, while staying in the Bots -// roster. The plugin passes hidden:true on session.create when the pref is on, -// and setHideBotChats reconciles existing chats via session.set_hidden. +// Bot Mode sessions are ALWAYS hidden from the global Sessions sidebar +// (canonical Bot Chats and group-chat member sessions alike) via the core +// generic `hidden` session flag. There is no user pref: session.create +// passes hidden:true unconditionally, and hideOwnedBotSessions() sweeps +// every known plugin-owned session id through session.set_hidden so rows +// born visible under the old pref get reconciled. const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') -function loadCreate(hidePref) { +function loadCreate() { const start = source.indexOf('const canonicalCreations = new Map()') const end = source.indexOf('function displayName(', start) const created = [] @@ -26,7 +28,6 @@ function loadCreate(hidePref) { } }, saveBotMeta: () => {}, - $hideBotChats: { get: () => hidePref }, window: { setTimeout: cb => cb() } } const section = source.slice(start, end).concat('\nglobalThis.__c = { createCanonicalChat };\n') @@ -34,26 +35,54 @@ function loadCreate(hidePref) { return { create: context.__c.createCanonicalChat, created } } -test('createCanonicalChat passes hidden:true when the pref is on', async () => { - const { create, created } = loadCreate(true) +test('createCanonicalChat always passes hidden:true — no pref gate', async () => { + const { create, created } = loadCreate() await create('alpha') assert.equal(created.length, 1) assert.equal(created[0].hidden, true) assert.equal(created[0].title, 'Bot Chat') }) -test('createCanonicalChat omits hidden when the pref is off', async () => { - const { create, created } = loadCreate(false) - await create('beta') - assert.equal(created.length, 1) - assert.ok(!('hidden' in created[0]), 'hidden should be omitted, not false') +test('group member session.create is unconditionally hidden too', () => { + // Source contract on ensureGroupChatSession: the create carries a literal + // hidden:true, with no $hideBotChats conditional anywhere in the plugin. + const fn = source.slice(source.indexOf('async function ensureGroupChatSession('), source.indexOf('const GROUP_TURN_TIMEOUT_MS')) + assert.match(fn, /hidden: true/) + assert.equal(source.includes('$hideBotChats'), false, 'the old pref atom must be gone') }) -test('setHideBotChats persists the pref and reconciles known chats via session.set_hidden', () => { - // Source-level: the toggle calls session.set_hidden for each known canonical - // chat id and persists the pref to storage. - const fn = source.slice(source.indexOf('async function setHideBotChats('), source.indexOf('const avatarFetchInflight')) - assert.match(fn, /storage\?\.set\?\.\('hide-bot-chats', hidden\)/) - assert.match(fn, /session\.set_hidden.*session_id: sid, hidden/) - assert.match(fn, /\.map\(m => m && m\.chat\)/) +test('hideOwnedBotSessions sweeps canonical chats AND room member sessions', async () => { + const start = source.indexOf('function hideOwnedBotSessions()') + const end = source.indexOf('/** Fetch server-side avatars', start) + const calls = [] + const context = { + host: { + request: async (method, params) => { + calls.push({ method, params }) + return {} + } + }, + $botMeta: { get: () => ({ alpha: { chat: 'chat-a' }, beta: { chat: 'chat-b' }, gamma: {} }) }, + $groupChats: { + get: () => ({ + Core: { sessions: { alpha: 'room-core-a', beta: 'room-core-b' } }, + Quiet: { sessions: { alpha: 'chat-a' } }, // duplicate id — must dedupe + Legacy: {} // pre-sessions room shape + }) + } + } + const section = source.slice(start, end).concat('\nglobalThis.__h = { hideOwnedBotSessions };\n') + vm.runInNewContext(section, context, { filename: 'h.js' }) + await context.__h.hideOwnedBotSessions() + + const ids = calls.map(c => c.params.session_id).sort() + assert.deepEqual(ids, ['chat-a', 'chat-b', 'room-core-a', 'room-core-b']) + assert.ok(calls.every(c => c.method === 'session.set_hidden' && c.params.hidden === true)) +}) + +test('the Bots session browser lists with include_hidden', () => { + // The one session.list consumer that must see the always-hidden rows. + // (Canonical-chat recovery now goes through profiles.list + // preferred_session_ids, whose resolver already sees hidden rows.) + assert.match(source, /session\.list', \{ profile: botName, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true \}/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs index 3c3665ff8e..fb89358649 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs @@ -134,7 +134,7 @@ test('source contract: bot rows and Active now activate the owner before canonic test('source contract: workspaces disclose the bounded recent-session inventory', () => { assert.match(pluginSource, /queryKey: \[ID, 'profile-sessions', botName, gatewayGeneration\]/) assert.match(pluginSource, /enabled: Boolean\(botName\)/) - assert.match(pluginSource, /host\.request\('session\.list', \{ profile: botName, limit: PROFILE_SESSION_LIST_LIMIT \}\)/) + assert.match(pluginSource, /host\.request\('session\.list', \{ profile: botName, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true \}\)/) assert.match(pluginSource, /Showing the \$\{PROFILE_SESSION_LIST_LIMIT\} most recent sessions\./) assert.match(pluginSource, /No matching sessions in the \$\{PROFILE_SESSION_LIST_LIMIT\} most recent\./) assert.doesNotMatch(pluginSource, /children: 'Activate profile'/) diff --git a/tests/tui_gateway/test_session_hidden_rpc.py b/tests/tui_gateway/test_session_hidden_rpc.py new file mode 100644 index 0000000000..2e197f67f5 --- /dev/null +++ b/tests/tui_gateway/test_session_hidden_rpc.py @@ -0,0 +1,71 @@ +"""RPC-level tests for the generic hidden-session surface (tui_gateway). + +Covers the two seams Bot Mode's "sessions are always hidden" policy leans on: + +* ``session.set_hidden`` resolves a DURABLE stored session id when no live + runtime session matches — plugins reconciling sessions they own (Bot Mode's + hide sweep) hold stored ids for chats that aren't live right now. The old + live-only lookup failed those with 4001 and the sweep silently no-opped. +* ``session.list`` honors ``include_hidden`` so owning surfaces (the Bots + pane's per-profile browser) can still enumerate the rows they hid, while + every default caller keeps the hidden rows dropped. +""" + +import pytest + +import tui_gateway.server as srv +import tui_gateway.methods_session # noqa: F401 (registers the RPC methods) +from hermes_state import SessionDB + + +@pytest.fixture +def db(tmp_path, monkeypatch): + database = SessionDB(tmp_path / "state.db") + monkeypatch.setattr(srv, "_get_db", lambda: database) + try: + yield database + finally: + database.close() + + +def _call(method: str, params: dict) -> dict: + return srv._methods[method](1, params) + + +def _seed(db, sid: str) -> None: + db.create_session(sid, source="desktop") + db._conn.execute("UPDATE sessions SET message_count = 1 WHERE id = ?", (sid,)) + db._conn.commit() + + +def test_set_hidden_resolves_stored_id_without_live_session(db): + """A stored (non-live) session id must be hideable — the sweep path.""" + _seed(db, "stored-chat") + assert srv._find_live_session_by_key("stored-chat") is None + + envelope = _call("session.set_hidden", {"session_id": "stored-chat", "hidden": True}) + assert "error" not in envelope, envelope + assert envelope["result"]["hidden"] is True + assert db.get_session("stored-chat")["hidden"] == 1 + + # And back — unhide through the same durable path. + envelope = _call("session.set_hidden", {"session_id": "stored-chat", "hidden": False}) + assert "error" not in envelope, envelope + assert db.get_session("stored-chat")["hidden"] == 0 + + +def test_set_hidden_unknown_id_still_errors(db): + envelope = _call("session.set_hidden", {"session_id": "no-such-session", "hidden": True}) + assert envelope.get("error"), envelope + + +def test_session_list_include_hidden(db): + _seed(db, "plain-chat") + _seed(db, "bot-chat") + assert db.set_session_hidden("bot-chat", True) is True + + default_rows = _call("session.list", {})["result"]["sessions"] + assert {s["id"] for s in default_rows} == {"plain-chat"} + + all_rows = _call("session.list", {"include_hidden": True})["result"]["sessions"] + assert {s["id"] for s in all_rows} == {"plain-chat", "bot-chat"} diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 281a18b53f..29ebbf332e 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -178,6 +178,11 @@ def _(rid, params: dict) -> dict: deny = frozenset({"kanban", "tool"}) limit = int(params.get("limit", 200) or 200) + # ``include_hidden``: surfaces that OWN hidden sessions (the Bots + # pane's per-profile browser, plugin session pickers) need to list + # them; the flag stays off for the resume picker and every other + # global caller so `hidden` keeps meaning "not in shared lists". + include_hidden = is_truthy_value(params.get("include_hidden", False)) # Over-fetch modestly so per-source filtering doesn't leave us # short; the compression-tip projection in ``list_sessions_rich`` # can also merge rows. @@ -189,6 +194,7 @@ def _(rid, params: dict) -> dict: limit=fetch_limit, order_by_last_active=True, compact_rows=True, + include_hidden=include_hidden, ) if (s.get("source") or "").strip().lower() not in deny ][:limit] @@ -1190,23 +1196,44 @@ def _(rid, params: dict) -> dict: owns it — for plugins that manage their own sessions and don't want them cluttering the shared recents list. Flips the whole compression chain as a unit in the DB layer. + + Resolution is two-tier: a LIVE runtime session id first (which also + covers the not-yet-persisted draft via the ``pending_hidden`` deferral), + then a durable stored id/key against the target profile's state.db — + plugins reconciling sessions they own (e.g. Bot Mode's hide sweep) hold + stored ids for chats that aren't live right now, and the live-only + lookup silently failed those with 4001. """ - session, err = _sess_nowait(params, rid) - if err: - return err hidden = is_truthy_value(params.get("hidden", True)) - with _session_db(session) as db: + session, err = _sess_nowait(params, rid) + if session is not None: + with _session_db(session) as db: + if db is None: + return _db_unavailable_error(rid, code=5007) + key = session["session_key"] + try: + changed = db.set_session_hidden(key, hidden) + if not changed: + # No row yet (write deferred to the first prompt): remember the + # intent so _ensure_session_db_row is born hidden, mirroring the + # pending_title deferral. + session["pending_hidden"] = hidden + return _ok(rid, {"hidden": hidden, "session_key": key}) + except Exception as e: + return _err(rid, 5007, str(e)) + # Durable fallback: a stored session id (or key) in the requested + # profile's db. ``resolve_session_id`` follows key/title aliases the + # same way the REST pin/archive path does. + target = str(params.get("session_id") or "").strip() + with _profile_db(params) as db: if db is None: return _db_unavailable_error(rid, code=5007) - key = session["session_key"] try: - changed = db.set_session_hidden(key, hidden) - if not changed: - # No row yet (write deferred to the first prompt): remember the - # intent so _ensure_session_db_row is born hidden, mirroring the - # pending_title deferral. - session["pending_hidden"] = hidden - return _ok(rid, {"hidden": hidden, "session_key": key}) + resolved = db.resolve_session_id(target) if hasattr(db, "resolve_session_id") else target + if not resolved: + return err + db.set_session_hidden(resolved, hidden) + return _ok(rid, {"hidden": hidden, "session_key": resolved}) except Exception as e: return _err(rid, 5007, str(e)) diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 1ec1c9f253..93dbb745d5 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -182,6 +182,11 @@ when a teammate bot opens it headlessly from the CLI — so bot-to-bot replies and handoffs work without touching your SOUL.md, and your regular sessions stay untouched. +Bot Mode's sessions — each bot's canonical Bot Chat and every group-chat +member session — are always hidden from the global Sessions sidebar. They +live in the Bots pane (roster rows, room views, and each bot's session +browser) instead of interleaving with your own conversations. + Don't want it? Flip it off in **Settings → Plugins → Bots** — the roster, routines pane, and composer middleware unregister live, no restart needed.