diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 712042ef53..ce66a17929 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -120,13 +120,17 @@ function setActivityToasts(enabled) { } /** Detect new inbound activity from a fresh roster: last_active moved past - * the watermark for a bot whose chat isn't on screen -> unread + toast. */ + * the watermark for a bot whose chat isn't on screen -> unread + toast. + * Watermarks follow botActivitySession (canonical Bot Chat included) — + * last_session alone never sees the hidden Bot Chat, so DMs delivered + * there would neither badge nor toast. */ function trackInboundActivity(roster) { const seeding = !watermarksSeeded watermarksSeeded = true for (const bot of roster) { - const ts = bot.last_session?.last_active || 0 + const activity = botActivitySession(bot) + const ts = activity?.last_active || 0 const prev = rosterWatermarks.get(bot.name) || 0 rosterWatermarks.set(bot.name, Math.max(prev, ts)) @@ -153,7 +157,7 @@ function trackInboundActivity(roster) { if ($activityToasts.get()) { const meta = $botMeta.get()[bot.name] const label = displayName(bot, meta) - const preview = (bot.last_session?.preview || '').trim() + const preview = (activity?.preview || '').trim() const inbound = /^Message from/i.test(preview) host.notify({ @@ -4762,6 +4766,28 @@ function generatedSessionTitle(session, preview) { * seconds is treated as "active now" (pulsing dot in its row). */ 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 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. */ +function botActivitySession(bot) { + const preferred = bot?.preferred_session + const last = bot?.last_session + + if (!preferred || !last) { + return preferred || last || null + } + + return (preferred.last_active || 0) >= (last.last_active || 0) ? preferred : last +} + /** Bots that are working right now: the profile the gateway is running a * turn for (busy), plus any bot whose last message landed inside the * liveness window. Pure — output follows the input roster's order, so @@ -4769,7 +4795,7 @@ const ACTIVE_WINDOW_S = 90 function activeBots(roster, activeProfile, gatewayState, now = Date.now()) { return (roster || []).filter(bot => { const busyTurn = !bot.remoteSource && bot.name === activeProfile && gatewayState === 'busy' - const last = bot.last_session?.last_active || 0 + const last = botActivitySession(bot)?.last_active || 0 const inWindow = Boolean(last && now / 1000 - last < ACTIVE_WINDOW_S) return busyTurn || inWindow @@ -4797,7 +4823,17 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { // Keep user photos/pets. Drop the 160px SVG backfill so the math face can move. const photo = Boolean(image && !isBackfilledFacePng(image)) const gatewayState = useValue(host.state.gateway) - const activeNow = Boolean(last?.last_active && Date.now() / 1000 - last.last_active < ACTIVE_WINDOW_S) + // Preview identity must match click identity (#88200): when the backend + // resolved the pinned canonical chat, preview THAT session — not the + // profile's most recent (but unrelated) activity. Activity signals + // (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 activitySession = botActivitySession(bot) + const activeNow = Boolean( + activitySession?.last_active && Date.now() / 1000 - activitySession.last_active < ACTIVE_WINDOW_S + ) // Work pose only when this bot is actually doing something: the active // profile while the gateway is busy, or a bot that wrote within the // liveness window. Not every bot whenever the gateway is busy. @@ -4808,11 +4844,6 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { const unread = !bot.remoteSource && Boolean(unreadByName[bot.name]) // WHO sent the last message (bot-to-bot DM vs human) — the full stored // history lives in the Sessions workspace (context menu), not inline. - // Preview identity must match click identity (#88200): when the backend - // resolved the pinned canonical chat, preview THAT session — not the - // profile's most recent (but unrelated) activity. Liveness checks above - // keep last_session semantics: any recent activity means the bot is alive. - const previewSession = bot.preferred_session || last const { fromBot } = previewKind(previewSession?.preview) // DM previews read like DMs: strip the delivery prefix, keep the message. const displayPreview = stripPreviewMarkdown( @@ -4983,10 +5014,10 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { title: 'Active in the last 90s' }) : null, - last + activitySession ? jsx('span', { className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)', - children: relativeTime(last.last_active * 1000) + children: relativeTime(activitySession.last_active * 1000) }) : null ] @@ -9541,7 +9572,7 @@ function BotsPane() { // No special slot for the primary bot — it competes on recency too. const activityOf = bot => { const created = botRosterMeta(bot, allMeta)?.created || bot.ui_meta?.['hermes-bots']?.created || 0 - const lastMsg = (bot.last_session?.last_active || 0) * 1000 + const lastMsg = (botActivitySession(bot)?.last_active || 0) * 1000 return Math.max(created, lastMsg) } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs index c56c587bac..10d57932f6 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs @@ -7,7 +7,7 @@ const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') // The activeBots helper must stay a self-contained slice between the // liveness-window constant and the BotRow section so tests can extract it. -function loadActiveBots() { +function loadActiveBotsSlice() { const start = source.indexOf('const ACTIVE_WINDOW_S') const end = source.indexOf('// ── bot row ─') @@ -15,11 +15,19 @@ function loadActiveBots() { const context = {} vm.runInNewContext( - `${source.slice(start, end)}\nglobalThis.__activeBots = activeBots;`, + `${source.slice(start, end)}\nglobalThis.__activeBots = activeBots;\nglobalThis.__botActivitySession = botActivitySession;`, context ) - return context.__activeBots + return context +} + +function loadActiveBots() { + return loadActiveBotsSlice().__activeBots +} + +function loadBotActivitySession() { + return loadActiveBotsSlice().__botActivitySession } // Fixed clock so "inside the window" vs "stale" is deterministic. @@ -74,6 +82,57 @@ test('roster without profiles never throws', () => { assert.equal(activeBots([], 'default', 'open', NOW).length, 0) }) +// ── botActivitySession: canonical Bot Chat activity counts (hermes-agent "6d ago" bug) ── + +test('botActivitySession picks the fresher preferred_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' }, + // Newest VISIBLE session: 6 days old — what last_session alone reports. + last_session: { id: 'old-scratch', last_active: NOW / 1000 - 6 * 86400, preview: 'ancient' } + } + assert.equal(botActivitySession(bot).id, 'bot-chat') +}) + +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 }, + last_session: { id: 'scratch', last_active: NOW / 1000 - 10 } + } + assert.equal(botActivitySession(bot).id, 'scratch') +}) + +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({}), null) + assert.equal(botActivitySession(null), null) +}) + +test('activeBots counts Bot Chat activity that last_session cannot see', () => { + const activeBots = loadActiveBots() + const bots = [ + { + name: 'default', + preferred_session: { last_active: NOW / 1000 - 5 }, + last_session: { last_active: NOW / 1000 - 6 * 86400 } + } + ] + const names = activeBots(bots, 'other', 'open', NOW).map(bot => bot.name) + assert.ok(names.includes('default'), 'fresh canonical-chat activity must light the pulse dot') +}) + +test('row age label and recency sort key off botActivitySession, not last_session', () => { + // The "6d ago" regression: the timestamp/sort sites must not read + // bot.last_session directly anymore. + assert.match(source, /relativeTime\(activitySession\.last_active \* 1000\)/) + assert.match(source, /const lastMsg = \(botActivitySession\(bot\)\?\.last_active \|\| 0\) \* 1000/) + assert.doesNotMatch(source, /relativeTime\(last\.last_active \* 1000\)/) +}) + test('ActiveNowStrip renders above the roster, is a live region, and is click-accessible', () => { // Strip is placed between the pane header and the search field. const headerEnd = source.indexOf("children: 'Bots'") diff --git a/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs index cd89ff4942..3ba2d2968e 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs @@ -13,6 +13,11 @@ const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') function loadTracker(toastsEnabled) { const start = source.indexOf('const rosterWatermarks = new Map()') const end = source.indexOf('/** Last good cron list', start) + // The tracker keys watermarks off the REAL botActivitySession helper + // (defined later in plugin.js) — extract it so the harness can't drift. + const helperStart = source.indexOf('function botActivitySession(') + const helperEnd = source.indexOf('/** Bots that are working', helperStart) + assert.ok(helperStart >= 0 && helperEnd > helperStart, 'botActivitySession must remain extractable') const notifications = [] const context = { pluginCtx: null, @@ -30,7 +35,8 @@ function loadTracker(toastsEnabled) { displayName: bot => bot.name } const section = source - .slice(start, end) + .slice(helperStart, helperEnd) + .concat('\n', source.slice(start, end)) .concat('\nglobalThis.__t = { trackInboundActivity, $activityToasts, setActivityToasts };\n') vm.runInNewContext(section, context, { filename: 't.js' }) if (toastsEnabled) { @@ -68,3 +74,19 @@ test('pref defaults OFF and persists via ctx.storage under activity-toasts', () ) assert.match(source, /storage\?\.get\?\.\('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. + 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' } + } + ] + t.trackInboundActivity(at(150)) // seeding poll + t.trackInboundActivity(at(250)) // Bot Chat got a DM; last_session unchanged + assert.equal(t.$botUnread.get().researcher, true, 'hidden Bot Chat activity must set unread') +}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs index 57ade31cd8..971ba37980 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs @@ -15,6 +15,12 @@ function sourceBetween(start, end) { return source.slice(from, to) } +// BotRow's activity resolver — extract the REAL helper so the harness can't +// drift from production behavior. +function activitySessionSource() { + return sourceBetween('function botActivitySession(', '/** Bots that are working') +} + function renderBotRow(input = 'alpha') { const bot = typeof input === 'string' ? { name: input } : input const name = bot.name @@ -93,7 +99,7 @@ function renderBotRow(input = 'alpha') { useValue: store => store.get() } - vm.runInNewContext(`${prepareSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context) + vm.runInNewContext(`${activitySessionSource()}\n${prepareSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context) const tree = context.BotRow({ bot, onEdit: context.onEdit }) const row = tree.type === 'button' ? tree : tree.props.children[0].props.children @@ -215,7 +221,7 @@ test('behavior: remote default does not open this-device chat when the source di useValue: store => store.get() } - vm.runInNewContext(`${prepareSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context) + vm.runInNewContext(`${activitySessionSource()}\n${prepareSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context) const tree = context.BotRow({ bot, onEdit: context.onEdit }) const row = tree.type === 'button' ? tree : tree.props.children[0].props.children