From 21054b751ec89f95ce96606e46edfc3e22c8397a Mon Sep 17 00:00:00 2001 From: 686f6c61 Date: Wed, 26 Aug 2026 09:50:02 +0200 Subject: [PATCH] fix(desktop): hide unreachable same-name Bot Mode roster twins Group rooms persist source-qualified members. After Desktop switches to the built-in This-device source, a dead loopback row still listed next to the live profile and looked like a second agent. Collapse only the sidebar tiles; $lastRoster, group seats, and mentions keep every (connectionId, profile) identity. --- .../desktop/src/plugins/hermes-bots/plugin.js | 27 +++- .../tests/multi-source-roster.test.mjs | 152 +++++++++++++++++- .../hermes-bots/tests/roster-groups.test.mjs | 32 ++++ 3 files changed, 209 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index d8a72b07d8..2169facd6d 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -5242,6 +5242,31 @@ function botSourceStatus(bot) { return { available: true, key: 'unknown', label: 'Status unknown', tone: 'muted' } } + +/** Drop unreachable same-name copies from the top-level agent list. + * + * Group rooms persist source-qualified members. After Desktop moves to the + * built-in This-device source, the old loopback row (127.0.0.1:port, not + * listening) still sits next to the live profile and looks like a second + * agent (#92286). Routing identities stay intact: this only filters sidebar + * tiles. $lastRoster, group members, and @-mentions still see every + * (connectionId, profile) row. + * + * Ghosts stay: a selected-but-offline owner must remain visible rather than + * being replaced by a same-named twin on another gateway. A name with no + * reachable copy is kept, so a genuinely down source still has a row. */ +function preferReachableSameNameRows(bots) { + const rows = Array.isArray(bots) ? bots : [] + const reachableNames = new Set() + + for (const bot of rows) { + if (botSourceStatus(bot).available) { + reachableNames.add(bot?.name) + } + } + + return rows.filter(bot => botSourceStatus(bot).available || bot?.ghost || !reachableNames.has(bot?.name)) +} // ── cross-connection routing ───────────────────────────────────────────────── // A bot from another registered connection (remoteSource rows) is reached // through host.requestProfile with a route descriptor; local bots keep the @@ -14498,7 +14523,7 @@ function BotsPane() { const botRows = rowKindFilter === 'groups' ? [] - : filteredRoster.map(bot => ({ + : preferReachableSameNameRows(filteredRoster).map(bot => ({ kind: 'bot', bot, pinned: isPinned(bot), diff --git a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs index 3065aff6ea..0d34889773 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs @@ -32,7 +32,7 @@ function runtime() { .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') .replace('export default {', 'globalThis.plugin = {') .concat( - '\nglobalThis.__mergeMultiSourceRoster = mergeMultiSourceRoster;\nglobalThis.__botHandle = botHandle;\nglobalThis.__botRosterKey = botRosterKey;\nglobalThis.__botRosterMeta = botRosterMeta;\nglobalThis.__displayName = displayName;\nglobalThis.__filterBots = filterBots;\nglobalThis.__resolveRosterMentions = resolveRosterMentions;\nglobalThis.__botConnectionRoute = botConnectionRoute;\nglobalThis.__resolveBotConnectionRoute = resolveBotConnectionRoute;' + '\nglobalThis.__mergeMultiSourceRoster = mergeMultiSourceRoster;\nglobalThis.__botHandle = botHandle;\nglobalThis.__botRosterKey = botRosterKey;\nglobalThis.__botRosterMeta = botRosterMeta;\nglobalThis.__displayName = displayName;\nglobalThis.__filterBots = filterBots;\nglobalThis.__resolveRosterMentions = resolveRosterMentions;\nglobalThis.__botConnectionRoute = botConnectionRoute;\nglobalThis.__resolveBotConnectionRoute = resolveBotConnectionRoute;\nglobalThis.__preferReachableSameNameRows = preferReachableSameNameRows;' ) vm.runInNewContext(code, context) return context @@ -716,3 +716,153 @@ test('source contract: active roster queries use the SDK ambient owner route', ( 'roster hydration and the session sweep must both use the upstream ambient-owner route' ) }) + +test('preferReachableSameNameRows: drops a dead loopback twin when This device is live', () => { + const { __preferReachableSameNameRows: prefer } = runtime() + const live = { + name: 'profile-a', + handle: 'profile-a-this-device', + connectionId: 'local', + connectionKind: 'local', + connectionLabel: 'This device', + sourceReachable: true + } + const dead = { + name: 'profile-a', + handle: 'profile-a-127-0-0-1-19119', + connectionId: 'loopback-19119', + connectionKind: 'remote', + connectionLabel: '127.0.0.1:19119', + remoteSource: true, + sourceScoped: true, + sourceReachable: false + } + const other = { + name: 'profile-b', + handle: 'profile-b-127-0-0-1-19119', + connectionId: 'loopback-19119', + connectionKind: 'remote', + connectionLabel: '127.0.0.1:19119', + remoteSource: true, + sourceScoped: true, + sourceReachable: false + } + + const out = prefer([dead, live, other]) + + assert.equal(out.length, 2) + assert.equal(out[0], live) + assert.equal(out[1], other) +}) + +test('preferReachableSameNameRows: keeps two live sources with the same profile name', () => { + const { __preferReachableSameNameRows: prefer } = runtime() + const local = { + name: 'research', + connectionId: 'local', + connectionLabel: 'This device', + sourceReachable: true + } + const homelab = { + name: 'research', + connectionId: 'homelab', + connectionLabel: 'Homelab', + remoteSource: true, + sourceReachable: true + } + + const out = prefer([local, homelab]) + + assert.equal(out.length, 2) + assert.equal(out[0], local) + assert.equal(out[1], homelab) +}) + +test('preferReachableSameNameRows: keeps an unreachable row when no live twin exists', () => { + const { __preferReachableSameNameRows: prefer } = runtime() + const dead = { + name: 'research', + connectionId: 'loopback-19119', + sourceReachable: false + } + + const out = prefer([dead]) + + assert.equal(out.length, 1) + assert.equal(out[0], dead) +}) + +test('preferReachableSameNameRows: connect-on-demand counts as reachable', () => { + const { __preferReachableSameNameRows: prefer } = runtime() + const onDemand = { + name: 'research', + connectionId: 'mac-mini', + sourceError: 'connect-on-demand', + sourceReachable: false + } + const dead = { + name: 'research', + connectionId: 'loopback-19119', + sourceReachable: false + } + + const out = prefer([onDemand, dead]) + + assert.equal(out.length, 1) + assert.equal(out[0], onDemand) +}) + +test('preferReachableSameNameRows: sourceMissing drops when a live same-name row exists', () => { + const { __preferReachableSameNameRows: prefer } = runtime() + const live = { name: 'research', connectionId: 'local', sourceReachable: true } + const missing = { + name: 'research', + connectionId: 'gone', + sourceMissing: true, + sourceReachable: false + } + + const out = prefer([missing, live]) + + assert.equal(out.length, 1) + assert.equal(out[0], live) +}) + +test('preferReachableSameNameRows: ghosts stay so a selected offline owner is not replaced', () => { + const { __preferReachableSameNameRows: prefer } = runtime() + const live = { name: 'research', connectionId: 'local', sourceReachable: true } + const ghost = { + name: 'research', + connectionId: 'loopback-19119', + ghost: true, + sourceReachable: false + } + + const out = prefer([ghost, live]) + + assert.equal(out.length, 2) + assert.equal(out[0], ghost) + assert.equal(out[1], live) +}) + +test('preferReachableSameNameRows: does not mutate the input list', () => { + const { __preferReachableSameNameRows: prefer } = runtime() + const rows = [ + { name: 'research', connectionId: 'loopback-19119', sourceReachable: false }, + { name: 'research', connectionId: 'local', sourceReachable: true } + ] + + prefer(rows) + + assert.equal(rows.length, 2) + assert.equal(rows[0].connectionId, 'loopback-19119') +}) + +test('source contract: presentation collapses unreachable twins; shared roster does not', () => { + assert.match(source, /preferReachableSameNameRows\(filteredRoster\)/) + assert.match(source, /\$lastRoster\.set\(roster\.filter\(row => !row\?\.ghost\)\)/) + assert.doesNotMatch( + source.slice(source.indexOf('function groupChatMemberBots'), source.indexOf('function durableGroupChatMembers')), + /preferReachableSameNameRows/ + ) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs index 4a337ce5ee..27467a3f1c 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs @@ -125,6 +125,38 @@ test('groupChatMemberBots: seats local meta members plus stored remote descripto assert.equal(members[2], roster[2]) }) +test('groupChatMemberBots: persisted unreachable source members stay seated next to a live same-name twin', () => { + const { groupChatMemberBots, $groupChats } = load() + const live = { + name: 'profile-a', + handle: 'profile-a-this-device', + connectionId: 'local', + connectionKind: 'local', + connectionLabel: 'This device', + sourceReachable: true + } + const stored = { + name: 'profile-a', + handle: 'profile-a-127-0-0-1-19119', + connectionId: 'loopback-19119', + connectionKind: 'remote', + connectionLabel: '127.0.0.1:19119', + remoteSource: true, + sourceScoped: true, + sourceReachable: false + } + $groupChats.set({ Research: { log: [], members: [stored] } }) + + const members = groupChatMemberBots('Research', [live, stored], { + 'profile-a': { group: 'Research' } + }) + + assert.equal(members.length, 2) + assert.equal(members[0], live) + assert.equal(members[1], stored) + assert.equal(members[1].handle, 'profile-a-127-0-0-1-19119') +}) + test('groupChatMemberBots: stored descriptors beat presentation-only ghosts', () => { const { groupChatMemberBots, $groupChats } = load() const ghost = {