Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 65 additions & 13 deletions apps/desktop/src/plugins/hermes-bots/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
)
)

Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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
Expand All @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────

Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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
}
}]
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' } }])
})
39 changes: 37 additions & 2 deletions apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}
},
Expand All @@ -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 () => {
Expand Down
3 changes: 3 additions & 0 deletions tests/tui_gateway/test_profiles_list_preferred_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"]


Expand Down
1 change: 1 addition & 0 deletions tui_gateway/methods_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down