diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index eed110e190..c332c44ae7 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -4096,18 +4096,31 @@ function isCanonicalBotChatHistory(history) { * (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) { + // FAIL CLOSED. A failed registry lookup MUST NOT read as "no Bot Chat + // exists" — that is the one remaining way to fork a bot's forever chat. + // The failure lives exactly in the post-update window: the desktop + // restarts every profile backend, the first bot click races the warm-up, + // the lookup RPC fails transiently, and a swallowed error here sent + // createCanonicalChat() straight to session.create — minting a fresh + // "Bot Chat" while the real one (data intact, hidden) still held the + // canonical title. Users read that as "my bot lost all context after the + // update". Both open paths catch and toast "try again", which is the + // correct outcome for a transient lookup failure: retry, never mint. + let res try { - const res = await host.request('session.list', { + res = await host.request('session.list', { profile: name, title: CANONICAL_CHAT_TITLE, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true }) - const rows = res?.sessions ?? [] - return rows.find(row => isCanonicalBotChatHistory(row)) || null - } catch { - return null + } catch (error) { + const detail = error instanceof Error && error.message ? ` (${error.message})` : '' + throw new Error(`Could not check ${name}'s Bot Chat registry${detail} — not starting a new chat`) } + + const rows = res?.sessions ?? [] + return rows.find(row => isCanonicalBotChatHistory(row)) || null } /** Create the bot's ONE forever chat: a real session titled "Bot Chat", 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 index 4901439cc2..f5d242ae4e 100644 --- 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 @@ -175,3 +175,50 @@ test('an ordinary titled session never satisfies the registry lookup', async () 'no row titled "Bot Chat" → create; never adopt an ordinary conversation') assert.ok(!runtime.opened.some(o => o.id === 'scratch')) }) + +// ── 4. a failed lookup fails CLOSED — never "no chat exists" ──────────────── +// +// The post-update window: the desktop restarts every profile backend, the +// first bot click races the warm-up, and the registry lookup RPC fails +// transiently. Swallowing that error and returning null made the failure +// indistinguishable from "this bot has no Bot Chat yet", so the create path +// minted a fresh forever-chat while the real one (data intact, hidden) still +// held the canonical title — read by users as "my bot lost everything after +// the update". A lookup failure must surface (the open paths toast +// "try again"), never resolve to mint. + +test('a failed registry lookup rejects instead of minting a replacement chat', async () => { + const runtime = loadOpenPath({ + request: async method => { + if (method === 'session.list') { + throw new Error('gateway not ready') + } + if (method === 'session.create') { + throw new Error('must not create: a failed lookup is not "no chat exists"') + } + return {} + } + }) + + await assert.rejects(() => runtime.openBotCanonicalChat('ops'), /Bot Chat registry/) + assert.ok(!runtime.requests.some(r => r.method === 'session.create'), + 'session.create must never fire off a failed lookup') + assert.equal(runtime.opened.length, 0) +}) + +test('createCanonicalChat also refuses to mint when the adoption lookup fails', async () => { + const runtime = loadOpenPath({ + request: async method => { + if (method === 'session.list') { + throw new Error('backend warming up') + } + if (method === 'session.create') { + throw new Error('must not create: adoption check failed, ownership is unknown') + } + return {} + } + }) + + await assert.rejects(() => runtime.createCanonicalChat('ops'), /Bot Chat registry/) + assert.ok(!runtime.requests.some(r => r.method === 'session.create')) +}) 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 7a7df457e7..8d973a82c0 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 @@ -157,5 +157,5 @@ test('the canonical-chat adoption scan lists with include_hidden', () => { // 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\)\)/) + assert.match(source, /include_hidden: true\s*\}\)\s*\} catch \(error\) \{[\s\S]{0,400}?const rows = res\?\.sessions \?\? \[\]\s*return rows\.find\(row => isCanonicalBotChatHistory\(row\)\)/) })