diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index cf7b36a5cd4f..43a65833cc49 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -435,17 +435,50 @@ function fallbackSelectionAfterHide(name) { * setter is a no-op on already-hidden rows) and feature-detected: older * gateways lack session.set_hidden and simply keep the rows visible. */ function hideOwnedBotSessions() { - const canonical = Object.values($botMeta.get()) - .map(m => m && m.chat) - .filter(Boolean) + const 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) - const ids = [...new Set([...canonical, ...rooms])] - const known = Promise.all( - ids.map(sid => - Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden: true })).catch(() => undefined) + // 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) + ) ) ) @@ -3352,19 +3385,28 @@ function createCanonicalChat(name) { * * Identity rules (hermes-agent#88200 — the row must open the session its * preview describes): - * - grandfather: no pin + existing history adopts the previewed session + * - 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; + * 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') +} + async function openBotCanonicalChat(name, pinned, history) { if (!pinned) { - // Grandfather: adopt the conversation the row already previews. - const adoptId = history?.id + // 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 }) @@ -3400,7 +3442,7 @@ async function openBotCanonicalChat(name, pinned, history) { return openStoredBotChat(name, pinned, history) } - if (preferred) { + if (preferred && isCanonicalBotChatHistory(preferred)) { try { await openStoredBotChat(name, preferred.resolved_id || preferred.id, preferred) return pinned @@ -3413,9 +3455,19 @@ 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. + 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. - const recoveryId = history?.id + // 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 }) 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 index 7fd8e34bb647..df26346867d7 100644 --- 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 @@ -33,7 +33,7 @@ function loadOpenPath({ openSession, request }) { return { ...context.__open, saved, requests, host: context.host } } -const HISTORY = { id: 'hist-1', title: 'Weekly review', preview: 'history preview', last_active: 1000 } +const HISTORY = { id: 'hist-1', title: 'Bot Chat', preview: 'history preview', last_active: 1000 } // ── grandfather: no pin + existing history adopts the previewed session ──── @@ -51,6 +51,23 @@ test('grandfather: no pin + history opens and pins THAT session, no new chat', a '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, @@ -121,6 +138,32 @@ test('pin: preferred_session present opens the resolved session and keeps the pi 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({ @@ -131,7 +174,7 @@ test('pin: compression-rotated pin opens the live tip, keeps the durable pin', a profiles: [{ name: 'ops', preferred_session: { - id: 'root-1', resolved_id: 'tip-9', title: 'Bot Chat', + 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 } }] @@ -166,6 +209,27 @@ test('pin: definitively gone pin re-pins to the previewed session, not rows[0]', 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, 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 index b2796ba933b5..630ddf1a73dd 100644 --- 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 @@ -80,7 +80,7 @@ test('regression: only an actually-missing pin triggers recovery', async () => { // 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: 'Weekly review', preview: 'p', last_active: 1 } + 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/hide-bot-chats.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs index fda52c1c0683..0e3cf2845db0 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 @@ -59,6 +59,14 @@ 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 {} } }, @@ -75,9 +83,36 @@ test('hideOwnedBotSessions sweeps canonical chats AND room member sessions', asy vm.runInNewContext(section, context, { filename: 'h.js' }) await context.__h.hideOwnedBotSessions() - const ids = calls.map(c => c.params.session_id).sort() + 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.ok(calls.every(c => c.method === 'session.set_hidden' && c.params.hidden === true)) + 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 () => { + 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) }) test('sweepBotProfileSessions hides Bot-Mode-titled rows per roster bot, and only those', async () => { diff --git a/tests/tui_gateway/test_profiles_list_preferred_session.py b/tests/tui_gateway/test_profiles_list_preferred_session.py index 15b2a24dc75e..9c130f15b5a1 100644 --- a/tests/tui_gateway/test_profiles_list_preferred_session.py +++ b/tests/tui_gateway/test_profiles_list_preferred_session.py @@ -90,6 +90,7 @@ def test_preferred_session_summarizes_pin_not_latest(home): 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. @@ -156,6 +157,8 @@ def test_preferred_session_resolves_compression_tip(home): # 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"] diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index 8352c83d5492..85e2ecf04371 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -109,6 +109,7 @@ def _preferred_session_row(profile_path, session_id): return { "id": session_id, "resolved_id": tip, + "root_title": row.get("title") or "", "title": tip_row.get("title") or "", "preview": preview, "started_at": tip_row.get("started_at") or row.get("started_at") or 0,