Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 48 additions & 4 deletions apps/desktop/src/plugins/hermes-bots/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3381,6 +3381,31 @@ async function openStoredBotChat(name, storedId, summary) {
return storedId
}

/** Open the human-facing session that made a bot appear in Active now.
* Active-now chips represent live work, so they must not route through the
* roster row's canonical-chat opener (which may create a new chat or fall
* back to a Home draft when no canonical pin exists). */
async function openActiveBotSession(name, session) {
const storedId = session?.resolved_id || session?.id

if (!storedId || typeof host.openSession !== 'function') {
return null
}

try {
await openStoredBotChat(name, storedId, session)
return storedId
} catch (error) {
const message = error instanceof Error ? error.message : String(error)

if (/session not found/i.test(message)) {
return null
}

throw error
}
}

/** 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
Expand Down Expand Up @@ -4987,6 +5012,15 @@ function botActivitySession(bot) {
return (preferred.last_active || 0) >= (last.last_active || 0) ? preferred : last
}

/** The human-facing session that actually made this bot active now. A live
* worker or a busy turn can light the strip without a fresh chat, so callers
* must not mistake stale human history for the active destination. */
function activeHumanSession(bot, now = Date.now()) {
const session = botActivitySession(bot)
const lastActive = session?.last_active || 0
return lastActive && now / 1000 - lastActive < ACTIVE_WINDOW_S ? session : null
}

/** Worker liveness window: kanban/tool workers heartbeat last_activity_at
* at least every 60s while running (agent/session_activity.py), so a
* worker whose stamp is older than this is finished or stalled. Wider
Expand All @@ -5010,8 +5044,7 @@ function workerActiveAt(bot, now = Date.now()) {
function activeBots(roster, activeProfile, gatewayState, now = Date.now()) {
return (roster || []).filter(bot => {
const busyTurn = !bot.remoteSource && bot.name === activeProfile && gatewayState === 'busy'
const last = botActivitySession(bot)?.last_active || 0
const inWindow = Boolean(last && now / 1000 - last < ACTIVE_WINDOW_S)
const inWindow = Boolean(activeHumanSession(bot, now))

return busyTurn || inWindow || workerActiveAt(bot, now)
})
Expand Down Expand Up @@ -8019,7 +8052,7 @@ function RoutinesPane() {
/** "Active now" presence strip above the roster: chips for every bot that is
* working right now (the gateway-busy selected profile + bots whose last
* message landed inside the liveness window). Reuses the row avatar; each
* chip opens that bot's canonical Bot Chat. Omitted entirely when nothing
* chip opens that bot's active human-facing session. Omitted entirely when nothing
* is active, and never reorders the roster below it. */
function ActiveNowStrip({ roster, activeProfile, gatewayState, metaByName, onOpen }) {
const active = activeBots(roster, activeProfile, gatewayState)
Expand All @@ -8046,7 +8079,7 @@ function ActiveNowStrip({ roster, activeProfile, gatewayState, metaByName, onOpe

return jsx('button', {
type: 'button',
title: `Open ${label}'s chat`,
title: `Open ${label}'s active chat`,
className: cn(
'flex items-center gap-1.5 rounded-md bg-(--chrome-action-hover) px-1.5 py-1 text-left transition-colors',
'hover:bg-(--chrome-action-hover) hover:text-foreground'
Expand Down Expand Up @@ -9901,6 +9934,17 @@ function BotsPane() {
}

try {
const activeSession = activeHumanSession(bot)
const activeId = await openActiveBotSession(bot.name, activeSession)

if (generation !== botOpenGeneration) {
return
}

if (activeId) {
return
}

const id = await openBotCanonicalChat(
bot.name,
pinnedChat,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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 load(open = async storedId => storedId) {
const opened = []
const start = source.indexOf('async function openActiveBotSession(')
const end = source.indexOf("\n\n/** Create the bot's ONE forever chat", start)
const context = {
host: { openSession: async () => undefined },
openStoredBotChat: async (name, storedId, summary) => {
opened.push({ name, storedId, summary })
return open(storedId)
}
}

assert.notEqual(start, -1, 'active-session opener is missing')
assert.notEqual(end, -1, 'active-session opener boundary is missing')
vm.runInNewContext(source.slice(start, end).concat('\nglobalThis.__active = { openActiveBotSession };\n'), context, {
filename: 'active-now-session.js'
})

return { ...context.__active, opened }
}

test('Active now opens the session that supplied the activity signal', async () => {
const runtime = load()
const session = {
id: 'active-session',
title: 'Running task',
preview: 'still working',
last_active: 1_900_000_000,
message_count: 3
}

const result = await runtime.openActiveBotSession('ops', session)

assert.equal(result, 'active-session')
assert.deepEqual(runtime.opened, [{ name: 'ops', storedId: 'active-session', summary: session }])
})

test('Active now uses a resolved lineage tip when one is supplied', async () => {
const runtime = load()
const session = {
id: 'pinned-root',
resolved_id: 'live-tip',
title: 'Bot Chat',
message_count: 4
}

const result = await runtime.openActiveBotSession('ops', session)

assert.equal(result, 'live-tip')
assert.deepEqual(runtime.opened, [{ name: 'ops', storedId: 'live-tip', summary: session }])
})

test('Active now falls back when no human-facing session is available', async () => {
const runtime = load()

assert.equal(await runtime.openActiveBotSession('ops', null), null)
assert.equal(await runtime.openActiveBotSession('ops', { source: 'kanban' }), null)
assert.deepEqual(runtime.opened, [])
})

test('Active now falls back when the activity session is definitively gone', async () => {
const runtime = load(async () => {
throw new Error('Session not found')
})

assert.equal(await runtime.openActiveBotSession('ops', { id: 'stale-session' }), null)
assert.deepEqual(
runtime.opened.map(call => call.storedId),
['stale-session']
)
})

test('Active now surfaces transient hydration failures instead of opening Home', async () => {
const runtime = load(async () => {
throw new Error('Timed out waiting for session history hydration')
})

await assert.rejects(runtime.openActiveBotSession('ops', { id: 'live-session' }), /history hydration/)
assert.deepEqual(
runtime.opened.map(call => call.storedId),
['live-session']
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function loadActiveBotsSlice() {

const context = {}
vm.runInNewContext(
`${source.slice(start, end)}\nglobalThis.__activeBots = activeBots;\nglobalThis.__botActivitySession = botActivitySession;`,
`${source.slice(start, end)}\nglobalThis.__activeBots = activeBots;\nglobalThis.__botActivitySession = botActivitySession;\nglobalThis.__activeHumanSession = activeHumanSession;`,
context
)

Expand All @@ -30,6 +30,10 @@ function loadBotActivitySession() {
return loadActiveBotsSlice().__botActivitySession
}

function loadActiveHumanSession() {
return loadActiveBotsSlice().__activeHumanSession
}

// Fixed clock so "inside the window" vs "stale" is deterministic.
const NOW = 1_000_000_000_000

Expand Down Expand Up @@ -112,6 +116,24 @@ test('botActivitySession degrades to whichever side exists (older gateways / no
assert.equal(botActivitySession(null), null)
})

test('activeHumanSession returns the fresh session that supplied the activity signal', () => {
const activeHumanSession = loadActiveHumanSession()
const fresh = { id: 'live', last_active: NOW / 1000 - 10 }
const stale = { id: 'old', last_active: NOW / 1000 - 400 }

assert.equal(activeHumanSession({ preferred_session: stale, last_session: fresh }, NOW).id, 'live')
})

test('activeHumanSession ignores stale human history when only a worker is active', () => {
const activeHumanSession = loadActiveHumanSession()
const bot = {
last_session: { id: 'old', last_active: NOW / 1000 - 400 },
worker_session: { id: 'worker', source: 'kanban', last_active: NOW / 1000 - 10 }
}

assert.equal(activeHumanSession(bot, NOW), null)
})

test('activeBots counts Bot Chat activity that last_session cannot see', () => {
const activeBots = loadActiveBots()
const bots = [
Expand Down Expand Up @@ -173,13 +195,21 @@ test('ActiveNowStrip renders above the roster, is a live region, and is click-ac
// Live region announces membership changes politely.
assert.match(source, /'aria-live': 'polite'/)
// Chips are real buttons (keyboard/click accessible), reuse BotFace, and
// open the canonical chat via the same path as roster rows.
assert.match(source, /jsx\('button', \{\s*type: 'button',\s*title: `Open \$\{label\}'s chat`/)
// open the active human-facing session instead of the canonical chat used
// by ordinary roster rows.
assert.match(source, /jsx\('button', \{\s*type: 'button',\s*title: `Open \$\{label\}'s active chat`/)
// The key rides as jsx()'s third argument — the ONLY form React treats as
// 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, /const activeSession = activeHumanSession\(bot\)/)
assert.match(source, /await openActiveBotSession\(bot\.name, activeSession\)/)
assert.match(source, /bot\.preferred_session \|\| bot\.last_session/)

const handlerStart = source.indexOf('onOpen: bot =>')
const handlerEnd = source.indexOf("children: 'Search bots…'", handlerStart)
const handler = source.slice(handlerStart, handlerEnd)
assert.ok(handler.indexOf('openActiveBotSession') < handler.indexOf('openBotCanonicalChat'))
})