From 7253a8b396c71cff4c48613fdaeb624950ba01e5 Mon Sep 17 00:00:00 2001 From: jiso <308973923+okazakijiso-creator@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:10:30 +0900 Subject: [PATCH] fix(desktop): open active bot sessions from Active now --- .../desktop/src/plugins/hermes-bots/plugin.js | 52 ++++++++++- .../tests/active-now-session.test.mjs | 90 +++++++++++++++++++ .../tests/active-now-strip.test.mjs | 36 +++++++- 3 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/active-now-session.test.mjs diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index a2a9bb4aedee..baa6f214bc62 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -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 @@ -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 @@ -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) }) @@ -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) @@ -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' @@ -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, diff --git a/apps/desktop/src/plugins/hermes-bots/tests/active-now-session.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/active-now-session.test.mjs new file mode 100644 index 000000000000..fdd75434f11a --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/active-now-session.test.mjs @@ -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'] + ) +}) 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 057b61e46bcc..d01056744822 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 @@ -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 ) @@ -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 @@ -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 = [ @@ -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')) })