From 41d92a1c890b4e8dc89bb5e28fe0bdd05e7f0937 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:21:58 -0700 Subject: [PATCH] fix(bot-mode): a dead/stale chat pin adopts the existing hidden Bot Chat instead of reintroducing the bot on a new session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom (reported live on Windows after an update): opening a bot showed a fresh 'introduce yourself' session and the real forever-chat history looked gone. Root cause: the pinned canonical-chat id can go stale (points at a session id that was never persisted or was rewritten past recovery). On a dead pin, profiles.list returns preferred_session=null, and the recovery branches relied on last_session/preferred_session for an adoptable history — but both are computed from a hidden-EXCLUDING query, and Bot Mode sessions are hidden by design. So the real Bot Chat (intact on disk) was never found and every open minted a new intro. Fix: before minting, findExistingCanonicalBotChat browses the profile's hidden sessions (session.list include_hidden:true — the same view the Sessions submenu uses) and adopts the existing 'Bot Chat'. All three mint-new branches route through adoptOrCreateCanonicalChat; the bot is reintroduced ONLY when there is genuinely no forever-chat to return to. The user's messages were never lost — only unpinned. Verified end-to-end over gateway RPC against a real profile DB: dead pin -> preferred_session=null -> session.list finds the hidden Bot Chat -> session.resume returns the real history (not a new intro). 335/335 plugin tests; new adopt tests fail on reverted plugin (sabotage-checked). --- .../desktop/src/plugins/hermes-bots/plugin.js | 82 +++++++++++++++++-- .../tests/canonical-chat-adopt.test.mjs | 54 ++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-adopt.test.mjs diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 10d06429a5c8e..d51a4b632347a 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -3468,6 +3468,69 @@ function isCanonicalBotChatHistory(history) { return rootTitle === 'Bot Chat' || (!rootTitle && title === 'Bot Chat') } +/** Last-resort recovery when the pinned id is dead AND no adoptable history + * was handed in: the canonical Bot Chat is hidden from the roster's + * last_session/preferred_session (both computed from a hidden-EXCLUDING + * query), so a stale/never-persisted pin used to fall straight through to + * createCanonicalChat and reintroduce the bot on a brand-new session while + * the real forever-chat sat intact but hidden on disk. Browse the profile's + * own hidden sessions (the same include_hidden view the Sessions submenu + * uses) and adopt the existing "Bot Chat" instead of minting a new one. + * Returns the adoptable stored id, or null when there genuinely isn't one. */ +async function findExistingCanonicalBotChat(name) { + if (typeof host.request !== 'function') { + return null + } + + let rows + try { + const res = await host.request('session.list', { + profile: name, + limit: PROFILE_SESSION_LIST_LIMIT, + include_hidden: true + }) + rows = res?.sessions ?? res?.rows ?? (Array.isArray(res) ? res : null) + } catch { + // Older gateway without include_hidden (or a transient failure): no safe + // adoption target — the caller keeps its existing behavior. + return null + } + + if (!Array.isArray(rows)) { + return null + } + + // Prefer the most recently active canonical Bot Chat. session.list is + // already newest-first, so the first match wins. + for (const row of rows) { + if (isCanonicalBotChatHistory(row)) { + return row.resolved_id || row.id || row.session_id || null + } + } + + return null +} + +/** Adopt the profile's existing hidden Bot Chat if one exists; else mint a + * fresh canonical chat. Centralizes the dead-pin / no-history recovery so + * every branch reintroduces the bot ONLY when there is truly no forever-chat + * to return to. */ +async function adoptOrCreateCanonicalChat(name) { + const existing = await findExistingCanonicalBotChat(name) + if (existing && typeof host.openSession === 'function') { + try { + await openStoredBotChat(name, existing, null) + saveBotMeta(name, { chat: existing }) + return existing + } catch { + // Fall through to a fresh chat only if opening the adopted row failed + // outright (not a transient hydration retry, which openStoredBotChat + // already handles internally). + } + } + return createCanonicalChat(name) +} + async function openBotCanonicalChat(name, pinned, history) { if (!pinned) { // Grandfather only an actual Bot Chat. `last_session` is merely the most @@ -3479,7 +3542,10 @@ async function openBotCanonicalChat(name, pinned, history) { saveBotMeta(name, { chat: adoptId }) return adoptId } - return createCanonicalChat(name) + // No pin and no adoptable preview — but the forever-chat may still exist + // hidden on disk (its id just isn't surfaced by the roster query). Adopt + // it before minting a new introduction. + return adoptOrCreateCanonicalChat(name) } // Precise verification. An older gateway ignores the unknown param and @@ -3524,10 +3590,11 @@ async function openBotCanonicalChat(name, pinned, history) { if (preferred) { // The stored pointer resolved to a real session, but not to Bot Mode's - // plumbing session. Treat it as corrupted metadata rather than opening or - // hiding the user's ordinary conversation. + // plumbing session. The pin is bad — but before minting a NEW chat (which + // reintroduces the bot), adopt the profile's existing hidden Bot Chat if + // one is there. Only reintroduce when there truly is no forever-chat. await saveBotMeta(name, { chat: null }) - return createCanonicalChat(name) + return adoptOrCreateCanonicalChat(name) } // Definitively gone (db reset, or the lineage was rewritten past @@ -3540,8 +3607,11 @@ async function openBotCanonicalChat(name, pinned, history) { saveBotMeta(name, { chat: recoveryId }) return recoveryId } - saveBotMeta(name, { chat: null }) - return createCanonicalChat(name) + // The pin is gone and there's no adoptable preview — but the real + // forever-chat is hidden from that preview. Adopt it (by title, from the + // hidden-inclusive listing) before reintroducing the bot on a new session. + await saveBotMeta(name, { chat: null }) + return adoptOrCreateCanonicalChat(name) } async function prepareBotSource(bot, pinnedChat) { diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-adopt.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-adopt.test.mjs new file mode 100644 index 0000000000000..c84354045d93f --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-adopt.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +// Bug: a bot whose pinned canonical-chat id is dead/stale (points at a session +// id that was never persisted, or was rewritten past recovery) reintroduced +// itself on a brand-new session every open — while its real forever-chat sat +// intact but HIDDEN on disk. The roster's last_session/preferred_session are +// both computed from a hidden-EXCLUDING query, so the recovery branches had no +// adoptable history and fell straight through to createCanonicalChat. +// +// Fix: before minting a new chat, browse the profile's hidden sessions +// (session.list include_hidden:true — the same view the Sessions submenu uses) +// and adopt the existing "Bot Chat". + +test('a dead/stale pin adopts the existing hidden Bot Chat instead of reintroducing', () => { + // The recovery helper exists and browses hidden sessions. + assert.match(source, /async function findExistingCanonicalBotChat\(name\)/) + assert.match(source, /async function adoptOrCreateCanonicalChat\(name\)/) + + const finder = source.slice( + source.indexOf('async function findExistingCanonicalBotChat'), + source.indexOf('async function adoptOrCreateCanonicalChat') + ) + // It queries the hidden-inclusive listing, not the roster's hidden-excluding one. + assert.match(finder, /session\.list/) + assert.match(finder, /include_hidden:\s*true/) + // It only adopts an actual Bot Chat (never an unrelated user conversation). + assert.match(finder, /isCanonicalBotChatHistory\(row\)/) +}) + +test('every mint-new branch routes through adoptOrCreateCanonicalChat', () => { + const fn = source.slice( + source.indexOf('async function openBotCanonicalChat'), + source.indexOf('async function prepareBotSource') + ) + // The old direct createCanonicalChat fall-throughs are gone from the + // recovery branches — they adopt first now. + const adoptCalls = (fn.match(/adoptOrCreateCanonicalChat\(name\)/g) || []).length + assert.ok(adoptCalls >= 3, `expected >=3 adopt-or-create fallbacks, found ${adoptCalls}`) +}) + +test('adopt path opens the stored chat and re-pins it', () => { + const adopt = source.slice( + source.indexOf('async function adoptOrCreateCanonicalChat'), + source.indexOf('async function openBotCanonicalChat') + ) + assert.match(adopt, /openStoredBotChat\(name, existing/) + assert.match(adopt, /saveBotMeta\(name, \{ chat: existing \}\)/) + // Falls back to a fresh chat only when there's genuinely nothing to adopt. + assert.match(adopt, /return createCanonicalChat\(name\)/) +})