fix(bot-mode): roster age, pulse, unread, and sort now see canonical Bot Chat activity

The canonical Bot Chat is hidden from session lists by design, so
profiles.list's last_session never advances when you message a bot there.
PR #88690 moved the roster PREVIEW to preferred_session but left every
activity signal on last_session — a bot you just messaged showed '6d ago',
never pulsed, never badged, and sorted below stale bots.

New botActivitySession(bot) helper returns the fresher of preferred_session
(the pinned Bot Chat, resolved precisely by the backend) and last_session
(newest visible conversation). All four activity sites key off it now:

- row age label (relativeTime)
- active-now pulse dot + activeBots strip
- unread watermark + activity toast preview
- roster recency sort (activityOf)

Older gateways without the preferred_session resolver degrade to
last_session exactly as before. Backend untouched.

Tests: extracted the real helper into the vm harnesses (no stub drift),
5 new behavior tests; sabotage-verified they fail against the old code.
This commit is contained in:
Teknium
2026-08-19 15:38:01 -07:00
parent 7b25941b0e
commit 010c9925e3
4 changed files with 137 additions and 19 deletions
+44 -13
View File
@@ -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)
}
@@ -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'")
@@ -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')
})
@@ -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