From 0ecbcd568af221eba5a30ccb67a90a7e98b59e7f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:04:43 -0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(bot-mode):=20the=20canonical=20Bot=20Ch?= =?UTF-8?q?at=20is=20found=20by=20NAME=20=E2=80=94=20session-id=20pins=20r?= =?UTF-8?q?emoved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bot's forever-chat now has exactly one identity: the session titled "Bot Chat" on that bot's profile. Core UNIQUE(title) makes (profile, 'Bot Chat') an exact registry, and every open consults it directly via session.list {title, include_hidden}. The stored-id pin (ui_meta['hermes-bots'].chat) and its entire verification apparatus — preferred_session_ids resolution, drifted-pin keep branches, last_session grandfathering, dead-pin recovery re-anchoring, newerVisibleBotChat — are removed, not deprecated. Legacy ui_meta.chat keys are ignored and dropped from merges on sight. Every lost-canonical-chat incident (#88146, #88200, #90524, #90705, and five hardening waves) traced to that pointer dangling or being stolen, then later guards welding the wrong session in. A name cannot dangle: corrupt pins self-heal on first click because the pointer is simply never read. Gateway: profiles.list now reports canonical_session per profile row (registry row resolved server-side by title — hidden rows resolve, deny-listed sources and archived rows do not, compression lineages resolve to the live tip), replacing the preferred_session_ids request contract. The roster preview, activity signals, and the /new→/compact guard all read canonical_session, so preview identity and click identity are the same row by construction. No migration shims: this IS the system. --- .../desktop/src/plugins/hermes-bots/plugin.js | 434 ++++-------------- .../tests/active-now-strip.test.mjs | 15 +- .../tests/activity-toasts.test.mjs | 4 +- .../bot-row-opens-canonical-chat.test.mjs | 256 ----------- .../canonical-chat-adopt-before-mint.test.mjs | 155 ------- .../tests/canonical-chat-creation.test.mjs | 12 +- .../canonical-chat-empty-recovery.test.mjs | 117 ----- .../tests/canonical-chat-identity.test.mjs | 417 ----------------- .../tests/canonical-chat-pin.test.mjs | 86 ---- .../tests/canonical-chat-registry.test.mjs | 177 +++++++ .../hermes-bots/tests/hide-bot-chats.test.mjs | 56 +-- .../tests/new-compact-guard.test.mjs | 23 +- .../hermes-bots/tests/roster-preview.test.mjs | 2 +- .../test_profiles_list_canonical_session.py | 189 ++++++++ .../test_profiles_list_preferred_session.py | 211 --------- tui_gateway/methods_profiles.py | 40 +- tui_gateway/methods_session.py | 2 +- 17 files changed, 525 insertions(+), 1671 deletions(-) delete mode 100644 apps/desktop/src/plugins/hermes-bots/tests/bot-row-opens-canonical-chat.test.mjs delete mode 100644 apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-adopt-before-mint.test.mjs delete mode 100644 apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-empty-recovery.test.mjs delete mode 100644 apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs delete mode 100644 apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-pin.test.mjs create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs create mode 100644 tests/tui_gateway/test_profiles_list_canonical_session.py delete mode 100644 tests/tui_gateway/test_profiles_list_preferred_session.py diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 2b6bc62eb886..2aacbe5be93d 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -1276,57 +1276,22 @@ function fallbackSelectionAfterHide(name) { /** 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, then run the - * ownership-based sweep for the rows we DON'T know by id. 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. */ + * left visible rows behind. On every plugin load, sweep the session ids we + * own by id (each group room's member sessions) through the core + * session.set_hidden RPC, then run the TITLE-based ownership sweep for + * everything else — canonical Bot Chats are identified by name (the + * registry row titled "Bot Chat"), so the title sweep is what hides them; + * no stored-id pointer is consulted. 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.entries($botMeta.get()) - .map(([name, meta]) => ({ name, id: meta && meta.chat })) - .filter(entry => Boolean(entry.id)) const rooms = Object.values($groupChats.get()) .flatMap(room => Object.values(room?.sessions || {})) .filter(sid => Boolean(sid) && sid !== true) - // A stale local/server pointer must not be trusted merely because it looks - // like a session id. Resolve every canonical pointer through the backend and - // require the canonical Bot Chat title before the hide write. This is - // deliberately fail-closed: an unavailable/old gateway may leave an old - // Bot Chat visible, but it must never hide an unrelated user conversation. - const verifiedCanonical = Promise.resolve() - .then(() => - host.request('profiles.list', { - include_sessions: true, - preferred_session_ids: Object.fromEntries(canonical.map(entry => [entry.name, entry.id])) - }) - ) - .then(res => { - const profiles = Array.isArray(res?.profiles) ? res.profiles : [] - const valid = [] - - for (const entry of canonical) { - const profile = profiles.find(item => item?.name === entry.name) - const preferred = profile?.preferred_session - const ids = [preferred?.id, preferred?.resolved_id, preferred?.session_id, preferred?.session_key] - .filter(Boolean) - .map(String) - - if (String(preferred?.title || '').trim() === 'Bot Chat' && ids.includes(String(entry.id))) { - valid.push(entry.id) - } - } - - return valid - }) - .catch(() => []) - - const known = verifiedCanonical.then(validCanonical => - Promise.all( - [...new Set([...validCanonical, ...rooms])].map(sid => - Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden: true })).catch(() => undefined) - ) + const known = Promise.all( + [...new Set(rooms)].map(sid => + Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden: true })).catch(() => undefined) ) ) @@ -1573,15 +1538,10 @@ function mergeServerMeta(roster, fetchedAt = 0) { merged.image = mine.image } - // Server metadata is authoritative for the canonical chat pointer. - // Without this deletion sync, ctx.storage resurrects stale sessions - // after the server pin is cleared and even after a full app restart. - if ( - Object.prototype.hasOwnProperty.call(mine, 'chat') && - !Object.prototype.hasOwnProperty.call(server, 'chat') - ) { - delete merged.chat - } + // Legacy canonical-chat pointers (meta.chat) are dead: identity is the + // profile's "Bot Chat" registry row, resolved by name. Drop the key on + // sight so old ui_meta can never look meaningful again. + delete merged.chat // Canonical multi-group metadata is authoritative for the compatibility // scalar too. A server-side `group: null` is represented by omission, @@ -3592,21 +3552,6 @@ function PetTab({ image, onImage }) { * Gates every SOUL.md protocol append below. */ let serverInjectsProtocol = false -/** Pins to resolve precisely on the next roster poll: {profile: chatId}. - * The backend answers "what about THIS conversation" per entry - * (preferred_session), so a row's preview can describe the same session its - * click opens (hermes-agent#88200). Unknown params are ignored by older - * gateways, which simply omit the field. */ -function preferredSessionIds(allMeta) { - const pins = {} - for (const [name, meta] of Object.entries(allMeta || {})) { - if (meta?.chat) { - pins[name] = meta.chat - } - } - return pins -} - function useRoster() { const activeConnectionId = useValue(host.state.connectionId) @@ -3618,13 +3563,11 @@ function useRoster() { // a write can only carry pre-write ui_meta. (Issue time is the // conservative bound — the server answered no earlier than this.) const issuedAt = Date.now() - // Rich rows (last_session, ui_meta, has_avatar) come from the ACTIVE - // gateway's profiles.list — unchanged single-source behavior. - const pins = preferredSessionIds($botMeta.get()) - const local = await host.request( - 'profiles.list', - Object.keys(pins).length ? { preferred_session_ids: pins } : {} - ) + // Rich rows (last_session, canonical_session, ui_meta, has_avatar) + // come from the ACTIVE gateway's profiles.list — the canonical Bot + // Chat is resolved server-side by NAME (the "Bot Chat" registry row), + // so the roster never sends session pointers. + const local = await host.request('profiles.list', {}) // Newer backends inject the teammate-messaging protocol into every // session's system prompt (agent.bot_mode_protocol) — SOUL.md must not // carry a second copy. Older gateways lack the flag: keep appending. @@ -4079,18 +4022,14 @@ function showsHandle(name, meta, bot) { } // ── canonical bot chat ─────────────────────────────────────────────────────── -// Each bot has ONE forever chat, pinned by stored-session id in bot meta -// (meta.chat — synced server-side via ui_meta, so it follows the profile). -// Opening a bot ALWAYS lands there: never "most recent session", which -// drifts whenever the profile is used from the CLI, Sessions mode, or a -// cronjob. The pin only changes through explicit adoption: -// - grandfather: first open of a bot that already has history pins its -// current latest session, so continuity starts from the chat in use -// - fresh bot: opens a draft; when the first message persists a stored -// session, we adopt that id (empty sessions are pruned server-side, so -// pre-creating one at enable time is not possible) -// - recovery: if the pinned id vanishes from the DB (compaction rewrote -// the lineage), re-pin the newest session carrying the canonical title. +// Each bot has ONE forever chat, identified by NAME, never by pointer: the +// session titled exactly "Bot Chat" on that bot's profile. The core +// UNIQUE(title) index makes (profile, "Bot Chat") an exact registry, so every +// open consults that registry directly — there is nothing to verify, re-pin, +// grandfather, or recover. Stored-id pins (ui_meta['hermes-bots'].chat) were +// the previous identity and are REMOVED: every lost-chat incident traced to a +// dangled or stolen pointer that later guards then welded in. Legacy +// ui_meta.chat keys are simply ignored. // In-flight creations, keyed by bot name — double-clicking a row must not // mint two canonical chats. @@ -4101,6 +4040,10 @@ const canonicalCreations = new Map() const PROFILE_SESSION_LIST_LIMIT = 200 let botOpenGeneration = 0 +/** The one canonical title. (profile, CANONICAL_CHAT_TITLE) IS the bot's + * forever-chat identity — see the header above. */ +const CANONICAL_CHAT_TITLE = 'Bot Chat' + async function openStoredBotChat(name, storedId, summary) { if (!storedId || typeof host.openSession !== 'function') { throw new Error('This Hermes Desktop version cannot open stored sessions') @@ -4137,27 +4080,26 @@ async function openStoredBotChat(name, storedId, summary) { return storedId } -/** Adopt-before-mint: the profile may already own a canonical Bot Chat that - * the pin lost track of (pin cleared during an outage, ui_meta rolled back, - * a fork squatting the title). The core UNIQUE title index guarantees at - * most ONE session titled "Bot Chat" per profile db — Profile → Named - * Session is an exact registry, so consult it exactly: `title` asks the - * gateway for an indexed WHERE title = ? lookup (window-free; a busy - * profile can push the forever-chat past any recency window, which would - * re-open the fork loop with a higher trigger threshold). Minting while a - * "Bot Chat" row exists is always wrong twice over: it forks the - * forever-chat AND the new row can never take the (already held) canonical - * title, so the next identity check misreads it and forks again — the - * infinite-fork loop. An older gateway ignores the unknown `title` param - * and returns the plain windowed listing instead — the pre-exact-lookup - * behavior — so the local scan below stays as the compatibility rung. - * include_hidden is required (canonical chats are always hidden); a gateway - * without it simply finds nothing and we fall through to mint. */ +/** True when a session summary IS the canonical registry row. root_title is + * the durable lineage-root title reported by exact-lookup gateways; plain + * title covers windowed listings. */ +function isCanonicalBotChatHistory(history) { + const rootTitle = String(history?.root_title || '').trim() + const title = String(history?.title || '').trim() + return rootTitle === CANONICAL_CHAT_TITLE || (!rootTitle && title === CANONICAL_CHAT_TITLE) +} + +/** THE identity lookup: the profile's session titled exactly "Bot Chat". + * The core UNIQUE title index guarantees at most ONE such row per profile + * db — Profile → Named Session is an exact registry, so consult it exactly: + * `title` asks the gateway for an indexed WHERE title = ? lookup + * (window-free; a busy profile can push the forever-chat past any recency + * window). include_hidden is required (canonical chats are always hidden). */ async function findExistingCanonicalChat(name) { try { const res = await host.request('session.list', { profile: name, - title: 'Bot Chat', + title: CANONICAL_CHAT_TITLE, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true }) @@ -4168,11 +4110,13 @@ async function findExistingCanonicalChat(name) { } } -/** Create the bot's ONE forever chat: a real session opened with a kickoff - * message (the gateway prunes zero-message sessions, so the chat is born - * with the bot introducing itself). Pins the stored id in bot meta and - * returns it. Adopts an existing "Bot Chat" row instead of creating when - * the profile already has one (see findExistingCanonicalChat). */ +/** Create the bot's ONE forever chat: a real session titled "Bot Chat", + * opened with a kickoff message (the gateway prunes zero-message sessions, + * so the chat is born with the bot introducing itself). Adopts the existing + * "Bot Chat" row instead of creating when the profile already has one — + * minting while a "Bot Chat" row exists is always wrong twice over: it + * forks the forever-chat AND the new row can never take the (already held) + * canonical title. */ function createCanonicalChat(name) { const inflight = canonicalCreations.get(name) @@ -4184,12 +4128,9 @@ function createCanonicalChat(name) { const existing = await findExistingCanonicalChat(name) if (existing?.id) { - saveBotMeta(name, { chat: existing.id }) - if (typeof host.openSession === 'function') { // The exact-lookup gateway reports the compression-lineage tip as - // resolved_id; the pin stays the durable row id (same split the - // preferred_session path uses). + // resolved_id; open the tip, the registry row stays the identity. await openStoredBotChat(name, existing.resolved_id || existing.id, existing) } @@ -4198,7 +4139,7 @@ function createCanonicalChat(name) { const res = await host.request('session.create', { profile: name, - title: 'Bot Chat', + title: CANONICAL_CHAT_TITLE, // 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 @@ -4208,10 +4149,6 @@ function createCanonicalChat(name) { const sid = res?.stored_session_id const runtime = res?.session_id - if (sid) { - saveBotMeta(name, { chat: sid }) - } - // Mount the session view FIRST, then send the kickoff — submitting into // an unmounted session left the intro reply invisible until reopen. let opened = false @@ -4236,8 +4173,8 @@ function createCanonicalChat(name) { await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: false }) } } catch { - // The chat already exists. Keep the pin so the next click - // opens it instead of making a second Bot Chat. + // The chat already exists under the canonical title — the next click + // finds it by name instead of making a second Bot Chat. } } @@ -4249,187 +4186,25 @@ function createCanonicalChat(name) { return run } -/** Open the bot's ONE forever chat and return the opened id (or the pin). +/** Open the bot's ONE forever chat and return the opened registry id. * - * Identity rules (hermes-agent#88200 — the row must open the session its - * preview describes): - * - grandfather: no pin + an existing Bot Chat adopts the previewed session - * (`history`, the roster's last_session for this bot) instead of minting - * a new empty chat. Ordinary user conversations are never adopted; - * `last_session` is only a recency hint, not an ownership proof; - * - a live pin is verified through the backend's precise preferred_session - * resolver (hidden rows still resolve; compression lineages resolve to - * the live tip) — never inferred from a paginated, hidden-excluding - * session.list window, which misjudged real hidden pins as gone; - * - transient lookup failures keep the pin: try the stored id as-is, and - * only a rejected open enters recovery. */ -function isCanonicalBotChatHistory(history) { - const rootTitle = String(history?.root_title || '').trim() - const title = String(history?.title || '').trim() - return rootTitle === 'Bot Chat' || (!rootTitle && title === 'Bot Chat') -} + * The whole resolution is one registry consultation: the profile's session + * titled "Bot Chat" exists → open it (lineage tip); it doesn't → create it. + * No id pointer is read or written anywhere in this path. */ +async function openBotCanonicalChat(name) { + const existing = await findExistingCanonicalChat(name) -/** The bot's newest VISIBLE conversation when it should win over the pin, else - * null. - * - * RETAINED FOR THE DEAD-PIN RECOVERY PATH ONLY. This is deliberately NOT - * consulted while the pin is alive: Bot Mode's documented contract is "click - * a Bot to land in its chat — every Bot has a canonical, persistent Bot Chat - * conversation that is created (and pinned) the moment the Bot is born", and - * canonical Bot Chats are ALWAYS hidden from the Sessions sidebar - * (session.create passes hidden:true unconditionally — see - * hide-bot-chats.test.mjs). The bot row is therefore the ONLY door to the - * forever-chat; preferring a newer session here walls the relationship off - * behind a door that no longer leads to it. - * - * Guards, all of which matter: - * - the canonical Bot Chat itself is never "newer" (it IS the pin), so - * plumbing can't shadow itself; - * - an empty draft is skipped: clicking a bot right after a stray ⌘N would - * otherwise open a blank chat instead of the conversation; - * - identical ids mean the pin already points there — nothing to switch to. - * Returns the stored id so callers keep using the normal open path. */ -function newerVisibleBotChat(pinned, history) { - const id = history?.id - - if (!id || id === pinned || isCanonicalBotChatHistory(history)) { - return null + if (existing?.id && typeof host.openSession === 'function') { + await openStoredBotChat(name, existing.resolved_id || existing.id, existing) + return existing.id } - // `message_count` is absent on older gateways — treat unknown as real - // history rather than discarding a legitimate conversation. - const count = history?.message_count - - if (typeof count === 'number' && count <= 0) { - return null - } - - return id -} - -async function openBotCanonicalChat(name, pinned, history) { - if (!pinned) { - // Grandfather only an actual Bot Chat. `last_session` is merely the most - // recent row for the profile; adopting it blindly can claim an unrelated - // user conversation and the hide sweep would then hide that conversation. - const adoptId = isCanonicalBotChatHistory(history) ? history.id : null - if (adoptId && typeof host.openSession === 'function') { - await openStoredBotChat(name, adoptId, history) - saveBotMeta(name, { chat: adoptId }) - return adoptId - } - return createCanonicalChat(name) - } - - // Precise verification. An older gateway ignores the unknown param and - // omits the key — that reads as a lookup failure below, NOT as a missing - // session, so legacy backends keep the try-as-is escape hatch. - let preferred - let lookupFailed = false - try { - const res = await host.request('profiles.list', { - include_sessions: true, - preferred_session_ids: { [name]: pinned } - }) - const row = (res?.profiles ?? []).find(p => p.name === name) - preferred = row?.preferred_session - if (preferred === undefined) { - lookupFailed = true - } - } catch { - lookupFailed = true - } - - if (lookupFailed) { - // Transient gateway state (or an older backend): the pin is innocent - // until proven guilty — try it as-is. A rejected open is still ambiguous: - // it can be the same reconnect/hydration outage that broke this lookup, so - // preserve the forever-chat pin and surface Retry instead of forking it. - return openStoredBotChat(name, pinned, history) - } - - if (preferred && isCanonicalBotChatHistory(preferred)) { - // The pin is alive and healthy — open it. This is the whole contract: - // "Click a Bot to land in its chat — every Bot has a canonical, - // persistent Bot Chat conversation that is created (and pinned) the - // moment the Bot is born." - // - // A newer-visible-session preference used to sit here, so that a bot row - // landed on the user's most recent conversation instead of the pin. It - // was reverted (2026-08-22) because it is unsound given how Bot Mode - // stores these chats: canonical Bot Chats are ALWAYS hidden from the - // Sessions sidebar (session.create passes hidden:true unconditionally, - // and hideOwnedBotSessions sweeps any that were born visible). The bot - // row is therefore the ONLY door to the forever-chat, so preferring a - // newer session did not merely re-order two equal entry points — it made - // the pinned relationship unreachable from anywhere in the UI. Reported - // symptom: a bot's whole build history became invisible, while the row - // previewed one session and opened another. - // - // The bug that motivated the preference — "I start a new chat with a bot, - // click another bot, click back, and my new chat is gone" — has a - // non-destructive answer: scratch sessions started via "New chat with - // this agent" are NOT plumbing-titled, so the hide sweep leaves them in - // the Sessions sidebar. They are reachable there; they simply are not the - // bot row's target, which is by design. - try { - await openStoredBotChat(name, preferred.resolved_id || preferred.id, preferred) - return pinned - } catch (error) { - // The precise lookup JUST confirmed this session exists, so a failed - // open is transient (reconnect, backend restart). Clearing the pin or - // minting a replacement here would fork the bot's forever-chat on - // every hiccup — report and keep everything as it is. - throw error - } - } - - if (preferred) { - // The stored pointer resolved to a real session, but not to Bot Mode's - // titled plumbing session. Two legitimate ways to get here, and neither - // means "mint a new chat": - // - the pin IS the forever-chat but its title drifted (grandfathered - // pre-convention chats; the LLM auto-titler renaming an untitled row - // after a silent unique-title conflict dropped "Bot Chat"). A pinned - // session carrying real history is the user's conversation — forking - // away from it silently loses their thread, the exact bug this whole - // resolver exists to prevent. The pin is the durable intent: keep it - // and open it, even when some other (likely forked) row holds the - // "Bot Chat" title. The hide sweep only matches plumbing titles, so - // an adopted odd-titled chat is never swept out of the user's - // ordinary session list. - // - the pin resolves to an EMPTY non-plumbing session (a stray draft): - // genuinely corrupted metadata. Clear it — createCanonicalChat then - // adopts the profile's existing "Bot Chat" row if one exists before - // ever creating a new one. - const messageCount = Number(preferred.message_count) || 0 - - if (messageCount > 0) { - await openStoredBotChat(name, preferred.resolved_id || preferred.id, preferred) - return pinned - } - - await saveBotMeta(name, { chat: null }) - return createCanonicalChat(name) - } - - // Definitively gone (db reset, or the lineage was rewritten past - // recovery): re-anchor on the previewed session when there is one. - // A previewed row is safe to re-anchor only when it is Bot Mode plumbing. - // Otherwise a stale pin must not steal the profile's ordinary latest chat. - const recoveryId = isCanonicalBotChatHistory(history) ? history.id : null - if (recoveryId && typeof host.openSession === 'function') { - await openStoredBotChat(name, recoveryId, history) - saveBotMeta(name, { chat: recoveryId }) - return recoveryId - } - saveBotMeta(name, { chat: null }) return createCanonicalChat(name) } -async function prepareBotSource(bot, pinnedChat) { +async function prepareBotSource(bot) { if (!bot.sourceScoped) { - return pinnedChat + return } if (typeof host.ensureAgent !== 'function') { @@ -4439,7 +4214,7 @@ async function prepareBotSource(bot, pinnedChat) { await host.ensureAgent(bot.connectionId, bot.name) if (!bot.remoteSource) { - return pinnedChat + return } const liveId = String(typeof host.activeConnectionId === 'function' ? host.activeConnectionId() || '' : '').trim() @@ -4449,18 +4224,8 @@ async function prepareBotSource(bot, pinnedChat) { throw new Error(`Still on ${liveId || 'this device'}, not ${bot.connectionLabel || targetId}`) } - // Thin rows deliberately omit metadata from the active source. Once their - // owner is active, recover that source's canonical-chat pointer so - // same-named agents never reuse or overwrite each other's pin. - try { - const refreshed = await host.request('profiles.list', {}) - const owner = refreshed?.profiles?.find(profile => profile.name === bot.name) - - return owner?.ui_meta?.['hermes-bots']?.chat || null - } catch { - // Metadata refresh is best-effort; canonical creation remains the fallback. - return null - } + // The canonical chat is found by NAME on the now-active owner source — + // there is no per-source pointer to recover. } function displayName(bot, meta) { @@ -6095,18 +5860,18 @@ function generatedSessionTitle(session, preview) { const ACTIVE_WINDOW_S = 90 /** The session whose activity best represents this bot — the FRESHER of the - * pinned canonical Bot Chat (preferred_session) and the profile's newest - * visible conversation (last_session). + * canonical Bot Chat (canonical_session, the profile's "Bot Chat" registry + * row resolved server-side by name) and the profile's newest visible + * conversation (last_session). * * Canonical Bot Chats are hidden from the session list by design, so * last_session alone never sees them: a bot you talk to all day through its * Bot Chat reads "6d ago" because its newest VISIBLE session is a week old. - * #88690 moved the preview text to preferred_session but left every activity - * signal (age label, pulse dot, unread watermark, recency sort) on - * last_session. All of them key off this helper now. Older gateways without - * the preferred_session resolver degrade to last_session unchanged. */ + * Every activity signal (age label, pulse dot, unread watermark, recency + * sort) keys off this helper. Older gateways without the canonical_session + * field degrade to last_session unchanged. */ function botActivitySession(bot) { - const preferred = bot?.preferred_session + const preferred = bot?.canonical_session const last = bot?.last_session if (!preferred || !last) { @@ -6173,7 +5938,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { // (age label, pulse dot) follow the same rule via botActivitySession: // the canonical Bot Chat is hidden from last_session, so keying age off // last_session alone shows "6d ago" on a bot you just messaged. - const previewSession = bot.preferred_session || last + const previewSession = bot.canonical_session || last const activitySession = botActivitySession(bot) // A live kanban/tool worker counts as activity (#90268): pulse + fresh // age while it runs, falling back to chat activity when it ends. @@ -6241,8 +6006,6 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { return } - let pinnedChat = meta?.chat - if (!bot.remoteSource && $botUnread.get()[bot.name]) { const next = { ...$botUnread.get() } delete next[bot.name] @@ -6252,7 +6015,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { // Activate the owner first so every canonical-chat RPC lands on the // backend that owns this bot's state database. try { - pinnedChat = await prepareBotSource(bot, pinnedChat) + await prepareBotSource(bot) } catch (error) { host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`) @@ -6264,13 +6027,10 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { } try { - // `previewSession` prefers the PIN, and so does the click — preview - // identity and click identity are the same session by construction - // (#88200). The roster's freshest visible session is deliberately NOT - // passed: the row's job is to land in the bot's forever-chat, which is - // the only door to it (canonical Bot Chats are always hidden from the - // Sessions sidebar). - const id = await openBotCanonicalChat(bot.name, pinnedChat, previewSession) + // Identity is the NAMED registry row (profile → session titled + // "Bot Chat"), resolved fresh on every click — preview identity and + // click identity agree because both describe that same row (#88200). + const id = await openBotCanonicalChat(bot.name) if (generation === botOpenGeneration && id) { return @@ -11315,10 +11075,8 @@ function BotsPane() { } void (async () => { - let pinnedChat = botRosterMeta(bot, allMeta)?.chat - try { - pinnedChat = await prepareBotSource(bot, pinnedChat) + await prepareBotSource(bot) } catch (error) { host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`) @@ -11330,11 +11088,7 @@ function BotsPane() { } try { - const id = await openBotCanonicalChat( - bot.name, - pinnedChat, - bot.preferred_session || bot.last_session - ) + const id = await openBotCanonicalChat(bot.name) if (generation === botOpenGeneration && id) { return @@ -11854,11 +11608,17 @@ export default { if (slashNew) { const activeBot = $selectedBot.get() - const meta = activeBot ? $botMeta.get()[activeBot] : null - const pinnedId = meta?.chat || null + // Canonical identity is the profile's "Bot Chat" registry row — + // read it from the roster cache (canonical_session, resolved + // server-side by name), matching either the durable row id or + // the compression-lineage tip currently on screen. + const roster = $lastRoster.get() + const row = Array.isArray(roster) ? roster.find(bot => bot?.name === activeBot) : null + const canonical = row?.canonical_session || null const currentId = host.activeSessionId?.get?.() ?? null + const canonicalIds = [canonical?.id, canonical?.resolved_id].filter(Boolean).map(String) - if (activeBot && pinnedId && currentId && String(currentId) === String(pinnedId)) { + if (activeBot && currentId && canonicalIds.includes(String(currentId))) { host.notify({ kind: 'info', title: 'This chat never resets', diff --git a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs index 057b61e46bcc..6d921aa75c01 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs @@ -84,11 +84,11 @@ test('roster without profiles never throws', () => { // ── botActivitySession: canonical Bot Chat activity counts (hermes-agent "6d ago" bug) ── -test('botActivitySession picks the fresher preferred_session over a stale last_session', () => { +test('botActivitySession picks the fresher canonical_session over a stale last_session', () => { const botActivitySession = loadBotActivitySession() const bot = { // Canonical Bot Chat (hidden from session lists): messaged seconds ago. - preferred_session: { id: 'bot-chat', last_active: NOW / 1000 - 5, preview: 'fresh DM' }, + canonical_session: { id: 'bot-chat', last_active: NOW / 1000 - 5, preview: 'fresh DM' }, // Newest VISIBLE session: 6 days old — what last_session alone reports. last_session: { id: 'old-scratch', last_active: NOW / 1000 - 6 * 86400, preview: 'ancient' } } @@ -98,7 +98,7 @@ test('botActivitySession picks the fresher preferred_session over a stale last_s test('botActivitySession keeps last_session when it is the fresher one', () => { const botActivitySession = loadBotActivitySession() const bot = { - preferred_session: { id: 'bot-chat', last_active: NOW / 1000 - 3600 }, + canonical_session: { id: 'bot-chat', last_active: NOW / 1000 - 3600 }, last_session: { id: 'scratch', last_active: NOW / 1000 - 10 } } assert.equal(botActivitySession(bot).id, 'scratch') @@ -107,7 +107,7 @@ test('botActivitySession keeps last_session when it is the fresher one', () => { test('botActivitySession degrades to whichever side exists (older gateways / no pin)', () => { const botActivitySession = loadBotActivitySession() assert.equal(botActivitySession({ last_session: { id: 'only', last_active: 1 } }).id, 'only') - assert.equal(botActivitySession({ preferred_session: { id: 'pin', last_active: 1 } }).id, 'pin') + assert.equal(botActivitySession({ canonical_session: { id: 'pin', last_active: 1 } }).id, 'pin') assert.equal(botActivitySession({}), null) assert.equal(botActivitySession(null), null) }) @@ -117,7 +117,7 @@ test('activeBots counts Bot Chat activity that last_session cannot see', () => { const bots = [ { name: 'default', - preferred_session: { last_active: NOW / 1000 - 5 }, + canonical_session: { last_active: NOW / 1000 - 5 }, last_session: { last_active: NOW / 1000 - 6 * 86400 } } ] @@ -179,7 +179,6 @@ test('ActiveNowStrip renders above the roster, is a live region, and is click-ac // a list key; a `key:` prop leaves chips unkeyed (index identity). assert.match(source, /\}, botRosterKey\(bot\)\)\s*\}\)\s*\]\s*\}\)\s*\}\s*\/\*\* Assign a bot to a group/s) assert.match(source, /jsx\(BotFace,\s*\{[\s\S]*?mood: 'work'/) - assert.match(source, /let pinnedChat = botRosterMeta\(bot, allMeta\)\?\.chat/) - assert.match(source, /await prepareBotSource\(bot, pinnedChat\)/) - assert.match(source, /bot\.preferred_session \|\| bot\.last_session/) + assert.match(source, /await prepareBotSource\(bot\)/) + assert.match(source, /bot\.canonical_session \|\| last/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs index 3ba2d2968e75..9b7a5d3e1bf1 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs @@ -77,13 +77,13 @@ test('pref defaults OFF and persists via ctx.storage under activity-toasts', () test('activity in the hidden canonical Bot Chat still badges (the "6d ago" class)', () => { // The canonical Bot Chat is hidden from session lists, so last_session - // never advances when a DM lands there — only preferred_session does. + // never advances when a DM lands there — only canonical_session does. const t = loadTracker(false) const at = ts => [ { name: 'researcher', last_session: { last_active: 100, preview: 'ancient scratch chat' }, - preferred_session: { last_active: ts, preview: 'Message from writer: hi' } + canonical_session: { last_active: ts, preview: 'Message from writer: hi' } } ] t.trackInboundActivity(at(150)) // seeding poll diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bot-row-opens-canonical-chat.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bot-row-opens-canonical-chat.test.mjs deleted file mode 100644 index 320532624850..000000000000 --- a/apps/desktop/src/plugins/hermes-bots/tests/bot-row-opens-canonical-chat.test.mjs +++ /dev/null @@ -1,256 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import test from 'node:test' -import vm from 'node:vm' - -const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') - -/** - * A bot row must open the bot's canonical, pinned Bot Chat. - * - * This file previously asserted the opposite — that a row opens the user's - * NEWEST visible conversation — after a report that a freshly started chat - * seemed to vanish when clicking away and back. That preference was reverted - * (2026-08-22): canonical Bot Chats are ALWAYS hidden from the Sessions - * sidebar (see hide-bot-chats.test.mjs), so the bot row is the ONLY door to - * the forever-chat. Preferring a newer session made the pinned relationship - * unreachable from anywhere in the UI — a user lost an entire bot-building - * history behind a row that previewed one session and opened another. - * - * The original complaint has a non-destructive answer: scratch sessions from - * "New chat with this agent" are not plumbing-titled, so the hide sweep leaves - * them visible in the Sessions sidebar. They are reachable there; they are - * simply not what the bot row targets. - * - * Documented contract (docs/user-guide/bot-mode): "Click a Bot to land in its - * chat — every Bot has a canonical, persistent Bot Chat conversation that is - * created (and pinned) the moment the Bot is born." - */ -function loadOpenPath({ openSession, request }) { - const start = source.indexOf('const canonicalCreations = new Map()') - const end = source.indexOf('function displayName(', start) - - assert.notEqual(start, -1, 'canonical creation section is missing') - assert.notEqual(end, -1, 'canonical creation section delimiter is missing') - - const saved = [] - const opened = [] - const context = { - host: { - openSession: async (id, options) => { - opened.push({ id, options }) - - return openSession(id, options) - }, - request: async (method, params) => request(method, params) - }, - saveBotMeta: (name, patch) => saved.push({ name, patch: JSON.parse(JSON.stringify(patch)) }), - $hideBotChats: { get: () => false }, - window: { setTimeout: callback => callback() } - } - - const section = source - .slice(start, end) - .concat('\nglobalThis.__open = { openBotCanonicalChat, newerVisibleBotChat };\n') - - vm.runInNewContext(section, context, { filename: 'canonical-open.js' }) - - return { ...context.__open, saved, opened } -} - -const noRequests = async () => ({}) - -/** A live, healthy pin: `profiles.list` resolves it to the canonical Bot Chat. - * That verification is the gate the newer-conversation preference sits behind - * — with a dead or unverified pin the bot must NOT adopt the profile's latest - * row (that would claim an unrelated conversation). */ -const healthyPin = - (pinned = 'pinned-bot-chat') => - async (method, params) => { - if (method === 'profiles.list') { - const name = Object.keys(params?.preferred_session_ids ?? { ops: 1 })[0] - - return { - profiles: [{ name, preferred_session: { id: pinned, resolved_id: pinned, title: 'Bot Chat' } }] - } - } - - return {} - } - -test('a healthy pin wins over a newer conversation — the row lands in the Bot Chat', async () => { - const runtime = loadOpenPath({ openSession: async () => undefined, request: healthyPin() }) - - // The roster's freshest visible session is a real conversation the user - // started after the pin was made. It must NOT displace the forever-chat: - // the pinned chat is hidden from Sessions, so the row is its only door, - // while this newer session remains reachable in the Sessions sidebar. - const history = { id: 'new-chat', title: '릴시아 카피 회의', message_count: 12, last_active: 9000 } - - const result = await runtime.openBotCanonicalChat('plan', 'pinned-bot-chat', history, history) - - assert.equal(result, 'pinned-bot-chat', 'should return the pinned Bot Chat') - assert.equal(runtime.opened.length, 1) - assert.equal(runtime.opened[0].id, 'pinned-bot-chat', 'must open the pinned forever-chat') - assert.equal(runtime.opened[0].options.profile, 'plan') - assert.equal( - runtime.opened[0].options.keepAllProfilesScope, - false, - 'clicking a bot moves the workspace onto that bot' - ) -}) - -/** - * The REAL call shape from the roster row: `previewSession` is - * `bot.preferred_session || last`, so on a pinned bot it resolves to the PIN. - * Preview identity and click identity are the same session by construction - * (#88200) — which is exactly the property the reverted newer-session - * preference broke. - */ -test('real roster call: preview identity and click identity are the same session', async () => { - const runtime = loadOpenPath({ openSession: async () => undefined, request: healthyPin('pin-1') }) - - const pinnedPreview = { id: 'pin-1', title: 'Bot Chat', preview: 'plumbing' } - - // Mirrors: openBotCanonicalChat(bot.name, pinnedChat, previewSession) - const result = await runtime.openBotCanonicalChat('plan', 'pin-1', pinnedPreview) - - assert.equal(result, 'pin-1', 'must open the pinned Bot Chat the row previewed') - assert.equal(runtime.opened[0].id, 'pin-1') -}) - -/** Regression guard for the revert: the open path must not consult the - * newer-visible-session predicate while the pin is alive. Bot Chats are - * hidden from Sessions, so a row that prefers a newer session strands the - * forever-chat with no reachable entry point. */ -test('the healthy-pin branch never prefers a newer visible session', () => { - const start = source.indexOf('if (preferred && isCanonicalBotChatHistory(preferred)) {') - const end = source.indexOf('if (preferred) {', start) - - assert.notEqual(start, -1, 'healthy-pin branch is missing') - - const branch = source.slice(start, end) - - assert.equal( - branch.includes('newerVisibleBotChat('), - false, - 'a healthy pin must be opened directly — no newer-session preference' - ) -}) - -test('the canonical Bot Chat itself never counts as "newer" (it IS the pin)', () => { - const runtime = loadOpenPath({ openSession: async () => undefined, request: noRequests }) - - assert.equal(runtime.newerVisibleBotChat('pin-1', { id: 'hidden-plumbing', title: 'Bot Chat' }), null) - assert.equal( - runtime.newerVisibleBotChat('pin-1', { id: 'hidden-plumbing', root_title: 'Bot Chat', title: '자동 제목' }), - null - ) -}) - -test('an empty draft never displaces the pinned conversation', () => { - const runtime = loadOpenPath({ openSession: async () => undefined, request: noRequests }) - - assert.equal(runtime.newerVisibleBotChat('pin-1', { id: 'blank', title: '', message_count: 0 }), null) -}) - -test('a gateway that omits message_count still yields the newer session', () => { - const runtime = loadOpenPath({ openSession: async () => undefined, request: noRequests }) - - assert.equal(runtime.newerVisibleBotChat('pin-1', { id: 'legacy', title: '대화' }), 'legacy') -}) - -test('history that IS the pin changes nothing', () => { - const runtime = loadOpenPath({ openSession: async () => undefined, request: noRequests }) - - assert.equal(runtime.newerVisibleBotChat('same-id', { id: 'same-id', title: '대화', message_count: 5 }), null) -}) - -/** - * Every path that mounts a bot's chat must move the workspace onto that bot. - * - * `keepAllProfilesScope` defaults to TRUE in the SDK, which keeps - * `$activeGatewayProfile` pointing at whatever profile was active before the - * click. Bot Mode wants the opposite: clicking a bot IS a profile switch, and - * leaving the scope behind meant sessions created afterwards were filed under - * the previous bot's profile (measured: four new chats started from three - * different bots all landed in `ops`). - * - * The newly-minted-chat path is asserted separately from the stored-chat path - * because they are different call sites; a guard on only one of them let the - * other regress silently. - */ -function creationRuntime({ failFirstOpen = false } = {}) { - let opens = 0 - - return loadOpenPath({ - openSession: async () => { - opens += 1 - - if (failFirstOpen && opens === 1) { - throw new Error('stored row not persisted yet') - } - - return undefined - }, - request: async method => { - if (method === 'session.create') { - return { stored_session_id: 'fresh-stored', session_id: 'fresh-runtime' } - } - - return {} - } - }) -} - -test('a newly minted Bot Chat opens with the workspace following the bot', async () => { - const runtime = creationRuntime() - - // No pin and no adoptable history — the real "first click on a bot" path. - const result = await runtime.openBotCanonicalChat('plan', null, null, null) - - assert.equal(result, 'fresh-stored') - assert.ok(runtime.opened.length >= 1, 'the new chat is mounted') - - for (const entry of runtime.opened) { - assert.equal(entry.options.keepAllProfilesScope, false, 'creating a bot chat must move the workspace onto that bot') - assert.equal(entry.options.profile, 'plan') - } -}) - -test('the post-kickoff retry open also follows the bot', async () => { - const runtime = creationRuntime({ failFirstOpen: true }) - - await runtime.openBotCanonicalChat('plan', null, null, null) - - assert.equal(runtime.opened.length, 2, 'first open fails, retry runs after the kickoff') - assert.equal( - runtime.opened[1].options.keepAllProfilesScope, - false, - 'the retry must not silently fall back to the SDK default' - ) -}) - -/** With the newer-session preference gone there is no "try the newer chat, - * fall back to the pin" dance: a verified pin is opened directly, and a - * failed open of a JUST-verified session is transient (reconnect, backend - * restart), so it propagates rather than forking the forever-chat. */ -test('a failed open of a verified pin surfaces instead of forking the chat', async () => { - const runtime = loadOpenPath({ - openSession: async () => { - throw new Error('session not found') - }, - request: healthyPin('pin-1') - }) - - await assert.rejects( - () => runtime.openBotCanonicalChat('ops', 'pin-1', { id: 'pin-1', title: 'Bot Chat' }), - /session not found/ - ) - - assert.deepEqual( - runtime.saved, - [], - 'a transient failure must not clear the pin or mint a replacement' - ) -}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-adopt-before-mint.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-adopt-before-mint.test.mjs deleted file mode 100644 index 06d982524017..000000000000 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-adopt-before-mint.test.mjs +++ /dev/null @@ -1,155 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import test from 'node:test' -import vm from 'node:vm' - -// Regression suite for the infinite-fork loop (hermes-agent#88200 follow-up): -// the core UNIQUE title index means at most one session per profile db holds -// the "Bot Chat" title. When a fork squats it, every later mint's title is -// silently dropped, the LLM titler renames the untitled row, and the next -// title-based identity check misreads the fresh chat as "not plumbing" — -// clearing the pin and minting again, forever. Two invariants kill the loop: -// 1. createCanonicalChat ADOPTS an existing "Bot Chat" row before creating. -// 2. A pin that resolves to a NON-plumbing session with real history is the -// user's conversation — keep it; only an empty stray draft is replaced. - -const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') - -function loadOpenPath({ openSession, request }) { - const start = source.indexOf('const canonicalCreations = new Map()') - const end = source.indexOf('function displayName(', start) - const saved = [] - const requests = [] - const context = { - host: { - openSession, - request: async (method, params) => { - requests.push({ method, params: JSON.parse(JSON.stringify(params ?? null)) }) - return request(method, params) - } - }, - saveBotMeta: (name, patch) => saved.push({ name, patch: JSON.parse(JSON.stringify(patch)) }), - $hideBotChats: { get: () => false }, - window: { setTimeout: callback => callback() } - } - const section = source - .slice(start, end) - .concat('\nglobalThis.__open = { createCanonicalChat, openBotCanonicalChat };\n') - - assert.notEqual(start, -1, 'canonical creation section is missing') - assert.notEqual(end, -1, 'canonical creation section delimiter is missing') - vm.runInNewContext(section, context, { filename: 'canonical-adopt.js' }) - return { ...context.__open, saved, requests } -} - -// ── invariant 1: adopt-before-mint ────────────────────────────────────────── - -test('createCanonicalChat adopts an existing hidden "Bot Chat" row instead of creating', async () => { - const runtime = loadOpenPath({ - openSession: async () => undefined, - request: async method => { - if (method === 'session.list') { - return { - sessions: [ - { id: 'newer-ordinary', title: 'help me with x', message_count: 12 }, - { id: 'real-forever-chat', title: 'Bot Chat', message_count: 930 } - ] - } - } - if (method === 'session.create') { - throw new Error('must not create: the profile already owns a Bot Chat') - } - return {} - } - }) - - assert.equal(await runtime.createCanonicalChat('ops'), 'real-forever-chat') - assert.deepEqual(runtime.saved, [{ name: 'ops', patch: { chat: 'real-forever-chat' } }]) - const list = runtime.requests.find(r => r.method === 'session.list') - assert.equal(list?.params?.include_hidden, true, - 'adoption scan must see hidden rows — canonical chats are always hidden') -}) - -test('createCanonicalChat still creates when no Bot Chat row exists', async () => { - const runtime = loadOpenPath({ - openSession: async () => undefined, - request: async method => { - if (method === 'session.list') { - return { sessions: [{ id: 'ordinary', title: 'help me with x', message_count: 3 }] } - } - if (method === 'session.create') return { stored_session_id: 'fresh-1', session_id: 'rt-1' } - return {} - } - }) - - assert.equal(await runtime.createCanonicalChat('newbie'), 'fresh-1') -}) - -test('createCanonicalChat mints when the adoption scan fails (older gateway)', async () => { - const runtime = loadOpenPath({ - openSession: async () => undefined, - request: async method => { - if (method === 'session.list') throw new Error('unknown method') - if (method === 'session.create') return { stored_session_id: 'fresh-2', session_id: 'rt-2' } - return {} - } - }) - - assert.equal(await runtime.createCanonicalChat('legacy'), 'fresh-2') -}) - -// ── invariant 2: a resolving pin with history is never abandoned ──────────── - -test('pin resolving to a renamed session WITH history keeps the pin (no fork)', async () => { - const opened = [] - const runtime = loadOpenPath({ - openSession: async id => opened.push(id), - request: async method => { - if (method === 'profiles.list') { - return { - profiles: [{ - name: 'ops', - preferred_session: { - id: 'grandfathered', resolved_id: 'grandfathered', - root_title: 'Use computer use to inspect…', title: 'Use computer use to inspect…', - message_count: 930 - } - }] - } - } - if (method === 'session.create') throw new Error('must not fork a chat with 930 messages') - return {} - } - }) - - assert.equal(await runtime.openBotCanonicalChat('ops', 'grandfathered', null), 'grandfathered') - assert.equal(opened.includes('grandfathered'), true) - assert.deepEqual(runtime.saved, [], 'pin must not be cleared or rewritten') -}) - -test('pin resolving to an EMPTY stray draft is replaced via adoption, not a blind mint', async () => { - const runtime = loadOpenPath({ - openSession: async () => undefined, - request: async method => { - if (method === 'profiles.list') { - return { - profiles: [{ - name: 'ops', - preferred_session: { id: 'stray', resolved_id: 'stray', root_title: 'Untitled', title: 'Untitled', message_count: 0 } - }] - } - } - if (method === 'session.list') { - return { sessions: [{ id: 'real-forever-chat', title: 'Bot Chat', message_count: 42 }] } - } - if (method === 'session.create') throw new Error('must adopt the existing Bot Chat') - return {} - } - }) - - assert.equal(await runtime.openBotCanonicalChat('ops', 'stray', null), 'real-forever-chat') - assert.deepEqual(runtime.saved, [ - { name: 'ops', patch: { chat: null } }, - { name: 'ops', patch: { chat: 'real-forever-chat' } } - ]) -}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-creation.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-creation.test.mjs index dc468d1f65bc..e5b069bdd5cb 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-creation.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-creation.test.mjs @@ -8,11 +8,8 @@ const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') function loadCanonicalCreation({ openSession, request }) { const start = source.indexOf('const canonicalCreations = new Map()') const end = source.indexOf('function displayName(', start) - const saved = [] const context = { host: { openSession, request }, - saveBotMeta: (name, patch) => saved.push({ name, patch }), - $hideBotChats: { get: () => false }, window: { setTimeout: callback => callback() } } const section = source @@ -22,7 +19,7 @@ function loadCanonicalCreation({ openSession, request }) { assert.notEqual(start, -1, 'canonical creation section is missing') assert.notEqual(end, -1, 'canonical creation section delimiter is missing') vm.runInNewContext(section, context, { filename: 'canonical-creation.js' }) - return { ...context.__canonical, saved } + return { ...context.__canonical } } test('regression: navigation retries after the kickoff persists a new canonical chat', async () => { @@ -45,7 +42,7 @@ test('regression: navigation retries after the kickoff persists a new canonical assert.deepEqual(events, ['open:stored-1', 'kickoff:persisted', 'open:stored-1']) }) -test('regression: a failed intro keeps the pin', async () => { +test('regression: a failed intro still returns the created registry row', async () => { const runtime = loadCanonicalCreation({ openSession: async () => undefined, request: async method => { @@ -55,8 +52,7 @@ test('regression: a failed intro keeps the pin', async () => { } }) + // The chat exists under the canonical title — the next click finds it by + // NAME (the registry), so a failed kickoff can never orphan or fork it. assert.equal(await runtime.createCanonicalChat('newbie'), 'new-bot-chat') - assert.deepEqual(JSON.parse(JSON.stringify(runtime.saved)), [ - { name: 'newbie', patch: { chat: 'new-bot-chat' } } - ]) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-empty-recovery.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-empty-recovery.test.mjs deleted file mode 100644 index 76a93ffa4106..000000000000 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-empty-recovery.test.mjs +++ /dev/null @@ -1,117 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import test from 'node:test' -import vm from 'node:vm' - -const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') - -function loadCanonicalRecovery({ openSession, request }) { - const start = source.indexOf('const canonicalCreations = new Map()') - const end = source.indexOf('function displayName(', start) - const saved = [] - const requests = [] - const context = { - host: { - openSession, - request: async (method, params) => { - requests.push(method) - return request(method, params) - } - }, - saveBotMeta: (name, patch) => saved.push({ name, patch }), - $hideBotChats: { get: () => false }, - window: { setTimeout: callback => callback() } - } - const section = source.slice(start, end).concat('\nglobalThis.__canonical = { openBotCanonicalChat };\n') - - assert.notEqual(start, -1, 'canonical chat section is missing') - assert.notEqual(end, -1, 'canonical chat section delimiter is missing') - vm.runInNewContext(section, context, { filename: 'canonical-recovery.js' }) - return { ...context.__canonical, saved, requests } -} - -test('regression: a definitively-gone pin with no history clears and creates a replacement', async () => { - // New contract (hermes-agent#88200): the pin is verified through the - // backend's precise preferred_session resolver — NOT a paginated, - // hidden-excluding session.list window. preferred_session=null is the - // definitive "this session is gone"; with no previewed history to - // re-anchor on, recovery clears the pin and creates a fresh chat. - const opened = [] - const runtime = loadCanonicalRecovery({ - openSession: async id => opened.push(id), - request: async method => { - if (method === 'profiles.list') return { profiles: [{ name: 'ops', preferred_session: null }] } - if (method === 'session.create') return { stored_session_id: 'replacement', session_id: 'replacement-runtime' } - return {} - } - }) - - assert.equal(await runtime.openBotCanonicalChat('ops', 'stale-pin', null), 'replacement') - assert.deepEqual(opened, ['replacement']) - assert.deepEqual(JSON.parse(JSON.stringify(runtime.saved)), [ - { name: 'ops', patch: { chat: null } }, - { name: 'ops', patch: { chat: 'replacement' } } - ]) -}) - -test('regression: an unpinned bot adopts its previewed chat instead of creating another', async () => { - // A CLI/A2A exchange can create the canonical chat before the desktop - // saves ui_meta.chat. Grandfathering adopts the session the row already - // previews (the roster's last_session) rather than minting a new one. - const opened = [] - const runtime = loadCanonicalRecovery({ - openSession: async id => opened.push(id), - request: async method => { - if (method === 'session.create') throw new Error('must not create') - return {} - } - }) - - const history = { id: 'existing-canonical', title: 'Bot Chat', preview: 'hey', last_active: 5 } - assert.equal(await runtime.openBotCanonicalChat('ops', null, history), 'existing-canonical') - assert.deepEqual(opened, ['existing-canonical']) - assert.deepEqual(JSON.parse(JSON.stringify(runtime.saved)), [ - { name: 'ops', patch: { chat: 'existing-canonical' } } - ]) -}) - -test('regression: a dead pin re-anchors on the previewed chat instead of the newest session', async () => { - // hermes-agent#88146: recovery must never steal an unrelated scratch - // session. A pin the backend reports definitively gone re-anchors on the - // previewed history row when one exists — session.create never fires. - const opened = [] - const runtime = loadCanonicalRecovery({ - openSession: async id => opened.push(id), - request: async method => { - if (method === 'profiles.list') return { profiles: [{ name: 'ops', preferred_session: null }] } - if (method === 'session.create') throw new Error('must not create') - return {} - } - }) - - const history = { id: 'the-real-bot-chat', title: 'Bot Chat', preview: 'p', last_active: 9 } - assert.equal(await runtime.openBotCanonicalChat('ops', 'old-pin', history), 'the-real-bot-chat') - assert.deepEqual(opened, ['the-real-bot-chat']) - assert.deepEqual(JSON.parse(JSON.stringify(runtime.saved)), [ - { name: 'ops', patch: { chat: 'the-real-bot-chat' } } - ]) -}) - -test('regression: an inconclusive lookup opens the stored pin as-is and never rewrites it', async () => { - // hermes-agent#88146: an older backend (profiles.list without the - // preferred_session_ids param) or a transient hiccup is NOT proof the pin - // is gone. The pin is opened as-is; nothing is saved, nothing is created. - const opened = [] - const runtime = loadCanonicalRecovery({ - openSession: async id => opened.push(id), - request: async method => { - if (method === 'profiles.list') return { profiles: [{ name: 'ops' }] } - if (method === 'session.create') throw new Error('must not create') - return {} - } - }) - - assert.equal(await runtime.openBotCanonicalChat('ops', 'old-pin-outside-page', null), 'old-pin-outside-page') - assert.deepEqual(opened, ['old-pin-outside-page']) - assert.equal(runtime.saved.length, 0) -}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs deleted file mode 100644 index 54a9056f4d6b..000000000000 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs +++ /dev/null @@ -1,417 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import test from 'node:test' -import vm from 'node:vm' - -const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') - -// ── canonical open-path harness (slice: createCanonicalChat + openBotCanonicalChat) -function loadOpenPath({ openSession, request }) { - const start = source.indexOf('const canonicalCreations = new Map()') - const end = source.indexOf('function displayName(', start) - const saved = [] - const requests = [] - const context = { - host: { - openSession, - request: async (method, params) => { - requests.push({ method, params }) - return request(method, params) - } - }, - saveBotMeta: (name, patch) => saved.push({ name, patch: JSON.parse(JSON.stringify(patch)) }), - $hideBotChats: { get: () => false }, - window: { setTimeout: callback => callback() } - } - const section = source - .slice(start, end) - .concat('\nglobalThis.__open = { openBotCanonicalChat };\n') - - assert.notEqual(start, -1, 'canonical creation section is missing') - assert.notEqual(end, -1, 'canonical creation section delimiter is missing') - vm.runInNewContext(section, context, { filename: 'canonical-open.js' }) - return { ...context.__open, saved, requests, host: context.host } -} - -const HISTORY = { id: 'hist-1', title: 'Bot Chat', preview: 'history preview', last_active: 1000 } - -// ── grandfather: no pin + existing history adopts the previewed session ──── - -test('grandfather: no pin + history opens and pins THAT session, no new chat', async () => { - const runtime = loadOpenPath({ - openSession: async () => undefined, - request: async () => ({}) - }) - - const result = await runtime.openBotCanonicalChat('ops', null, HISTORY) - - assert.equal(result, 'hist-1') - assert.deepEqual(runtime.saved, [{ name: 'ops', patch: { chat: 'hist-1' } }]) - assert.equal(runtime.requests.some(r => r.method === 'session.create'), false, - 'must not mint a new chat when the previewed session can be adopted') -}) - -test('safety: no pin + ordinary latest history creates a Bot Chat instead of claiming it', async () => { - const runtime = loadOpenPath({ - openSession: async () => undefined, - request: async method => - method === 'session.create' - ? { stored_session_id: 'safe-bot-chat', session_id: 'safe-bot-chat-runtime' } - : {} - }) - - const ordinary = { ...HISTORY, id: 'ordinary-1', title: '生产调度会优化' } - const result = await runtime.openBotCanonicalChat('ops', null, ordinary) - - assert.equal(result, 'safe-bot-chat') - assert.deepEqual(runtime.saved, [{ name: 'ops', patch: { chat: 'safe-bot-chat' } }]) - assert.equal(runtime.requests.some(r => r.method === 'session.create'), true) -}) - -test('grandfather: no pin + no history keeps the creation flow', async () => { - const runtime = loadOpenPath({ - openSession: async () => undefined, - request: async method => - method === 'session.create' ? { stored_session_id: 'stored-1', session_id: 'runtime-1' } : {} - }) - - const result = await runtime.openBotCanonicalChat('ops', null, null) - - assert.equal(result, 'stored-1') - assert.equal(runtime.requests.some(r => r.method === 'session.create'), true) -}) - -test('grandfather: adoption hydration failure surfaces without forking a replacement chat', async () => { - const runtime = loadOpenPath({ - openSession: async id => { - if (id === 'hist-1') throw new Error('session vanished') - }, - request: async method => - method === 'session.create' ? { stored_session_id: 'stored-2', session_id: 'runtime-2' } : {} - }) - - await assert.rejects(runtime.openBotCanonicalChat('ops', null, HISTORY), /session vanished/) - assert.equal(runtime.saved.some(s => s.patch?.chat === 'hist-1'), false, - 'a failed adoption must not persist the dead id as the pin') - assert.equal(runtime.requests.some(r => r.method === 'session.create'), false, - 'a transient hydration failure must not fork the canonical chat') -}) - -// ── precise pin verification (no session.list pagination/hidden semantics) ─ - -test('pin: preferred_session present opens the resolved session and keeps the pin', async () => { - const opened = [] - const runtime = loadOpenPath({ - openSession: async (id, options) => { opened.push({ id, options }) }, - request: async method => { - if (method === 'profiles.list') { - return { - profiles: [{ - name: 'ops', - preferred_session: { - id: 'pin-1', resolved_id: 'pin-1', title: 'Bot Chat', - preview: 'latest', started_at: 1, last_active: 2, message_count: 3 - } - }] - } - } - return {} - } - }) - - const result = await runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY) - - assert.equal(result, 'pin-1') - assert.deepEqual(JSON.parse(JSON.stringify(opened)), [{ - id: 'pin-1', - options: { - profile: 'ops', - intent: 'main', - awaitHydration: true, - expectHistory: true, - // false: clicking a bot moves the WORKSPACE onto that bot, not just the - // transcript. With true, `$activeGatewayProfile` stayed on the previously - // active profile, so "New session" from inside any bot was created on - // that other backend (measured: four new chats from different bots all - // landed in `ops`). - keepAllProfilesScope: false, - retryHydrationTimeoutOnce: true - } - }]) - assert.equal(runtime.saved.length, 0, 'a live pin must not be rewritten') - assert.equal(runtime.requests.some(r => r.method === 'session.create'), false) - // The pin is verified through the precise resolver, never session.list. - assert.equal(runtime.requests.some(r => r.method === 'session.list'), false) -}) - -test('safety: a pinned ordinary session is rejected and replaced with a Bot Chat', async () => { - const runtime = loadOpenPath({ - openSession: async () => undefined, - request: async method => { - if (method === 'profiles.list') { - return { - profiles: [{ - name: 'ops', - preferred_session: { id: 'ordinary-3', resolved_id: 'ordinary-3', title: '生产调度会优化' } - }] - } - } - if (method === 'session.create') return { stored_session_id: 'safe-pinned-chat', session_id: 'safe-pinned-runtime' } - return {} - } - }) - - const result = await runtime.openBotCanonicalChat('ops', 'ordinary-3', HISTORY) - - assert.equal(result, 'safe-pinned-chat') - assert.deepEqual(runtime.saved, [ - { name: 'ops', patch: { chat: null } }, - { name: 'ops', patch: { chat: 'safe-pinned-chat' } } - ]) -}) - -test('pin: compression-rotated pin opens the live tip, keeps the durable pin', async () => { - const opened = [] - const runtime = loadOpenPath({ - openSession: async id => { opened.push(id) }, - request: async method => { - if (method === 'profiles.list') { - return { - profiles: [{ - name: 'ops', - preferred_session: { - id: 'root-1', resolved_id: 'tip-9', root_title: 'Bot Chat', title: 'Bot Chat (continued)', - preview: 'post-compression', started_at: 1, last_active: 9, message_count: 42 - } - }] - } - } - return {} - } - }) - - const result = await runtime.openBotCanonicalChat('ops', 'root-1', HISTORY) - - assert.deepEqual(opened, ['tip-9']) - assert.equal(result, 'root-1', 'the stored pin keeps its durable identity') - assert.equal(runtime.saved.length, 0) -}) - -test('pin: definitively gone pin re-pins to the previewed session, not rows[0]', async () => { - const runtime = loadOpenPath({ - openSession: async () => undefined, - request: async method => { - if (method === 'profiles.list') { - return { profiles: [{ name: 'ops', preferred_session: null }] } - } - return {} - } - }) - - const result = await runtime.openBotCanonicalChat('ops', 'dead-pin', HISTORY) - - assert.equal(result, 'hist-1') - assert.deepEqual(runtime.saved, [{ name: 'ops', patch: { chat: 'hist-1' } }]) - assert.equal(runtime.requests.some(r => r.method === 'session.create'), false) -}) - -test('safety: a dead pin does not re-anchor on an ordinary latest session', async () => { - const runtime = loadOpenPath({ - openSession: async () => undefined, - request: async method => { - if (method === 'profiles.list') return { profiles: [{ name: 'ops', preferred_session: null }] } - if (method === 'session.create') return { stored_session_id: 'safe-replacement', session_id: 'safe-replacement-runtime' } - return {} - } - }) - - const ordinary = { ...HISTORY, id: 'ordinary-2', title: '生产调度会优化' } - const result = await runtime.openBotCanonicalChat('ops', 'dead-pin', ordinary) - - assert.equal(result, 'safe-replacement') - assert.deepEqual(runtime.saved, [ - { name: 'ops', patch: { chat: null } }, - { name: 'ops', patch: { chat: 'safe-replacement' } } - ]) - assert.equal(runtime.requests.some(r => r.method === 'session.create'), true) -}) - -test('pin: gone pin + no history clears the pin and creates', async () => { - const runtime = loadOpenPath({ - openSession: async () => undefined, - request: async method => { - if (method === 'profiles.list') { - return { profiles: [{ name: 'ops', preferred_session: null }] } - } - if (method === 'session.create') return { stored_session_id: 'stored-3', session_id: 'runtime-3' } - return {} - } - }) - - const result = await runtime.openBotCanonicalChat('ops', 'dead-pin', null) - - assert.equal(result, 'stored-3') - // Pin cleared first (dead pin is provably unusable), then the freshly - // created chat pins itself inside createCanonicalChat. - assert.deepEqual(runtime.saved, [ - { name: 'ops', patch: { chat: null } }, - { name: 'ops', patch: { chat: 'stored-3' } } - ]) -}) - -test('pin: precise hit but failed hydration keeps the pin and surfaces the failure', async () => { - const runtime = loadOpenPath({ - openSession: async () => { throw new Error('socket hiccup') }, - request: async method => { - if (method === 'profiles.list') { - return { - profiles: [{ - name: 'ops', - preferred_session: { - id: 'pin-1', resolved_id: 'pin-1', title: 'Bot Chat', - preview: 'latest', started_at: 1, last_active: 2, message_count: 3 - } - }] - } - } - return {} - } - }) - - await assert.rejects(runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY), /socket hiccup/) - assert.equal(runtime.saved.length, 0, 'a confirmed-live pin must survive a transient open failure') - assert.equal(runtime.requests.some(r => r.method === 'session.create'), false, - 'must not fork the forever-chat on a hiccup') -}) - -test('pin: a waking-backend hydration timeout asks the SDK to retry internally', async () => { - // The internal retry-and-succeed behavior lives in host.openSession itself - // (apps/desktop/src/sdk/index.ts) now, because only that layer sees the - // $resumeExhaustedSessionId latch that the core stranded-session overlay - // reads — a plugin-side retry can silently resolve while that overlay stays - // latched (hermes-agent#89617). This harness stubs host.openSession with a - // bare mock, so it can only prove the plugin ASKS for the retry, not that - // the overlay never appears; see profile-routing.test.ts for that. - const opts = [] - const runtime = loadOpenPath({ - openSession: async (id, options) => { opts.push(options) }, - request: async method => { - if (method === 'profiles.list') { - return { - profiles: [{ - name: 'ops', - preferred_session: { - id: 'pin-1', resolved_id: 'pin-1', title: 'Bot Chat', - preview: 'latest', started_at: 1, last_active: 2, message_count: 3 - } - }] - } - } - return {} - } - }) - - const result = await runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY) - - assert.equal(result, 'pin-1') - assert.equal(opts.length, 1) - assert.equal(opts[0].retryHydrationTimeoutOnce, true, 'the SDK must own the hydration-timeout retry') -}) - -test('pin: a persistent hydration timeout still surfaces the failure', async () => { - const runtime = loadOpenPath({ - openSession: async () => { throw new Error("Timed out loading ops's session history.") }, - request: async method => { - if (method === 'profiles.list') { - return { - profiles: [{ - name: 'ops', - preferred_session: { - id: 'pin-1', resolved_id: 'pin-1', title: 'Bot Chat', - preview: 'latest', started_at: 1, last_active: 2, message_count: 3 - } - }] - } - } - return {} - } - }) - - await assert.rejects(runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY), /Timed out loading/) - assert.equal(runtime.saved.length, 0, 'a confirmed-live pin must survive a persistent hydration timeout') -}) - -// ── transient failures must never destroy the pin ────────────────────────── - -test('transient: profiles.list failure keeps the pin when the direct open works', async () => { - const runtime = loadOpenPath({ - openSession: async () => undefined, - request: async method => { - if (method === 'profiles.list') throw new Error('gateway reconnecting') - return {} - } - }) - - const result = await runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY) - - assert.equal(result, 'pin-1') - assert.equal(runtime.saved.length, 0, 'a hiccup must not clear or rewrite the pin') - assert.equal(runtime.requests.some(r => r.method === 'session.create'), false, - 'a hiccup must not mint a replacement chat') -}) - -test('transient: profiles.list failure + failed direct open preserves pin and surfaces Retry', async () => { - const runtime = loadOpenPath({ - openSession: async id => { - if (id === 'pin-1') throw new Error('resume rejected') - }, - request: async method => { - if (method === 'profiles.list') throw new Error('gateway reconnecting') - if (method === 'session.create') return { stored_session_id: 'stored-4', session_id: 'runtime-4' } - return {} - } - }) - - await assert.rejects(runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY), /resume rejected/) - assert.deepEqual(runtime.saved, [], 'an inconclusive outage must never clear the canonical pin') - assert.equal(runtime.requests.some(r => r.method === 'session.create'), false, - 'an inconclusive outage must never fork the canonical chat') -}) - -// ── preferred_session_ids request shaping (pure helper) ──────────────────── - -function loadHelpers() { - const atom = value => ({ get: () => value, set: () => undefined }) - const jsx = (type, props = {}) => ({ type, props }) - const context = { - atom, - jsx, - jsxs: jsx, - useQuery: () => ({}), - useValue: value => (value?.get ? value.get() : value), - useState: value => [value, () => undefined], - document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } }, - host: { state: { profile: { get: () => 'ops', listen: () => undefined } }, request: () => undefined } - } - const code = source - .replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '') - .replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '') - .replace(/^const \{ McpTab, ToolsetConfigPanel \} = sdk\r?\n/m, '') - .replace(/^import .* from 'react'\r?\n/m, '') - .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') - .replace('export default {', 'globalThis.plugin = {') - .concat('\nglobalThis.__preferredSessionIds = preferredSessionIds;') - vm.runInNewContext(code, context) - return context -} - -test('preferredSessionIds: collects only live pins', () => { - // vm-realm objects fail assert.deepEqual prototype checks — compare via JSON. - const collect = meta => JSON.parse(JSON.stringify(loadHelpers().__preferredSessionIds(meta))) - assert.deepEqual( - collect({ ops: { chat: 'pin-1' }, scribe: { chat: null }, chef: { title: 'Chef' } }), - { ops: 'pin-1' } - ) - assert.deepEqual(collect({}), {}) - assert.deepEqual(collect(undefined), {}) -}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-pin.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-pin.test.mjs deleted file mode 100644 index 630ddf1a73dd..000000000000 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-pin.test.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import test from 'node:test' -import vm from 'node:vm' - -// #24's guarantee — a VALID canonical pin is opened as-is, never replaced, -// and only an ACTUALLY-missing pin triggers recovery — used to be pinned -// against the old implementation's source shape (session.list rows[0] -// fallback). hermes-agent#88200 replaced that windowed, hidden-excluding -// lookup with the backend's precise preferred_session resolver, so the -// guarantee is now pinned as BEHAVIOR: what gets opened, what gets saved, -// and what never happens to a live pin. - -const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') - -function loadOpenPath({ openSession, request }) { - const start = source.indexOf('const canonicalCreations = new Map()') - const end = source.indexOf('function displayName(', start) - const saved = [] - const requests = [] - const context = { - host: { - openSession, - request: async (method, params) => { - requests.push({ method, params }) - return request(method, params) - } - }, - saveBotMeta: (name, patch) => saved.push({ name, patch: JSON.parse(JSON.stringify(patch)) }), - $hideBotChats: { get: () => false }, - window: { setTimeout: callback => callback() } - } - const section = source - .slice(start, end) - .concat('\nglobalThis.__open = { openBotCanonicalChat };\n') - - assert.notEqual(start, -1, 'canonical chat section is missing') - assert.notEqual(end, -1, 'canonical chat section delimiter is missing') - vm.runInNewContext(section, context, { filename: 'canonical-pin.js' }) - return { ...context.__open, saved, requests } -} - -test('regression: a live pinned canonical chat is opened as-is, never replaced', async () => { - const opened = [] - const runtime = loadOpenPath({ - openSession: async id => opened.push(id), - request: async method => { - if (method === 'profiles.list') { - return { - profiles: [{ - name: 'ops', - preferred_session: { - id: 'pin-1', resolved_id: 'pin-1', title: 'Bot Chat', - preview: 'latest', started_at: 1, last_active: 2, message_count: 3 - } - }] - } - } - return {} - } - }) - - assert.equal(await runtime.openBotCanonicalChat('ops', 'pin-1', null), 'pin-1') - assert.deepEqual(opened, ['pin-1'], 'the pin itself is opened under the bot profile') - assert.deepEqual(runtime.saved, [], 'a live pin is never rewritten') - assert.equal(runtime.requests.some(r => r.method === 'session.create'), false, - 'a live pin never triggers a replacement chat') -}) - -test('regression: only an actually-missing pin triggers recovery', async () => { - const runtime = loadOpenPath({ - openSession: async () => undefined, - request: async method => { - if (method === 'profiles.list') { - return { profiles: [{ name: 'ops', preferred_session: null }] } - } - return {} - } - }) - - // Definitively gone, but the roster still previews a live session — - // recovery re-anchors on THAT session instead of minting a new chat. - const history = { id: 'hist-1', title: 'Bot Chat', preview: 'p', last_active: 1 } - assert.equal(await runtime.openBotCanonicalChat('ops', 'dead-pin', history), 'hist-1') - assert.deepEqual(runtime.saved, [{ name: 'ops', patch: { chat: 'hist-1' } }]) -}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs new file mode 100644 index 000000000000..4901439cc27f --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import vm from 'node:vm' + +// ── The canonical-chat REGISTRY contract ──────────────────────────────────── +// +// A bot's forever-chat has exactly ONE identity: the session titled "Bot Chat" +// on that bot's profile. The core UNIQUE(title) index makes (profile, +// "Bot Chat") an exact registry — at most one row, resolved fresh on every +// open via `session.list { title: 'Bot Chat', include_hidden: true }`. +// +// There is NO session-id pin. The previous design stored a pointer in +// ui_meta['hermes-bots'].chat and spent five hardening waves (#88690, #90732, +// #90751, #91791-revert, #92042) guarding its failure modes: rows[0] steals, +// last_session adoptions, transient clears, drifted-title welds. Every "lost +// canonical chat" incident traced to that pointer dangling and a later guard +// then welding the wrong session in. Name-as-identity removes the failure +// class instead of guarding it: a name cannot dangle. +// +// This suite pins the whole contract: +// 1. open = registry lookup → open the row (lineage tip) +// 2. no row → create (adopt-before-mint lives inside creation) +// 3. no pointer is ever read or written on the open path + +const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +function loadOpenPath({ openSession, request }) { + const start = source.indexOf('const canonicalCreations = new Map()') + const end = source.indexOf('function displayName(', start) + const requests = [] + const opened = [] + const context = { + host: { + openSession: async (id, options) => { + opened.push({ id, options }) + return openSession ? openSession(id, options) : undefined + }, + request: async (method, params) => { + requests.push({ method, params: JSON.parse(JSON.stringify(params ?? null)) }) + return request(method, params) + } + }, + window: { setTimeout: callback => callback() } + } + const section = source + .slice(start, end) + .concat('\nglobalThis.__open = { createCanonicalChat, openBotCanonicalChat, findExistingCanonicalChat };\n') + + assert.notEqual(start, -1, 'canonical section is missing') + assert.notEqual(end, -1, 'canonical section delimiter is missing') + vm.runInNewContext(section, context, { filename: 'canonical-registry.js' }) + return { ...context.__open, requests, opened } +} + +// ── 1. the registry row wins, always ──────────────────────────────────────── + +test('open resolves the profile\u2019s "Bot Chat" row by exact title and opens it', async () => { + const runtime = loadOpenPath({ + request: async method => { + if (method === 'session.list') { + return { sessions: [{ id: 'forever-chat', title: 'Bot Chat', message_count: 930 }] } + } + if (method === 'session.create') { + throw new Error('must not create: the registry row exists') + } + return {} + } + }) + + assert.equal(await runtime.openBotCanonicalChat('ops'), 'forever-chat') + assert.equal(runtime.opened.length, 1) + assert.equal(runtime.opened[0].id, 'forever-chat') + assert.equal(runtime.opened[0].options.profile, 'ops') + assert.equal(runtime.opened[0].options.keepAllProfilesScope, false, + 'opening a bot moves the workspace onto that bot') + + const list = runtime.requests.find(r => r.method === 'session.list') + assert.equal(list?.params?.title, 'Bot Chat', 'lookup is by exact title') + assert.equal(list?.params?.profile, 'ops') + assert.equal(list?.params?.include_hidden, true, + 'canonical chats are always hidden — the lookup must see hidden rows') +}) + +test('a compression-rotated registry row opens the lineage tip', async () => { + const runtime = loadOpenPath({ + request: async method => { + if (method === 'session.list') { + return { + sessions: [{ id: 'root-1', resolved_id: 'tip-9', root_title: 'Bot Chat', title: 'Bot Chat', message_count: 400 }] + } + } + return {} + } + }) + + assert.equal(await runtime.openBotCanonicalChat('ops'), 'root-1', + 'the durable registry id is returned') + assert.equal(runtime.opened[0].id, 'tip-9', 'the live tip is what opens') +}) + +test('the open path never reads or writes a stored pointer', () => { + const start = source.indexOf('const canonicalCreations = new Map()') + const end = source.indexOf('function displayName(', start) + const section = source.slice(start, end) + + assert.doesNotMatch(section, /saveBotMeta/, 'no pointer writes on the canonical path') + assert.doesNotMatch(section, /meta\??\.chat\b/, 'no pointer reads on the canonical path') + assert.doesNotMatch(section, /preferred_session_ids/, 'no id-verification RPC on the canonical path') +}) + +test('openBotCanonicalChat takes only the bot name — identity needs nothing else', () => { + assert.match(source, /async function openBotCanonicalChat\(name\) \{/) +}) + +// ── 2. no registry row → create ───────────────────────────────────────────── + +test('no registry row mints a hidden "Bot Chat" session with the intro kickoff', async () => { + const runtime = loadOpenPath({ + request: async method => { + if (method === 'session.list') return { sessions: [] } + if (method === 'session.create') return { stored_session_id: 'fresh-1', session_id: 'rt-1' } + return {} + } + }) + + assert.equal(await runtime.openBotCanonicalChat('newbie'), 'fresh-1') + const create = runtime.requests.find(r => r.method === 'session.create') + assert.equal(create?.params?.title, 'Bot Chat') + assert.equal(create?.params?.hidden, true) + const kickoff = runtime.requests.find(r => r.method === 'prompt.submit') + assert.equal(kickoff?.params?.session_id, 'rt-1') +}) + +test('a failed open of the registry row surfaces instead of forking a replacement', async () => { + const runtime = loadOpenPath({ + openSession: async () => { + throw new Error('backend restarting') + }, + request: async method => { + if (method === 'session.list') { + return { sessions: [{ id: 'forever-chat', title: 'Bot Chat', message_count: 12 }] } + } + if (method === 'session.create') { + throw new Error('must not create: a transient open failure is not ownership loss') + } + return {} + } + }) + + await assert.rejects(() => runtime.openBotCanonicalChat('ops'), /backend restarting/) +}) + +// ── 3. ordinary sessions are never claimed ────────────────────────────────── + +test('an ordinary titled session never satisfies the registry lookup', async () => { + const runtime = loadOpenPath({ + request: async method => { + if (method === 'session.list') { + // A misbehaving/older gateway ignores the title param and returns a + // windowed listing — the local exact-title scan still applies. + return { + sessions: [ + { id: 'scratch', title: 'help me with x', message_count: 40 }, + { id: 'draft', title: '', message_count: 0 } + ] + } + } + if (method === 'session.create') return { stored_session_id: 'fresh-2', session_id: 'rt-2' } + return {} + } + }) + + assert.equal(await runtime.openBotCanonicalChat('ops'), 'fresh-2', + 'no row titled "Bot Chat" → create; never adopt an ordinary conversation') + assert.ok(!runtime.opened.some(o => o.id === 'scratch')) +}) 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 0fc0f261ccbf..7a7df457e7b9 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 @@ -51,7 +51,7 @@ test('group member session.create is unconditionally hidden too', () => { assert.equal(source.includes('$hideBotChats'), false, 'the old pref atom must be gone') }) -test('hideOwnedBotSessions sweeps canonical chats AND room member sessions', async () => { +test('hideOwnedBotSessions sweeps room member sessions by id', async () => { const start = source.indexOf('function hideOwnedBotSessions()') const end = source.indexOf('/** Fetch server-side avatars', start) const calls = [] @@ -59,60 +59,37 @@ test('hideOwnedBotSessions sweeps canonical chats AND room member sessions', asy host: { request: async (method, params) => { calls.push({ method, params }) - if (method === 'profiles.list') { - return { - profiles: [ - { name: 'alpha', preferred_session: { id: 'chat-a', title: 'Bot Chat' } }, - { name: 'beta', preferred_session: { id: 'chat-b', title: 'Bot Chat' } } - ] - } - } 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 + Quiet: { sessions: { alpha: 'room-core-a' } }, // duplicate id — must dedupe Legacy: {} // pre-sessions room shape }) - } + }, + sweepBotProfileSessions: async () => undefined } 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.filter(c => c.method === 'session.set_hidden').map(c => c.params.session_id).sort() - assert.deepEqual(ids, ['chat-a', 'chat-b', 'room-core-a', 'room-core-b']) + assert.deepEqual(ids, ['room-core-a', 'room-core-b']) const hiddenCalls = calls.filter(c => c.method === 'session.set_hidden') assert.ok(hiddenCalls.every(c => c.params.hidden === true)) }) -test('safety: a stale canonical pointer to an ordinary session is not hidden', async () => { +test('hideOwnedBotSessions never consults stored canonical pointers', () => { + // Canonical Bot Chats are hidden by the TITLE sweep (they are identified by + // name, not by pointer) — the load-time reconciliation must not read + // $botMeta chat ids or verify them via profiles.list. 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 }) - if (method === 'profiles.list') { - return { - profiles: [{ name: 'default', preferred_session: { id: 'ordinary-1', title: '生产调度会优化' } }] - } - } - return {} - } - }, - $botMeta: { get: () => ({ default: { chat: 'ordinary-1' } }) }, - $groupChats: { get: () => ({}) } - } - const section = source.slice(start, end).concat('\nglobalThis.__h = { hideOwnedBotSessions };\n') - vm.runInNewContext(section, context, { filename: 'h-stale.js' }) - await context.__h.hideOwnedBotSessions() - - assert.equal(calls.some(c => c.method === 'session.set_hidden'), false) + const end = source.indexOf('// Titles Bot Mode itself mints', start) + const section = source.slice(start, end) + assert.doesNotMatch(section, /botMeta/) + assert.doesNotMatch(section, /profiles\.list/) }) test('sweepBotProfileSessions hides Bot-Mode-titled rows per roster bot, and only those', async () => { @@ -177,9 +154,8 @@ test('hideOwnedBotSessions chains the ownership sweep and survives its absence o test('the canonical-chat adoption scan lists with include_hidden', () => { // The one session.list consumer that must see the always-hidden rows: - // findExistingCanonicalChat (adopt-before-mint) — canonical Bot Chats are - // born hidden, so a visible-only scan would miss the very row whose - // existence forbids minting. (Pin recovery goes through profiles.list - // preferred_session_ids, whose resolver already sees hidden rows.) + // findExistingCanonicalChat (the registry lookup) — canonical Bot Chats + // are born hidden, so a visible-only scan would miss the very row that IS + // the bot's identity. assert.match(source, /include_hidden: true\s*\}\)\s*const rows = res\?\.sessions \?\? \[\]\s*return rows\.find\(row => isCanonicalBotChatHistory\(row\)\)/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/new-compact-guard.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/new-compact-guard.test.mjs index b72952009506..d6a18bee1cde 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/new-compact-guard.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/new-compact-guard.test.mjs @@ -3,25 +3,26 @@ import { readFileSync } from 'node:fs' import test from 'node:test' // The /new -> /compact guard protects a bot's canonical forever-chat from being -// forked by /new. It compares the current session id against the bot's stored -// canonical id. That id is persisted as meta.chat everywhere (createCanonicalChat -// saveBotMeta(name,{chat:sid}), openBotCanonicalChat, BotRow). A regression read -// it as meta.chat_pin — a key that is never written — so pinnedId was always null -// and the guard never fired: /new silently forked the forever-chat. +// forked by /new. Canonical identity is the NAME — the profile's session titled +// "Bot Chat" — reported by the gateway as canonical_session on every roster row. +// The guard compares the on-screen session id against that registry row (durable +// id OR compression-lineage tip). No stored meta.chat pointer is consulted: +// pointers dangle; the registry row cannot. const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') // Locate the /new reroute guard block. const guardStart = source.indexOf('const slashNew =') assert.notEqual(guardStart, -1, '/new guard block is missing') -const guardBlock = source.slice(guardStart, guardStart + 600) +const guardBlock = source.slice(guardStart, guardStart + 900) -test('regression: /new guard reads the canonical id from meta.chat, not meta.chat_pin', () => { - assert.match(guardBlock, /const pinnedId = meta\?\.chat \|\| null/) +test('the /new guard reads the canonical registry row, never a stored pointer', () => { + assert.match(guardBlock, /canonical_session/) + assert.doesNotMatch(guardBlock, /meta\?\.chat/) assert.doesNotMatch(guardBlock, /chat_pin/) }) -test('regression: canonical id is persisted as meta.chat (the key the guard reads)', () => { - // The writer and the guard must agree on the key, or the guard never fires. - assert.match(source, /saveBotMeta\([^)]*\{\s*chat:\s*sid\s*\}/) +test('the guard matches both the durable registry id and the lineage tip', () => { + assert.match(guardBlock, /canonical\?\.id/) + assert.match(guardBlock, /canonical\?\.resolved_id/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs index 0962f2d8952c..fba9ed2f7477 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs @@ -237,7 +237,7 @@ test('render: BotRow previews the pinned canonical chat, not an unrelated latest title: 'Ops', description: '', last_session: { id: 'scratch9', title: 'Scratch', preview: 'unrelated scratch content', last_active: 1_800_000_000 }, - preferred_session: { id: 'pinned1', resolved_id: 'pinned1', title: 'Bot Chat', preview: 'pinned chat content', started_at: 1, last_active: 1_700_000_000, message_count: 5 } + canonical_session: { id: 'pinned1', resolved_id: 'pinned1', title: 'Bot Chat', preview: 'pinned chat content', started_at: 1, last_active: 1_700_000_000, message_count: 5 } }, onEdit: () => undefined }) diff --git a/tests/tui_gateway/test_profiles_list_canonical_session.py b/tests/tui_gateway/test_profiles_list_canonical_session.py new file mode 100644 index 000000000000..a2c2356cccb3 --- /dev/null +++ b/tests/tui_gateway/test_profiles_list_canonical_session.py @@ -0,0 +1,189 @@ +"""Tests: profiles.list ``canonical_session`` registry summaries. + +Why: a bot's canonical forever-chat has exactly ONE identity — the session +titled "Bot Chat" on that bot's profile (core UNIQUE(title) makes it a +registry of at most one row). The desktop BOTS roster previews it and clicks +open it, so the gateway resolves the registry row server-side on every +``profiles.list`` and reports it per profile as ``canonical_session``. No +client ever passes a session pointer: the previous ``preferred_session_ids`` +pin-verification contract is REMOVED (pointers dangle; names cannot). + +Contract under test: +- Every profile row (with include_sessions on) carries ``canonical_session``: + a summary dict when a "Bot Chat" row exists, ``None`` when it does not + (no row, denied internal source, archived). +- Summary keys: ``id`` (the durable registry row), ``resolved_id`` (live + compression tip; equal to ``id`` when uncompressed), ``root_title``, + ``title``, ``preview`` (newest user/assistant text at the tip), + ``started_at``, ``last_active``, ``message_count``. +- Hidden rows resolve (canonical chats are always hidden). +- ``last_session`` behaviour is unchanged in every case. +- ``include_sessions: false`` skips resolution entirely. +- Resolution reads each profile's OWN state.db (strict per-profile scoping). +""" + +from __future__ import annotations + +import pytest + +import tui_gateway.server as srv + + +@pytest.fixture +def home(tmp_path, monkeypatch): + """Temp HERMES_HOME with the default profile plus one named profile.""" + h = tmp_path / ".hermes" + (h / "profiles" / "ops").mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(h)) + return h + + +def _db(profile_dir): + from hermes_state import SessionDB + + return SessionDB(db_path=profile_dir / "state.db") + + +def _add_session(db, sid, *, source="cli", title="", ts, text, hidden=False, + parent=None, end_reason=None): + """Create one session with a single user message at an exact timestamp.""" + db.create_session(sid, source, parent_session_id=parent) + db.append_message(sid, "user", text, timestamp=ts) + with db._lock: + db._conn.execute("UPDATE sessions SET title = ? WHERE id = ?", (title, sid)) + if end_reason: + # Mark ended AFTER appending: the DB (correctly) refuses writes + # to a compression-closed session. + db._conn.execute( + "UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ?", + (ts + 1, end_reason, sid), + ) + if hidden: + db.set_session_hidden(sid, True) + + +def _profiles(params): + envelope = srv._methods["profiles.list"](1, params) + return envelope["result"]["profiles"] + + +def _row(profiles, name): + return next(p for p in profiles if p["name"] == name) + + +# --------------------------------------------------------------------------- +# canonical_session resolution +# --------------------------------------------------------------------------- + + +def test_canonical_session_is_the_bot_chat_row_not_latest(home): + db = _db(home) + _add_session(db, "forever1", title="Bot Chat", ts=1000, text="forever chat content") + _add_session(db, "other1", title="Scratch", ts=2000, text="scratch pad content") + db.close() + + row = _row(_profiles({}), "default") + + canonical = row["canonical_session"] + assert canonical["id"] == "forever1" + assert canonical["resolved_id"] == "forever1" + assert canonical["root_title"] == "Bot Chat" + assert canonical["title"] == "Bot Chat" + assert "forever chat content" in canonical["preview"] + # last_session keeps its own contract: the most recently active session. + assert row["last_session"]["id"] == "other1" + + +def test_canonical_session_resolves_hidden_row(home): + db = _db(home) + _add_session(db, "hiddenchat", title="Bot Chat", ts=1000, + text="hidden bot chat content", hidden=True) + _add_session(db, "visible1", title="Visible", ts=2000, text="visible content") + db.close() + + row = _row(_profiles({}), "default") + + # Canonical chats are always hidden — the registry lookup must see them. + assert row["canonical_session"] is not None + assert row["canonical_session"]["id"] == "hiddenchat" + assert "hidden bot chat content" in row["canonical_session"]["preview"] + # …while the generic latest-session listing still excludes hidden rows. + assert row["last_session"]["id"] == "visible1" + + +def test_canonical_session_none_when_no_bot_chat_row(home): + db = _db(home) + _add_session(db, "real1", title="Real", ts=1000, text="real content") + db.close() + + row = _row(_profiles({}), "default") + + assert row["canonical_session"] is None + assert row["last_session"]["id"] == "real1" + + +def test_canonical_session_denied_internal_source_returns_none(home): + db = _db(home) + _add_session(db, "toolrun", source="tool", title="Bot Chat", ts=1000, text="tool output") + _add_session(db, "human1", title="Human", ts=2000, text="human content") + db.close() + + row = _row(_profiles({}), "default") + + # Internal sources (tool sub-agent runs, kanban workers) are not + # conversations — a registry row minted by one resolves as absent. + assert row["canonical_session"] is None + + +def test_canonical_session_resolves_compression_tip(home): + db = _db(home) + _add_session(db, "root1", title="Bot Chat", ts=1000, + text="pre-compression content", end_reason="compression") + _add_session(db, "tip1", title="Bot Chat (continued)", ts=3000, + text="post-compression content", parent="root1") + _add_session(db, "other1", title="Other", ts=4000, text="other content") + db.close() + + row = _row(_profiles({}), "default") + + canonical = row["canonical_session"] + # The registry row keeps its durable identity; the summary comes from the + # live tip. + assert canonical["id"] == "root1" + assert canonical["resolved_id"] == "tip1" + assert canonical["root_title"] == "Bot Chat" + assert canonical["title"] == "Bot Chat (continued)" + assert "post-compression content" in canonical["preview"] + + +# --------------------------------------------------------------------------- +# Contract guards +# --------------------------------------------------------------------------- + + +def test_include_sessions_false_skips_canonical(home): + db = _db(home) + _add_session(db, "s1", title="Bot Chat", ts=1000, text="content") + db.close() + + row = _row(_profiles({"include_sessions": False}), "default") + assert "last_session" not in row + assert "canonical_session" not in row + + +def test_canonical_session_scoped_per_profile_db(home): + # A "Bot Chat" row in BOTH profiles' state.db files, different content — + # each roster row must summarize its own profile's database. + default_db = _db(home) + _add_session(default_db, "chat-default", title="Bot Chat", ts=1000, + text="default profile content") + default_db.close() + + ops_db = _db(home / "profiles" / "ops") + _add_session(ops_db, "chat-ops", title="Bot Chat", ts=1000, + text="ops profile content") + ops_db.close() + + rows = _profiles({}) + assert "default profile content" in _row(rows, "default")["canonical_session"]["preview"] + assert "ops profile content" in _row(rows, "ops")["canonical_session"]["preview"] diff --git a/tests/tui_gateway/test_profiles_list_preferred_session.py b/tests/tui_gateway/test_profiles_list_preferred_session.py deleted file mode 100644 index 9c130f15b5a1..000000000000 --- a/tests/tui_gateway/test_profiles_list_preferred_session.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Tests: profiles.list ``preferred_session_ids`` precise session summaries. - -Why: the desktop BOTS roster previews ``last_session`` (the profile's most -recently active session) but clicking a bot row opens the PINNED canonical -chat — two different session identities, so the preview shows one -conversation and the click lands in another (NousResearch/hermes-agent#88200). -The generic fix at the RPC layer: callers that know which session they care -about pass ``preferred_session_ids={profile: session_id}`` and receive a -precise ``preferred_session`` summary per profile — hidden sessions included, -compression lineages resolved to the live tip, no pagination window — while -``last_session`` keeps its existing "most recent" contract. - -Contract under test: -- A profile named in the map gets ``preferred_session``: a summary dict when - the id resolves, ``None`` when it definitively does not (missing row, - denied internal source). -- Summary keys: ``id`` (the requested pin, durable identity), ``resolved_id`` - (live compression tip; equal to ``id`` when uncompressed), ``title``, - ``preview`` (newest user/assistant text at the tip), ``started_at``, - ``last_active``, ``message_count``. -- Profiles not named in the map carry no ``preferred_session`` key at all. -- ``last_session`` behaviour is unchanged in every case. -- ``include_sessions: false`` skips preferred resolution entirely. -- Resolution reads each profile's OWN state.db (strict per-profile scoping). -""" - -from __future__ import annotations - -import pytest - -import tui_gateway.server as srv - - -@pytest.fixture -def home(tmp_path, monkeypatch): - """Temp HERMES_HOME with the default profile plus one named profile.""" - h = tmp_path / ".hermes" - (h / "profiles" / "ops").mkdir(parents=True) - monkeypatch.setenv("HERMES_HOME", str(h)) - return h - - -def _db(profile_dir): - from hermes_state import SessionDB - - return SessionDB(db_path=profile_dir / "state.db") - - -def _add_session(db, sid, *, source="cli", title="", ts, text, hidden=False, - parent=None, end_reason=None): - """Create one session with a single user message at an exact timestamp.""" - db.create_session(sid, source, parent_session_id=parent) - db.append_message(sid, "user", text, timestamp=ts) - with db._lock: - db._conn.execute("UPDATE sessions SET title = ? WHERE id = ?", (title, sid)) - if end_reason: - # Mark ended AFTER appending: the DB (correctly) refuses writes - # to a compression-closed session. - db._conn.execute( - "UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ?", - (ts + 1, end_reason, sid), - ) - if hidden: - db.set_session_hidden(sid, True) - - -def _profiles(params): - envelope = srv._methods["profiles.list"](1, params) - return envelope["result"]["profiles"] - - -def _row(profiles, name): - return next(p for p in profiles if p["name"] == name) - - -# --------------------------------------------------------------------------- -# preferred_session resolution -# --------------------------------------------------------------------------- - - -def test_preferred_session_summarizes_pin_not_latest(home): - db = _db(home) - _add_session(db, "pinned1", title="Bot Chat", ts=1000, text="pinned chat content") - _add_session(db, "other1", title="Scratch", ts=2000, text="scratch pad content") - db.close() - - rows = _profiles({"preferred_session_ids": {"default": "pinned1"}}) - row = _row(rows, "default") - - pref = row["preferred_session"] - assert pref["id"] == "pinned1" - assert pref["resolved_id"] == "pinned1" - assert pref["root_title"] == "Bot Chat" - assert pref["title"] == "Bot Chat" - assert "pinned chat content" in pref["preview"] - # last_session keeps its own contract: the most recently active session. - assert row["last_session"]["id"] == "other1" - - -def test_preferred_session_resolves_hidden_pin(home): - db = _db(home) - _add_session(db, "hiddenpin", title="Bot Chat", ts=1000, - text="hidden bot chat content", hidden=True) - _add_session(db, "visible1", title="Visible", ts=2000, text="visible content") - db.close() - - rows = _profiles({"preferred_session_ids": {"default": "hiddenpin"}}) - row = _row(rows, "default") - - # The pin is precise: hidden from listings must not mean "does not exist". - assert row["preferred_session"] is not None - assert row["preferred_session"]["id"] == "hiddenpin" - assert "hidden bot chat content" in row["preferred_session"]["preview"] - # …while the generic latest-session listing still excludes hidden rows. - assert row["last_session"]["id"] == "visible1" - - -def test_preferred_session_missing_returns_none_and_keeps_last_session(home): - db = _db(home) - _add_session(db, "real1", title="Real", ts=1000, text="real content") - db.close() - - rows = _profiles({"preferred_session_ids": {"default": "does-not-exist"}}) - row = _row(rows, "default") - - assert row["preferred_session"] is None - assert row["last_session"]["id"] == "real1" - - -def test_preferred_session_denied_internal_source_returns_none(home): - db = _db(home) - _add_session(db, "toolrun", source="tool", title="", ts=1000, text="tool output") - _add_session(db, "human1", title="Human", ts=2000, text="human content") - db.close() - - rows = _profiles({"preferred_session_ids": {"default": "toolrun"}}) - row = _row(rows, "default") - - # Internal sources (tool sub-agent runs, kanban workers) are not - # conversations — a pin pointing at one resolves as absent. - assert row["preferred_session"] is None - - -def test_preferred_session_resolves_compression_tip(home): - db = _db(home) - _add_session(db, "root1", title="Bot Chat", ts=1000, - text="pre-compression content", end_reason="compression") - _add_session(db, "tip1", title="Bot Chat (continued)", ts=3000, - text="post-compression content", parent="root1") - _add_session(db, "other1", title="Other", ts=4000, text="other content") - db.close() - - rows = _profiles({"preferred_session_ids": {"default": "root1"}}) - row = _row(rows, "default") - - pref = row["preferred_session"] - # The pin keeps its durable identity; the summary comes from the live tip. - assert pref["id"] == "root1" - assert pref["resolved_id"] == "tip1" - assert pref["root_title"] == "Bot Chat" - assert pref["title"] == "Bot Chat (continued)" - assert "post-compression content" in pref["preview"] - - -# --------------------------------------------------------------------------- -# Contract guards -# --------------------------------------------------------------------------- - - -def test_no_param_omits_preferred_key(home): - db = _db(home) - _add_session(db, "s1", title="S", ts=1000, text="content") - db.close() - - row = _row(_profiles({}), "default") - assert "preferred_session" not in row - assert row["last_session"]["id"] == "s1" - - -def test_include_sessions_false_skips_preferred(home): - db = _db(home) - _add_session(db, "s1", title="S", ts=1000, text="content") - db.close() - - row = _row( - _profiles({"include_sessions": False, - "preferred_session_ids": {"default": "s1"}}), - "default", - ) - assert "last_session" not in row - assert "preferred_session" not in row - - -def test_preferred_ids_scoped_per_profile_db(home): - # Same session id in BOTH profiles' state.db files, different content — - # each row must summarize its own profile's database. - default_db = _db(home) - _add_session(default_db, "shared1", title="Default Bot", ts=1000, - text="default profile content") - default_db.close() - - ops_db = _db(home / "profiles" / "ops") - _add_session(ops_db, "shared1", title="Ops Bot", ts=1000, - text="ops profile content") - ops_db.close() - - rows = _profiles( - {"preferred_session_ids": {"default": "shared1", "ops": "shared1"}} - ) - assert "default profile content" in _row(rows, "default")["preferred_session"]["preview"] - assert "ops profile content" in _row(rows, "ops")["preferred_session"]["preview"] diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index a7bd780c3cc9..986155be6447 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -60,21 +60,22 @@ def _latest_message_preview(db, session_id): return text[:80] + "..." return text - def _preferred_session_row(profile_path, session_id): - """Precise summary for ONE caller-pinned session id, or None. + def _canonical_session_row(profile_path): + """Summary of the profile's canonical "Bot Chat" registry row, or None. - Complements ``last_session``: that field answers "what is the newest - conversation", this answers "what about THIS conversation". Callers - that open a specific session on click (e.g. a roster whose rows open - a pinned chat) pass their pins via ``preferred_session_ids`` so the - preview and the click target describe the same session - (hermes-agent#88200). + The canonical chat's identity is the NAME: the session titled exactly + "Bot Chat" on this profile (core UNIQUE(title) makes it a registry of + at most one row). Complements ``last_session``: that field answers + "what is the newest conversation", this answers "where is the + forever-chat" — so a roster row's preview and its click target + describe the same session (hermes-agent#88200) with no client-side + pointer involved. Exact-lookup semantics, deliberately different from the listing: - hidden rows still resolve (a hidden-from-sidebar session EXISTS), + hidden rows still resolve (canonical chats are always hidden), compression lineages resolve to the live tip with the same resolver ``session.resume`` uses, and denied internal sources (tool/kanban) - count as absent. The reported ``id`` stays the caller's durable pin + count as absent. The reported ``id`` stays the durable registry row while ``resolved_id`` names the live tip. Best-effort: any failure degrades to None rather than failing the whole profiles.list call. """ @@ -89,9 +90,12 @@ def _preferred_session_row(profile_path, session_id): deny = frozenset({"kanban", "tool"}) db = SessionDB(db_path=db_path) try: - row = db.get_session(session_id) + row = db.get_session_by_title("Bot Chat") if not row: return None + session_id = str(row.get("id") or "").strip() + if not session_id: + return None if (row.get("source") or "").strip().lower() in deny: return None if row.get("archived"): @@ -205,13 +209,6 @@ def _latest_profile_session_rows(profile_path): from hermes_cli.profiles import list_profiles include_sessions = is_truthy_value(params.get("include_sessions", True)) - # Optional precise lookups: {profile_name: session_id} from callers - # that open a specific session per row (pinned-chat rosters). Only - # resolved when include_sessions is on; each named profile row gains - # a ``preferred_session`` summary (None when the id is gone). - preferred_ids = params.get("preferred_session_ids") - if not isinstance(preferred_ids, dict): - preferred_ids = {} out = [] for p in list_profiles(): row = { @@ -231,9 +228,10 @@ def _latest_profile_session_rows(profile_path): # a profile as active while its worker runs (#90268). Older # clients ignore the extra field. row["worker_session"] = worker_row - pin = preferred_ids.get(p.name) - if isinstance(pin, str) and pin.strip(): - row["preferred_session"] = _preferred_session_row(p.path, pin.strip()) + # The profile's canonical "Bot Chat" registry row (or None) — + # identity is the NAME, resolved server-side on every listing + # so no client ever needs to carry a session pointer. + row["canonical_session"] = _canonical_session_row(p.path) # Client-agnostic UI metadata (avatars, accent colors, pinned # order, …) — stored server-side in profile.yaml so every diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 564271ea130c..91f6abb468a8 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -186,7 +186,7 @@ def _(rid, params: dict) -> dict: # resolve (canonical chats are born hidden); archived rows and # deny-listed sources do not; compression lineages resolve to the # live tip (``resolved_id``), mirroring profiles.list's - # preferred_session resolver. Older clients never send this param; + # canonical_session resolver. Older clients never send this param; # newer clients falling back to older gateways just get the normal # windowed listing back (the param is ignored) and scan it. title_lookup = str(params.get("title") or "").strip() From 64b22546df996cfd63d06937e53d9ae43763561c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:31:01 -0700 Subject: [PATCH 2/3] docs: record the Bot Mode canonical-chat invariant in AGENTS.md --- AGENTS.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b951e238fa0b..b949daf93e09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -920,6 +920,58 @@ plug into `agent/context_engine.py`; image-gen providers into [`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins) companion repo, not in this tree. +### Bot Mode (`apps/desktop/src/plugins/hermes-bots/`) + +The desktop "Bots" experience ships bundled in-tree. Each bot is a Hermes +agent **profile** with a persistent identity. Its design rests on one settled +invariant that has been regressed twice, cost users real conversation +history both times, and is not open for re-litigation in a routine PR: + +**One bot = ONE canonical forever-chat ("Bot Chat"), ever.** The full +lifecycle when a bot row is clicked: + +1. **A live pin ALWAYS and ONLY wins.** If the bot's pinned session resolves + (verified through the backend's `preferred_session` resolver), open it. + Nothing overrides it — not recency, not a newer visible session, not a + title mismatch on a pin that carries real history (grandfathered chats + stay adopted). +2. **No/dead pin → adopt before mint.** Look up the profile's existing + `Bot Chat` session by title via `session.list include_hidden:true` (the + state DB's unique-title index makes this an exact registry lookup) and + re-pin it. Only if none exists, create one. +3. **Create-time title conflict → adopt.** A failed/conflicting create means + the canonical session already exists — register that session as the pin. + Never mint a differently-titled replacement. (`set_session_title` + silently drops conflicting titles — returns 0 rows — which is how the + 2026-08 infinite fork loop started.) + +Why recency must never win (the #91791 → #92042 lesson): canonical Bot +Chats are **unconditionally hidden** from the Sessions sidebar, so the bot +row is the ONLY door to the forever-chat. A "newest visible session wins" +preference doesn't re-order two equivalent entry points — it walls the +entire relationship off behind a row that previews one session and opens +another, and any stray draft that catches a prompt captures the row. +Side-chats started via "New chat with this agent" are not plumbing-titled, +stay visible in the Sessions sidebar, and are reachable there; they are +never the bot row's target. + +Corollaries for reviewers: + +- There is no per-bot session browser, by explicit design (removed in + #90732). Do not add one back. +- A pinned session with real messages is the user's conversation whatever + its title says; only a pin resolving to an *empty* stray draft counts as + corrupted metadata. +- Reject any PR that consults recency, visibility, or "where the user left + off" while the pin is alive — reports that motivate such a change are + almost always about side-chats, and the fix belongs in the Sessions + sidebar (hide-sweep false positives), not in the bot row's target. + +Regression tests encoding this contract: +`tests/bot-row-opens-canonical-chat.test.mjs`, +`tests/canonical-chat-adopt-before-mint.test.mjs`, +`tests/canonical-chat-pin.test.mjs`, `tests/hide-bot-chats.test.mjs`. + --- ## Skills From b9f7ec747bb98171a4f1171195ef5eb5c2862383 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:09:39 -0700 Subject: [PATCH 3/3] =?UTF-8?q?docs(agents-md):=20Bot=20Mode=20canonical-c?= =?UTF-8?q?hat=20invariant=20is=20name-identity=20=E2=80=94=20corrections?= =?UTF-8?q?=20folded=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cherry-picked #92121 text documented the pin-first contract (#92042 era). Corrected to the registry contract this branch ships: identity is (profile, 'Bot Chat') via exact-title lookup; there is no session-id pin at any tier; reviewer corollaries and regression-test references updated to the surviving suites. --- AGENTS.md | 68 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b949daf93e09..e140522e964a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -924,26 +924,34 @@ companion repo, not in this tree. The desktop "Bots" experience ships bundled in-tree. Each bot is a Hermes agent **profile** with a persistent identity. Its design rests on one settled -invariant that has been regressed twice, cost users real conversation -history both times, and is not open for re-litigation in a routine PR: - -**One bot = ONE canonical forever-chat ("Bot Chat"), ever.** The full -lifecycle when a bot row is clicked: - -1. **A live pin ALWAYS and ONLY wins.** If the bot's pinned session resolves - (verified through the backend's `preferred_session` resolver), open it. - Nothing overrides it — not recency, not a newer visible session, not a - title mismatch on a pin that carries real history (grandfathered chats - stay adopted). -2. **No/dead pin → adopt before mint.** Look up the profile's existing - `Bot Chat` session by title via `session.list include_hidden:true` (the - state DB's unique-title index makes this an exact registry lookup) and - re-pin it. Only if none exists, create one. -3. **Create-time title conflict → adopt.** A failed/conflicting create means - the canonical session already exists — register that session as the pin. - Never mint a differently-titled replacement. (`set_session_title` - silently drops conflicting titles — returns 0 rows — which is how the - 2026-08 infinite fork loop started.) +invariant that has been regressed repeatedly, cost users real conversation +history each time, and is not open for re-litigation in a routine PR: + +**One bot = ONE canonical forever-chat, identified by NAME.** The chat's one +and only identity is **(profile, session titled exactly "Bot Chat")** — the +state DB's UNIQUE(title) index makes that pair an exact registry of at most +one row. The full lifecycle when a bot row is clicked: + +1. **Resolve the registry, every time.** Look up the profile's `Bot Chat` + session by exact title via `session.list {title, include_hidden: true}` + (indexed, window-free; hidden rows resolve because canonical chats are + always hidden; compression lineages resolve to the live tip). Row exists → + open it. That is the entire happy path. +2. **No row → create it,** titled `Bot Chat`, born hidden, kicked off with + the bot's intro. Creation adopts-before-minting: it re-runs the registry + lookup first, so a concurrent or pre-existing row is opened, never forked. + (`set_session_title` silently drops conflicting titles — returns 0 rows — + which is how the 2026-08 infinite fork loop started; adopt-before-mint is + what kills it.) + +**There is NO session-id pin.** The previous design stored a pointer in +`ui_meta['hermes-bots'].chat` and verified it per click; five hardening +waves (#88690, #90732, #90751, the #91791 revert, #92042) each guarded a new +way that pointer dangled or got stolen — rows[0] steals, `last_session` +adoptions, transient clears, drifted-title welds (a pin re-anchored onto a +cron session passed every guard). Name-as-identity removes the failure class: +a name cannot dangle, and a corrupted historical pointer simply never gets +read. Legacy `chat` keys in ui_meta are ignored and dropped from merges. Why recency must never win (the #91791 → #92042 lesson): canonical Bot Chats are **unconditionally hidden** from the Sessions sidebar, so the bot @@ -959,18 +967,24 @@ Corollaries for reviewers: - There is no per-bot session browser, by explicit design (removed in #90732). Do not add one back. -- A pinned session with real messages is the user's conversation whatever - its title says; only a pin resolving to an *empty* stray draft counts as - corrupted metadata. +- Reject any PR that reintroduces a stored session-id pointer as canonical + identity — including "as a fallback tier" or "for verification". The + registry lookup is the whole contract; pointers are how every prior + incident started. - Reject any PR that consults recency, visibility, or "where the user left - off" while the pin is alive — reports that motivate such a change are + off" for the bot row's target — reports that motivate such a change are almost always about side-chats, and the fix belongs in the Sessions sidebar (hide-sweep false positives), not in the bot row's target. +- The gateway reports the registry row per profile as `canonical_session` + on `profiles.list` (resolved server-side by title); roster preview, + activity signals, and the `/new`→`/compact` guard all read it, so preview + identity and click identity are the same row by construction. Regression tests encoding this contract: -`tests/bot-row-opens-canonical-chat.test.mjs`, -`tests/canonical-chat-adopt-before-mint.test.mjs`, -`tests/canonical-chat-pin.test.mjs`, `tests/hide-bot-chats.test.mjs`. +`tests/canonical-chat-registry.test.mjs` (includes a tripwire asserting the +open path never reads or writes a stored pointer), +`tests/canonical-chat-creation.test.mjs`, `tests/hide-bot-chats.test.mjs`, +and `tests/tui_gateway/test_profiles_list_canonical_session.py`. ---