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:
@@ -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',
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -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' } }
|
||||
])
|
||||
})
|
||||
@@ -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' } }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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), {})
|
||||
})
|
||||
@@ -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' } }])
|
||||
})
|
||||
@@ -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'))
|
||||
})
|
||||
@@ -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\)\)/)
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
189
tests/tui_gateway/test_profiles_list_canonical_session.py
Normal file
189
tests/tui_gateway/test_profiles_list_canonical_session.py
Normal 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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user