diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 3fe2c4eb7e..c5f783ddb3 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -4286,7 +4286,16 @@ async function openStoredBotChat(name, storedId, summary) { intent: 'main', awaitHydration: true, expectHistory, - keepAllProfilesScope: true, + // Move the WORKSPACE onto this bot, not just the transcript. + // + // With the default (true) the bot's chat opened against its own backend + // while `$activeGatewayProfile` stayed on whatever profile was active + // before — so "New session" from inside any bot was created on that other + // backend. Measured: four consecutive new chats started from different + // bots all landed in the `ops` profile's state.db. Clicking a bot is a + // workspace switch in this product (one bot = one workspace), so the + // chrome has to follow. + keepAllProfilesScope: false, retryHydrationTimeoutOnce: true }) @@ -4374,7 +4383,7 @@ function createCanonicalChat(name) { if (sid && typeof host.openSession === 'function') { try { - await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: true }) + await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: false }) opened = true } catch { // The stored row may not exist until the kickoff persists it. Retry @@ -4389,7 +4398,7 @@ function createCanonicalChat(name) { await host.request('prompt.submit', { session_id: runtime, text: 'Hey, tell me about yourself!' }) if (!opened && sid && typeof host.openSession === 'function') { - await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: true }) + await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: false }) } } catch { // The chat already exists. Keep the pin so the next click @@ -4425,7 +4434,37 @@ function isCanonicalBotChatHistory(history) { return rootTitle === 'Bot Chat' || (!rootTitle && title === 'Bot Chat') } -async function openBotCanonicalChat(name, pinned, history) { +/** The bot's newest VISIBLE conversation when it should win over the pin, else + * null. + * + * A bot row is a workspace entry point, so it must land on what the user was + * last saying to that bot — not on a pin frozen weeks ago. Guards, all of + * which matter: + * - the canonical Bot Chat itself is never "newer" (it IS the pin), so + * plumbing can't shadow itself; + * - an empty draft is skipped: clicking a bot right after a stray ⌘N would + * otherwise open a blank chat instead of the conversation; + * - identical ids mean the pin already points there — nothing to switch to. + * Returns the stored id so callers keep using the normal open path. */ +function newerVisibleBotChat(pinned, history) { + const id = history?.id + + if (!id || id === pinned || isCanonicalBotChatHistory(history)) { + return null + } + + // `message_count` is absent on older gateways — treat unknown as real + // history rather than discarding a legitimate conversation. + const count = history?.message_count + + if (typeof count === 'number' && count <= 0) { + return null + } + + return id +} + +async function openBotCanonicalChat(name, pinned, history, latestVisible) { if (!pinned) { // Grandfather only an actual Bot Chat. `last_session` is merely the most // recent row for the profile; adopting it blindly can claim an unrelated @@ -4467,6 +4506,38 @@ async function openBotCanonicalChat(name, pinned, history) { } if (preferred && isCanonicalBotChatHistory(preferred)) { + // The pin is alive and healthy — but it is not necessarily where the user + // left off. Prefer their MOST RECENT real conversation with this bot. + // + // "One bot = one forever chat" welded each row to a single session: start + // a new chat with a bot, click another bot, click back, and the new chat + // was stranded behind the pinned transcript ("세션을 다시 만들어도 다른 봇 + // 갔다가 다시 누르면 그 전 세션으로 돌아와"). A bot row is a workspace + // entry point here, so it should land on the live conversation. The pin + // keeps owning plumbing — creation, hide sweep, DM delivery — and stays + // untouched; it just stops overriding newer work. + // + // Deliberately AFTER the verification above: with a dead or unverified + // pin, adopting the profile's latest row would claim an unrelated user + // conversation as the bot's chat (see the "dead pin" safety tests). + // + // Uses `latestVisible` (the roster's freshest visible session), NOT + // `history` — the caller's `history` prefers the pin so preview identity + // matches click identity, which means it can never BE the newer chat. + // Falls back to `history` for callers that pass only three arguments. + const newer = newerVisibleBotChat(pinned, latestVisible ?? history) + + if (newer) { + try { + await openStoredBotChat(name, newer, history) + + return newer + } catch { + // Deleted or unreachable — fall back to the verified pin below so the + // row is never dead. + } + } + try { await openStoredBotChat(name, preferred.resolved_id || preferred.id, preferred) return pinned @@ -6359,7 +6430,13 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { } try { - const id = await openBotCanonicalChat(bot.name, pinnedChat, previewSession) + // `previewSession` prefers the PIN (preview identity must match click + // identity), so it can never carry the newer conversation. Pass the + // roster's freshest VISIBLE session (`last`) separately — that is what + // "open where I left off" needs. Without this the newer-chat preference + // was dead code: it always received the pin and short-circuited on + // "same id". + const id = await openBotCanonicalChat(bot.name, pinnedChat, previewSession, last) if (generation === botOpenGeneration && id) { return diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bot-row-opens-latest.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bot-row-opens-latest.test.mjs new file mode 100644 index 0000000000..84e03fe5f1 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/bot-row-opens-latest.test.mjs @@ -0,0 +1,227 @@ +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') + +/** + * A bot row must open the conversation the user was LAST having with that bot. + * + * Symptom (2026-08-21): every bot was welded to one session. Start a new chat + * with 기획총괄, click 시스템총괄, click back — and the new chat was gone, + * replaced by the pinned transcript. "세션을 다시 만들어도 다른 봇 갔다가 다시 + * 누르면 그 전 세션으로 다시 돌아와." + * + * The pin still owns plumbing (creation, hide sweep, DM delivery); it just + * must not override a newer real conversation. + */ +function loadOpenPath({ openSession, request }) { + const start = source.indexOf('const canonicalCreations = new Map()') + const end = source.indexOf('function displayName(', start) + + assert.notEqual(start, -1, 'canonical creation section is missing') + assert.notEqual(end, -1, 'canonical creation section delimiter is missing') + + const saved = [] + const opened = [] + const context = { + host: { + openSession: async (id, options) => { + opened.push({ id, options }) + + return openSession(id, options) + }, + request: async (method, params) => request(method, params) + }, + saveBotMeta: (name, patch) => saved.push({ name, patch: JSON.parse(JSON.stringify(patch)) }), + $hideBotChats: { get: () => false }, + window: { setTimeout: callback => callback() } + } + + const section = source + .slice(start, end) + .concat('\nglobalThis.__open = { openBotCanonicalChat, newerVisibleBotChat };\n') + + vm.runInNewContext(section, context, { filename: 'canonical-open.js' }) + + return { ...context.__open, saved, opened } +} + +const noRequests = async () => ({}) + +/** A live, healthy pin: `profiles.list` resolves it to the canonical Bot Chat. + * That verification is the gate the newer-conversation preference sits behind + * — with a dead or unverified pin the bot must NOT adopt the profile's latest + * row (that would claim an unrelated conversation). */ +const healthyPin = + (pinned = 'pinned-bot-chat') => + async (method, params) => { + if (method === 'profiles.list') { + const name = Object.keys(params?.preferred_session_ids ?? { ops: 1 })[0] + + return { + profiles: [{ name, preferred_session: { id: pinned, resolved_id: pinned, title: 'Bot Chat' } }] + } + } + + return {} + } + +test('bot row opens the NEWER real conversation instead of the pinned chat', async () => { + const runtime = loadOpenPath({ openSession: async () => undefined, request: healthyPin() }) + + // The roster's freshest visible session is a real conversation the user + // started after the pin was made. + const history = { id: 'new-chat', title: '릴시아 카피 회의', message_count: 12, last_active: 9000 } + + const result = await runtime.openBotCanonicalChat('plan', 'pinned-bot-chat', history, history) + + assert.equal(result, 'new-chat', 'should return the newer conversation') + assert.equal(runtime.opened.length, 1) + assert.equal(runtime.opened[0].id, 'new-chat', 'must not reopen the pinned transcript') + assert.equal(runtime.opened[0].options.profile, 'plan') + assert.equal( + runtime.opened[0].options.keepAllProfilesScope, + false, + 'clicking a bot moves the workspace onto that bot' + ) +}) + +/** + * The REAL call shape from the roster row — this is what the first fix got + * wrong. `previewSession` is `bot.preferred_session || last`, so on a pinned + * bot it resolves to the PIN (preview identity must match click identity). + * Feeding that as the "newer" candidate made the whole preference dead code: + * it always saw the pin and short-circuited on "same id", and the user still + * got the old session back ("다른 봇 눌렀다가 다시 그 봇 누르면 그 전 세션 열림"). + * The freshest visible session has to arrive as its own argument. + */ +test('real roster call: previewSession is the pin, latest arrives separately', async () => { + const runtime = loadOpenPath({ openSession: async () => undefined, request: healthyPin('pin-1') }) + + const pinnedPreview = { id: 'pin-1', title: 'Bot Chat', preview: 'plumbing' } + const last = { id: 'user-newest', title: '오늘 기획 회의', message_count: 8, last_active: 9999 } + + // Mirrors: openBotCanonicalChat(bot.name, pinnedChat, previewSession, last) + const result = await runtime.openBotCanonicalChat('plan', 'pin-1', pinnedPreview, last) + + assert.equal(result, 'user-newest', 'must open the newest real conversation, not the pin') + assert.equal(runtime.opened[0].id, 'user-newest') +}) + +test('the canonical Bot Chat itself never counts as "newer" (it IS the pin)', () => { + const runtime = loadOpenPath({ openSession: async () => undefined, request: noRequests }) + + assert.equal(runtime.newerVisibleBotChat('pin-1', { id: 'hidden-plumbing', title: 'Bot Chat' }), null) + assert.equal( + runtime.newerVisibleBotChat('pin-1', { id: 'hidden-plumbing', root_title: 'Bot Chat', title: '자동 제목' }), + null + ) +}) + +test('an empty draft never displaces the pinned conversation', () => { + const runtime = loadOpenPath({ openSession: async () => undefined, request: noRequests }) + + assert.equal(runtime.newerVisibleBotChat('pin-1', { id: 'blank', title: '', message_count: 0 }), null) +}) + +test('a gateway that omits message_count still yields the newer session', () => { + const runtime = loadOpenPath({ openSession: async () => undefined, request: noRequests }) + + assert.equal(runtime.newerVisibleBotChat('pin-1', { id: 'legacy', title: '대화' }), 'legacy') +}) + +test('history that IS the pin changes nothing', () => { + const runtime = loadOpenPath({ openSession: async () => undefined, request: noRequests }) + + assert.equal(runtime.newerVisibleBotChat('same-id', { id: 'same-id', title: '대화', message_count: 5 }), null) +}) + +/** + * Every path that mounts a bot's chat must move the workspace onto that bot. + * + * `keepAllProfilesScope` defaults to TRUE in the SDK, which keeps + * `$activeGatewayProfile` pointing at whatever profile was active before the + * click. Bot Mode wants the opposite: clicking a bot IS a profile switch, and + * leaving the scope behind meant sessions created afterwards were filed under + * the previous bot's profile (measured: four new chats started from three + * different bots all landed in `ops`). + * + * The newly-minted-chat path is asserted separately from the stored-chat path + * because they are different call sites; a guard on only one of them let the + * other regress silently. + */ +function creationRuntime({ failFirstOpen = false } = {}) { + let opens = 0 + + return loadOpenPath({ + openSession: async () => { + opens += 1 + + if (failFirstOpen && opens === 1) { + throw new Error('stored row not persisted yet') + } + + return undefined + }, + request: async method => { + if (method === 'session.create') { + return { stored_session_id: 'fresh-stored', session_id: 'fresh-runtime' } + } + + return {} + } + }) +} + +test('a newly minted Bot Chat opens with the workspace following the bot', async () => { + const runtime = creationRuntime() + + // No pin and no adoptable history — the real "first click on a bot" path. + const result = await runtime.openBotCanonicalChat('plan', null, null, null) + + assert.equal(result, 'fresh-stored') + assert.ok(runtime.opened.length >= 1, 'the new chat is mounted') + + for (const entry of runtime.opened) { + assert.equal(entry.options.keepAllProfilesScope, false, 'creating a bot chat must move the workspace onto that bot') + assert.equal(entry.options.profile, 'plan') + } +}) + +test('the post-kickoff retry open also follows the bot', async () => { + const runtime = creationRuntime({ failFirstOpen: true }) + + await runtime.openBotCanonicalChat('plan', null, null, null) + + assert.equal(runtime.opened.length, 2, 'first open fails, retry runs after the kickoff') + assert.equal( + runtime.opened[1].options.keepAllProfilesScope, + false, + 'the retry must not silently fall back to the SDK default' + ) +}) + +test('a failed open of the newer session falls back to the pin (row never dies)', async () => { + const runtime = loadOpenPath({ + openSession: async id => { + if (id === 'deleted-chat') { + throw new Error('session not found') + } + + return undefined + }, + request: healthyPin('pin-1') + }) + + const history = { id: 'deleted-chat', title: '지워진 대화', message_count: 3 } + + const result = await runtime.openBotCanonicalChat('ops', 'pin-1', history) + + const ids = runtime.opened.map(entry => entry.id) + + assert.ok(ids.includes('deleted-chat'), 'tries the newer session first') + assert.ok(ids.includes('pin-1'), 'falls back to the verified pin') + assert.equal(result, 'pin-1', 'row resolves to the pin rather than failing') +}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs index c09b7dafc2..54a9056f4d 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs @@ -129,7 +129,12 @@ test('pin: preferred_session present opens the resolved session and keeps the pi intent: 'main', awaitHydration: true, expectHistory: true, - keepAllProfilesScope: true, + // false: clicking a bot moves the WORKSPACE onto that bot, not just the + // transcript. With true, `$activeGatewayProfile` stayed on the previously + // active profile, so "New session" from inside any bot was created on + // that other backend (measured: four new chats from different bots all + // landed in `ops`). + keepAllProfilesScope: false, retryHydrationTimeoutOnce: true } }])