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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user