fix(bot-mode): the canonical Bot Chat is found by NAME — session-id pins removed

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.
This commit is contained in:
Teknium
2026-08-22 01:04:43 -07:00
parent 4ba038b51d
commit a9860d413d
17 changed files with 525 additions and 1671 deletions

View File

@@ -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',

View File

@@ -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/)
})

View File

@@ -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

View File

@@ -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'
)
})

View File

@@ -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' } }
])
})

View File

@@ -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' } }
])
})

View File

@@ -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)
})

View File

@@ -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), {})
})

View File

@@ -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' } }])
})

View File

@@ -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'))
})

View File

@@ -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\)\)/)
})

View File

@@ -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/)
})

View File

@@ -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
})

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -60,21 +60,22 @@ def _(rid, params: dict) -> dict:
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 _(rid, params: dict) -> dict:
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 _(rid, params: dict) -> dict:
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 _(rid, params: dict) -> dict:
# 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

View File

@@ -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()