feat(desktop): organize the global bot roster

This commit is contained in:
David Dudok de Wit
2026-08-20 20:14:00 +02:00
committed by Teknium
parent b766607b5b
commit 613244cbb1
10 changed files with 2812 additions and 509 deletions
File diff suppressed because it is too large Load Diff
@@ -104,10 +104,10 @@ test('botActivitySession keeps last_session when it is the fresher one', () => {
assert.equal(botActivitySession(bot).id, 'scratch')
})
test('botActivitySession degrades to whichever side exists (older gateways / no pin)', () => {
test('botActivitySession degrades to whichever session summary exists on an older gateway', () => {
const botActivitySession = loadBotActivitySession()
assert.equal(botActivitySession({ last_session: { id: 'only', last_active: 1 } }).id, 'only')
assert.equal(botActivitySession({ canonical_session: { id: 'pin', last_active: 1 } }).id, 'pin')
assert.equal(botActivitySession({ canonical_session: { id: 'canonical', last_active: 1 } }).id, 'canonical')
assert.equal(botActivitySession({}), null)
assert.equal(botActivitySession(null), null)
})
@@ -164,7 +164,7 @@ test('activeBots ignores a finished worker outside the liveness window', () => {
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'")
const searchField = source.indexOf("placeholder: 'Search bots…'")
const searchField = source.indexOf("placeholder: 'Search bots and group chats…'")
assert.ok(headerEnd >= 0 && searchField > headerEnd)
const stripStart = source.indexOf('jsx(ActiveNowStrip')
@@ -172,13 +172,22 @@ 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`/)
// 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)
// Chips use the shared Tip component, remain keyboard/click accessible,
// and open the canonical chat via the same path as roster rows.
assert.match(source, /label: `Open \$\{label\}'s chat`/)
assert.match(source, /'aria-label': `Open \$\{label\}'s chat`/)
// The key rides as jsx()'s third argument so React keeps chip identity.
assert.match(source, /botRosterKey\(bot\)\s*\)\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, /await prepareBotSource\(bot\)/)
assert.match(source, /bot\.canonical_session \|\| last/)
// Chips share the exact-owner route with roster rows. That route activates the
// owner source, then resolves the profile's canonical name registry; no
// renderer pointer or preview-derived session id participates.
assert.match(source, /onOpen: bot => void openRosterBot\(bot\)/)
const openStart = source.indexOf('async function openRosterBot(')
assert.ok(openStart >= 0)
const open = source.slice(openStart, openStart + 3200)
assert.match(open, /await prepareBotSource\(bot\)/)
assert.match(open, /await openBotCanonicalChat\(bot\.name\)/)
})
@@ -204,13 +204,13 @@ test('unit: older desktop without host.deleteProfile falls back to the non-inter
test('integration: a deleted bot is removed from plugin-local state and the roster is refreshed', async () => {
const { context, invalidations, stored } = load()
context.__delete.$botMeta.set({ researcher: { title: 'Research' }, writer: { title: 'Writer' } })
context.__delete.$botUnread.set({ researcher: true, writer: true })
context.__delete.$botUnread.set({ 'legacy::researcher': true, 'legacy::writer': true })
context.__delete.$selectedBot.set('researcher')
await context.__delete.deleteBot({ name: 'researcher' })
assert.equal(context.__delete.$botMeta.get().researcher, undefined)
assert.equal(context.__delete.$botUnread.get().researcher, undefined)
assert.equal(context.__delete.$botUnread.get()['legacy::researcher'], undefined)
assert.equal(context.__delete.$selectedBot.get(), 'default')
assert.equal(stored.at(-1).key, 'bot-meta')
assert.equal(stored.at(-1).value.researcher, undefined)
@@ -0,0 +1,936 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import vm from 'node:vm'
// Bot Mode's main workspace always has ONE clear owner: a bot chat, a group
// chat, or the Bots home. The home exists so the Bots tab never falls through
// to the ownerless Sessions composer.
//
// The invariant under test is #90149's: an existing resource carries its exact
// owner. Selecting or RESTORING a bot is presentation only — it must never
// activate a gateway, open a chat, create a session, or route a remote bot
// through whatever connection happens to be live.
const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
function load({ focusedStoredSessionId = null, paneVisibility = true, openWorkspace = true } = {}) {
const values = new Map()
const atom = initial => {
const slot = {
get: () => values.get(slot),
set: value => {
values.set(slot, value)
for (const fn of slot.__listeners || []) {
fn(value)
}
},
listen: fn => {
slot.__listeners = [...(slot.__listeners || []), fn]
return () => {
slot.__listeners = (slot.__listeners || []).filter(entry => entry !== fn)
}
}
}
values.set(slot, initial)
return slot
}
const opened = []
const closed = []
const notifications = []
const requests = []
const invalidations = []
const paneVisible = new Map()
const focused = atom(focusedStoredSessionId)
const host = {
state: {
profile: { get: () => 'default', listen: () => undefined },
gateway: { get: () => 'open', listen: () => undefined },
focusedStoredSessionId: focused
},
request: (method, params) => {
requests.push({ method, params })
return Promise.resolve({})
},
notify: params => notifications.push(params),
notifyError: (error, fallback) => notifications.push({ kind: 'error', message: fallback, error }),
ensureAgent: async () => undefined,
activeConnectionId: () => 'local'
}
if (openWorkspace) {
host.openWorkspace = (id, options) => {
const entry = { id, options, disposed: false }
opened.push(entry)
paneVisible.set(`plugin-workspace:${id}`, true)
return () => {
entry.disposed = true
closed.push(entry)
paneVisible.set(`plugin-workspace:${id}`, false)
options.onClose?.()
}
}
}
if (paneVisibility) {
host.paneVisibility = id => ({
get: () => paneVisible.get(id) ?? false,
listen: () => () => undefined
})
}
const context = {
atom,
haptic: () => undefined,
PALETTE_AREA: 'palette',
COMPOSER_AREAS: { middleware: 'middleware' },
document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } },
host,
queryClient: { invalidateQueries: params => invalidations.push(params) },
navigator: { clipboard: { writeText: async () => undefined } },
sdk: new Proxy({}, { get: () => undefined })
}
const source = pluginSource
.replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '')
.replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '')
.replace(/^const \{ McpTab, ToolsetConfigPanel \} = sdk\r?\n/m, '')
.replace(/^import .* from 'react'\r?\n/m, '')
.replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '')
.replace('export default {', 'globalThis.plugin = {')
.concat(`
globalThis.__home = {
botRosterKey,
parseRosterKey,
ghostRosterOwner,
rosterWithSelectedOwner,
reconcileRosterSelection,
saveRosterPreference,
saveSelectedRosterBot,
clearSelectedRosterBot,
clearSelectedRosterKey,
releaseStaleOpenBotChat,
syncBotsHomeWorkspace,
openBotsHomeWorkspace,
closeBotsHomeWorkspace,
botsHomeMayOpen,
botsHomeVisible,
botChatOwnsWorkspace,
sessionOwnsWorkspace,
openRosterBot,
openGroupChat,
closeGroupChatMainTab,
prepareBotSource,
$botMeta,
$botsPaneVisible,
$botChatFocused,
$botsHomeFronted,
$groupChatWorkspace,
$lastRoster,
$lastSources,
$openBotChat,
$rosterHydrated,
$selectedBot,
$selectedRosterHydrated,
$selectedRosterKey,
setPluginCtx: value => { pluginCtx = value }
};
`)
vm.runInNewContext(source, context, { filename: 'plugin.js' })
return { ...context.__home, closed, focused, host, invalidations, notifications, opened, paneVisible, requests }
}
/** Every door that would create, activate, or route something. A passive
* selection must touch none of them. */
function assertNothingRouted(t, label) {
assert.deepEqual(t.requests, [], `${label}: no gateway RPC`)
assert.deepEqual(t.opened.filter(entry => entry.id !== 'hermes-bots:home'), [], `${label}: no chat surface opened`)
}
// ── selection is source-qualified and presentation-only ─────────────────────
test('selection persists the source-qualified key, not the bare profile name', () => {
const t = load()
const writes = []
t.setPluginCtx({ storage: { set: (key, value) => writes.push({ key, value }) } })
t.saveSelectedRosterBot({ connectionId: 'work-vps', name: 'researcher', remoteSource: true })
assert.equal(t.$selectedRosterKey.get(), 'work-vps::researcher')
assert.deepEqual(writes.at(-1), { key: 'selected-roster-bot-v1', value: 'work-vps::researcher' })
assertNothingRouted(t, 'saving a selection')
})
test('a remote selection never redirects the bare-name consumers at a local twin', () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
// Same profile name on two gateways — the exact-owner class from #90149.
t.saveSelectedRosterBot({ connectionId: 'local', name: 'default' })
assert.equal(t.$selectedBot.get(), 'default')
t.saveSelectedRosterBot({ connectionId: 'mac-mini', name: 'default', remoteSource: true })
assert.equal(t.$selectedRosterKey.get(), 'mac-mini::default')
assert.equal(
t.$selectedBot.get(),
'default',
'the Cronjobs/slash-guard name tracker keeps its LOCAL owner — a remote row must not claim it'
)
})
test('clearing only fires for the exact selected owner', () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.saveSelectedRosterBot({ connectionId: 'local', name: 'writer' })
t.clearSelectedRosterBot({ connectionId: 'mac-mini', name: 'writer' })
assert.equal(t.$selectedRosterKey.get(), 'local::writer', 'a same-named bot elsewhere must not clear this one')
t.clearSelectedRosterBot({ connectionId: 'local', name: 'writer' })
assert.equal(t.$selectedRosterKey.get(), '')
})
test('roster keys round-trip through parseRosterKey', () => {
const t = load()
const parsed = key => ({ ...t.parseRosterKey(key) })
assert.deepEqual(parsed('work-vps::researcher'), { connectionId: 'work-vps', name: 'researcher' })
assert.deepEqual(parsed('local::default'), { connectionId: 'local', name: 'default' })
assert.deepEqual(parsed(''), { connectionId: '', name: '' })
// A key always round-trips from the identity that produced it.
const bot = { connectionId: 'work-vps', name: 'researcher' }
assert.deepEqual(parsed(t.botRosterKey(bot)), { connectionId: 'work-vps', name: 'researcher' })
})
// ── hydration ───────────────────────────────────────────────────────────────
test('hydration restores the stored key and flips the hydrated flag', async () => {
const t = load()
t.$selectedRosterHydrated.set(false)
const ctx = {
storage: { get: key => (key === 'selected-roster-bot-v1' ? 'work-vps::researcher' : undefined), set: () => undefined },
register: () => () => undefined,
onDispose: () => undefined
}
// Only the selection hydrate is under test; run it the way register() does.
await Promise.resolve(ctx.storage.get('selected-roster-bot-v1')).then(value => {
if (typeof value === 'string' && value.trim()) {
t.$selectedRosterKey.set(value.trim())
}
})
t.$selectedRosterHydrated.set(true)
assert.equal(t.$selectedRosterKey.get(), 'work-vps::researcher')
assert.equal(t.$selectedRosterHydrated.get(), true)
assertNothingRouted(t, 'restoring a selection')
})
test('source contract: every hydrate settle path flips the flag, and none of them opens anything', () => {
assert.match(
pluginSource,
/Promise\.resolve\(ctx\.storage\?\.get\?\.\('selected-roster-bot-v1'\)\)[\s\S]{0,400}?\.finally\(\(\) => \$selectedRosterHydrated\.set\(true\)\)/,
'a storage quirk must not strand the home in its loading state'
)
assert.match(pluginSource, /\} catch \{\s*\n\s*\/\* no storage[^\n]*\n\s*\$selectedRosterHydrated\.set\(true\)/)
})
// ── first selection + reconciliation ────────────────────────────────────────
test('the first selection picks a reachable visible bot and creates nothing', () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$rosterHydrated.set(true)
t.$selectedRosterHydrated.set(true)
const sources = [{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }]
const roster = [{ connectionId: 'local', name: 'writer' }, { connectionId: 'local', name: 'coder' }]
t.reconcileRosterSelection(roster, sources, {})
assert.equal(t.$selectedRosterKey.get(), 'local::writer')
assert.equal(t.$openBotChat.get(), null, 'selection is not an open')
assertNothingRouted(t, 'seating the first selection')
})
test('an unreachable bot is never auto-selected', () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$rosterHydrated.set(true)
t.$selectedRosterHydrated.set(true)
const sources = [
{ connectionId: 'work-vps', kind: 'remote', label: 'Work', reachable: false },
{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }
]
const roster = [
{ connectionId: 'work-vps', name: 'researcher', remoteSource: true, sourceScoped: true },
{ connectionId: 'local', name: 'writer' }
]
t.reconcileRosterSelection(roster, sources, {})
assert.equal(t.$selectedRosterKey.get(), 'local::writer')
})
test('nothing is selected before the roster has answered', () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$rosterHydrated.set(false)
t.$selectedRosterHydrated.set(true)
t.reconcileRosterSelection([{ connectionId: 'local', name: 'writer' }], [], {})
assert.equal(t.$selectedRosterKey.get(), '', 'a pending roster must not seat a selection it may have to revoke')
})
test('a hidden bot is not auto-selected', () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$rosterHydrated.set(true)
t.$selectedRosterHydrated.set(true)
const sources = [{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }]
const roster = [{ connectionId: 'local', name: 'hidden-one' }, { connectionId: 'local', name: 'writer' }]
// Hidden is a source-qualified Desktop preference, so hide the exact row.
t.saveRosterPreference(roster[0], 'hidden', true)
t.reconcileRosterSelection(roster, sources, {})
assert.equal(
t.$selectedRosterKey.get(),
'local::writer',
'the home must not open onto a bot the user removed from the roster'
)
})
// ── offline owner survives; deleted owner does not ──────────────────────────
test('an offline gateway keeps the selection and its identity (relaunch case)', () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$rosterHydrated.set(true)
t.$selectedRosterHydrated.set(true)
t.$selectedRosterKey.set('work-vps::researcher')
// The gateway is registered but down, so it contributes no roster rows.
const sources = [
{ connectionId: 'work-vps', kind: 'remote', label: 'Work', reachable: false, error: 'ECONNREFUSED' },
{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }
]
const roster = [{ connectionId: 'local', name: 'writer' }]
t.reconcileRosterSelection(roster, sources, {})
assert.equal(
t.$selectedRosterKey.get(),
'work-vps::researcher',
'an unreachable owner is cached, not replaced — falling back would re-own the bot on another gateway'
)
const ghost = t.ghostRosterOwner('work-vps::researcher', sources)
assert.equal(ghost.name, 'researcher')
assert.equal(ghost.connectionId, 'work-vps')
assert.equal(ghost.connectionLabel, 'Work')
assert.equal(ghost.remoteSource, true)
assert.equal(ghost.sourceReachable, false)
assertNothingRouted(t, 'rendering an offline owner')
})
test('cold-start outage keeps the selected owner in the visible roster without caching every bot', () => {
const t = load()
const sources = [
{ connectionId: 'work-vps', kind: 'remote', label: 'Work', reachable: false, error: 'ECONNREFUSED' },
{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }
]
const localOnly = [{ connectionId: 'local', name: 'writer' }]
const restored = t.rosterWithSelectedOwner(localOnly, sources, 'work-vps::researcher')
assert.equal(restored.length, 2, 'only the exact selected owner is restored, not an invented remote roster')
assert.equal(t.botRosterKey(restored[1]), 'work-vps::researcher')
assert.equal(restored[1].connectionLabel, 'Work')
assert.equal(restored[1].sourceReachable, false)
assert.equal(t.rosterWithSelectedOwner(restored, sources, 'work-vps::researcher').length, 2, 'never duplicated')
})
test('a reachable source never receives a ghost row for a deleted bot', () => {
const t = load()
const roster = [{ connectionId: 'local', name: 'writer' }]
const sources = [
{ connectionId: 'work-vps', kind: 'remote', label: 'Work', reachable: true },
{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }
]
assert.equal(t.rosterWithSelectedOwner(roster, sources, 'work-vps::deleted').length, 1)
})
test('a reachable source that no longer lists the bot clears the selection', () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$rosterHydrated.set(true)
t.$selectedRosterHydrated.set(true)
t.$selectedRosterKey.set('local::deleted-bot')
const sources = [{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }]
const roster = [{ connectionId: 'local', name: 'writer' }]
assert.equal(t.ghostRosterOwner('local::deleted-bot', sources), null, 'a live source answering without it is proof')
t.reconcileRosterSelection(roster, sources, {})
assert.equal(t.$selectedRosterKey.get(), 'local::writer', 'the invalid selection is replaced, not kept')
})
test('an unknown source list is not proof of deletion', () => {
const t = load()
// Sources have not hydrated yet — the owner must keep its identity.
assert.ok(t.ghostRosterOwner('work-vps::researcher', []))
assert.equal(t.ghostRosterOwner('work-vps::researcher', [{ connectionId: 'local', reachable: true }]), null)
})
// ── explicit open: local routes, remote never does ──────────────────────────
test('opening a remote bot selects it and routes nothing', async () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$botsPaneVisible.set(true)
const bot = {
connectionId: 'work-vps',
connectionLabel: 'Work',
name: 'researcher',
remoteSource: true,
sourceScoped: true
}
const result = await t.openRosterBot(bot)
assert.equal(result, false, 'a remote row does not open a chat')
assert.equal(t.$selectedRosterKey.get(), 'work-vps::researcher')
assert.equal(t.$openBotChat.get(), null)
assertNothingRouted(t, 'clicking a remote row')
})
test('a remote owner fronts the Bots home without closing the group tab', async () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$botsPaneVisible.set(true)
t.openGroupChat('Launch room')
const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room')
const result = await t.openRosterBot({
connectionId: 'work-vps',
connectionLabel: 'Work',
name: 'researcher',
remoteSource: true
})
assert.equal(result, false)
assert.equal(t.botsHomeVisible(), true)
assert.equal(t.$groupChatWorkspace.get(), null)
assert.equal(groupEntry.disposed, false, 'explicit selection must not close an unrelated group tab')
})
test('a remote owner preserves a group when the Bots home cannot open', async () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$botsPaneVisible.set(true)
t.openGroupChat('Launch room')
const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room')
t.host.openWorkspace = () => {
throw new Error('workspace unavailable')
}
const result = await t.openRosterBot({
connectionId: 'work-vps',
connectionLabel: 'Work',
name: 'researcher',
remoteSource: true
})
assert.equal(result, false)
assert.equal(t.$groupChatWorkspace.get(), 'Launch room')
assert.equal(groupEntry.disposed, false)
})
test('a failed local open leaves no phantom owner in the center', async () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$botsPaneVisible.set(true)
t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'previous' })
// A source-scoped row on a desktop that cannot activate it: prepareBotSource
// refuses rather than letting the open fall through to the live gateway.
delete t.host.ensureAgent
const bot = { connectionId: 'work-vps', name: 'writer', sourceScoped: true }
const result = await t.openRosterBot(bot)
assert.equal(result, false)
assert.equal(t.$openBotChat.get(), null, 'a failed open must release the center back to the home')
assert.equal(t.notifications.at(-1).kind, 'error', 'and the failure is surfaced, not swallowed')
assertNothingRouted(t, 'a refused local open')
})
test('a missing profile-scoped draft API returns to the home without navigating', async () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$botsPaneVisible.set(true)
t.host.request = async method => {
if (method === 'session.list') return { sessions: [] }
if (method === 'session.create') return {}
return {}
}
const result = await t.openRosterBot({ connectionId: 'local', name: 'writer' })
assert.equal(result, false)
assert.equal(t.$openBotChat.get(), null, 'no draft was opened without the owner-scoped API')
assert.ok(t.botsHomeVisible(), 'the owner home remains the visible recovery surface')
})
test('a bot chat opens from its canonical name-registry row without closing the prior group tab', async () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$botsPaneVisible.set(true)
t.host.openSession = async () => undefined
t.host.request = async method => {
if (method === 'session.list') {
return { sessions: [{ id: 'bot-chat', title: 'Bot Chat', message_count: 4 }] }
}
return {}
}
t.openGroupChat('Launch room')
const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room')
assert.ok(groupEntry)
assert.equal(t.$groupChatWorkspace.get(), 'Launch room')
const result = await t.openRosterBot({ connectionId: 'local', name: 'writer' })
assert.equal(result, true)
assert.equal(t.$groupChatWorkspace.get(), null)
assert.equal(groupEntry.disposed, false, 'opening a canonical chat must not close an unrelated group tab')
assert.equal(t.$openBotChat.get()?.key, 'local::writer')
assert.equal(t.$openBotChat.get()?.openedRegistryId, 'bot-chat')
})
test('a failed canonical-chat open preserves the visible group owner', async () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$botsPaneVisible.set(true)
t.host.openSession = async () => {
throw new Error('gateway unavailable')
}
t.host.request = async method => {
if (method === 'session.list') {
return { sessions: [{ id: 'bot-chat', title: 'Bot Chat', message_count: 4 }] }
}
return {}
}
t.openGroupChat('Launch room')
const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room')
const result = await t.openRosterBot({ connectionId: 'local', name: 'writer' })
assert.equal(result, false)
assert.equal(t.$groupChatWorkspace.get(), 'Launch room')
assert.equal(groupEntry.disposed, false, 'a failed transition cannot retire the surface still on screen')
assert.equal(t.$openBotChat.get(), null)
assert.equal(t.notifications.at(-1).kind, 'error')
})
test('choosing a group prevents a stale canonical-chat open from closing it later', async () => {
const t = load()
t.setPluginCtx({ storage: { set: () => undefined } })
t.$botsPaneVisible.set(true)
let finishOpen
let markOpenStarted
const openStarted = new Promise(resolve => {
markOpenStarted = resolve
})
t.host.openSession = () =>
new Promise(resolve => {
finishOpen = resolve
markOpenStarted()
})
t.host.request = async method => {
if (method === 'session.list') {
return { sessions: [{ id: 'bot-chat', title: 'Bot Chat', message_count: 4 }] }
}
return {}
}
const opening = t.openRosterBot({ connectionId: 'local', name: 'writer' })
await openStarted
t.openGroupChat('Launch room')
const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room')
finishOpen()
assert.equal(await opening, false)
assert.equal(t.$groupChatWorkspace.get(), 'Launch room')
assert.equal(groupEntry.disposed, false)
assert.equal(t.$openBotChat.get(), null)
})
// ── who owns the main workspace ─────────────────────────────────────────────
test('ownership table: exactly one surface owns the center', () => {
const t = load()
// Bot Mode not on screen: neither surface exists.
t.$botsPaneVisible.set(false)
assert.equal(t.botsHomeMayOpen(false), false)
assert.equal(t.botChatOwnsWorkspace(), false)
// Bots visible, nothing else: the home owns it, Cronjobs stay away.
t.$botsPaneVisible.set(true)
assert.equal(t.botsHomeMayOpen(false), true)
assert.equal(t.botChatOwnsWorkspace(), false, 'no bot chat owns the center, so bot-scoped Cronjobs must not seat')
// A group chat owns it: neither the home nor Cronjobs.
t.$groupChatWorkspace.set('Core')
assert.equal(t.botsHomeMayOpen(false), false)
assert.equal(t.botsHomeMayOpen(true), false, 'even an explicit gesture cannot cover a group chat')
assert.equal(t.botChatOwnsWorkspace(), false)
t.$groupChatWorkspace.set(null)
// A bot chat owns it: the home yields and Cronjobs seat.
t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'chat-1' })
assert.equal(t.botsHomeMayOpen(false), false)
assert.equal(t.botChatOwnsWorkspace(), true)
t.$openBotChat.set(null)
// A focused session (restored at boot, or Sessions mode) vetoes PASSIVE
// opens but not an explicit gesture at the home.
t.focused.set('chat-9')
assert.equal(t.sessionOwnsWorkspace(), true)
assert.equal(t.botsHomeMayOpen(false), false)
assert.equal(t.botsHomeMayOpen(true), true)
assert.equal(t.botChatOwnsWorkspace(), true)
})
test('with the home tab fronted, the hidden chat does not seat Cronjobs', () => {
const t = load()
t.$botsPaneVisible.set(true)
// Explicitly front the home over a focused chat.
t.focused.set('chat-1')
t.openBotsHomeWorkspace(true)
assert.equal(t.botsHomeVisible(), true)
assert.equal(
t.botChatOwnsWorkspace(),
false,
'the chat is a hidden sibling layer while the home holds the tab slot'
)
// The user fronts the chat tab again (no focus change fires): the pane
// visibility flip alone must reseat Cronjobs.
t.closeBotsHomeWorkspace()
assert.equal(t.botsHomeVisible(), false)
assert.equal(t.botChatOwnsWorkspace(), true)
})
test('a persisted layout that restored the home behind the draft gets re-fronted', () => {
const t = load()
t.$botsPaneVisible.set(true)
t.syncBotsHomeWorkspace()
assert.equal(t.opened.length, 1)
// The tree restored the tab BEHIND the core draft pane (adoption kept the
// persisted active slot): the ownerless composer would sit on top.
t.paneVisible.set('plugin-workspace:hermes-bots:home', false)
t.syncBotsHomeWorkspace()
assert.equal(t.opened.length, 2, 're-opened to reclaim the active slot')
assert.equal(t.closed.length, 1, 'the stale registration was closed first — never two live disposers')
assert.equal(t.botsHomeVisible(), true)
})
test('an explicit remote selection fronts the home over a focused chat', async () => {
const t = load({ focusedStoredSessionId: 'local-scout-chat' })
t.setPluginCtx({ storage: { set: () => undefined } })
t.$botsPaneVisible.set(true)
// Passive sync must not cover the chat…
t.syncBotsHomeWorkspace()
assert.deepEqual(t.opened, [])
// …but clicking the same-named twin on another gateway is a gesture at
// that owner: the home fronts, the chat stays alive underneath.
await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'scout', remoteSource: true })
assert.equal(t.opened.length, 1)
assert.equal(t.$selectedRosterKey.get(), 'work-vps::scout')
assert.equal(t.$openBotChat.get(), null)
assertNothingRouted(t, 'explicit remote selection')
// Browsing more remote owners reuses the fronted home instead of
// re-registering it (a stale disposer would tear down the newer one).
await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'relay', remoteSource: true })
assert.equal(t.opened.length, 1)
assert.equal(t.$selectedRosterKey.get(), 'work-vps::relay')
})
test('an explicit Bots-home gesture fronts the selected owner over a Sessions composer', () => {
const t = load({ focusedStoredSessionId: 'sessions-chat' })
t.$botsPaneVisible.set(true)
t.syncBotsHomeWorkspace()
assert.deepEqual(t.opened, [], 'passive polling still leaves a focused session alone')
t.openBotsHomeWorkspace(true)
assert.equal(t.opened.length, 1, 'the explicit gesture has an exact owner instead of the Sessions composer')
assert.equal(t.opened[0].id, 'hermes-bots:home')
})
test('source contract: sidebar entry and boot restore reconcile passively after layout hydration', () => {
assert.match(pluginSource, /const syncWorkspaceSurfaces = \(\) =>/)
assert.match(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,450}?syncWorkspaceSurfaces\(\)/)
assert.doesNotMatch(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,450}?syncWorkspaceSurfaces\(Boolean\(visible\)\)/)
assert.match(
pluginSource,
/\$botChatFocused\.set\(sessionOwnsWorkspace\(\)\)[\s\S]{0,500}?syncWorkspaceSurfaces\(\)[\s\S]{0,120}?scheduleSurfaceSync\(\)/
)
assert.match(
pluginSource,
/homeVisibleStore\.listen\(visible => \{[\s\S]{0,260}?\$botsHomeFronted\.set\(Boolean\(visible\)\)[\s\S]{0,120}?scheduleSurfaceSync\(\)/
)
})
test('an opened chat releases the center once focus leaves it', () => {
const t = load()
t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'chat-1' })
t.releaseStaleOpenBotChat('chat-1')
assert.deepEqual(t.$openBotChat.get(), { key: 'local::writer', openedRegistryId: 'chat-1' }, 'still the focused chat')
t.releaseStaleOpenBotChat('chat-2')
assert.equal(t.$openBotChat.get(), null, 'another session took the center')
t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'chat-1' })
t.releaseStaleOpenBotChat(null)
assert.equal(t.$openBotChat.get(), null, 'the chat was closed — the home may come back')
})
test('a legacy draft keeps the center until a real session takes focus', () => {
const t = load()
// The newChat fallback has no stored id to compare against.
t.$openBotChat.set({ key: 'local::writer', openedRegistryId: '' })
t.releaseStaleOpenBotChat(null)
assert.deepEqual(t.$openBotChat.get(), { key: 'local::writer', openedRegistryId: '' }, 'an unsent draft is still that bot’s')
t.releaseStaleOpenBotChat('chat-7')
assert.equal(t.$openBotChat.get(), null)
})
// ── the home tab itself ─────────────────────────────────────────────────────
test('the home opens once and is not re-fronted while it already owns the center', () => {
const t = load()
t.$botsPaneVisible.set(true)
t.syncBotsHomeWorkspace()
assert.equal(t.opened.length, 1)
assert.equal(t.opened[0].id, 'hermes-bots:home')
assert.equal(t.opened[0].options.title, 'Bots')
// Repeated signals (focus churn, roster polls) must not steal focus back
// or mint a second disposer whose stale predecessor could tear down the
// newer registration.
t.syncBotsHomeWorkspace()
t.syncBotsHomeWorkspace()
assert.equal(t.opened.length, 1)
assert.deepEqual(t.closed, [])
})
test('the home yields the center to a chat and returns when the chat closes', () => {
const t = load()
t.$botsPaneVisible.set(true)
t.syncBotsHomeWorkspace()
assert.equal(t.opened.length, 1)
t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'chat-1' })
t.syncBotsHomeWorkspace()
assert.equal(t.closed.length, 1, 'the home closed for the chat')
t.$openBotChat.set(null)
t.syncBotsHomeWorkspace()
assert.equal(t.opened.length, 2, 'and comes back when nothing else owns the center')
})
test('a restored session at boot keeps the home from covering it', () => {
const t = load({ focusedStoredSessionId: 'restored-chat' })
t.$botsPaneVisible.set(true)
t.syncBotsHomeWorkspace()
assert.deepEqual(t.opened, [], 'the home must not steal the tab from a session the user left open')
})
test('closing the home tab does not resurrect it mid-close', () => {
const t = load()
t.$botsPaneVisible.set(true)
t.syncBotsHomeWorkspace()
// The tab's own ✕ routes through the same disposer.
t.opened[0].options.onClose()
assert.equal(t.opened.length, 1, 'onClose must not re-open the tab the user just closed')
// It comes back only when something else actually happens.
t.syncBotsHomeWorkspace()
assert.equal(t.opened.length, 2)
})
test('the home never yanks the center back from a sibling tab the user chose', () => {
const t = load()
t.$botsPaneVisible.set(true)
t.syncBotsHomeWorkspace()
assert.equal(t.opened.length, 1)
// The user tabs to the ordinary draft workspace: no session owns the
// center, so the home still "should" own it — but it is already open, and
// re-opening would front it over the tab the user just picked.
t.focused.set(null)
t.syncBotsHomeWorkspace()
t.syncBotsHomeWorkspace()
assert.equal(t.opened.length, 1, 'focus churn must not re-front the home')
assert.deepEqual(t.closed, [])
})
test('older shells without the main-area door simply have no home', async () => {
const t = load({ openWorkspace: false, paneVisibility: false })
t.setPluginCtx({ storage: { set: () => undefined } })
t.$botsPaneVisible.set(true)
t.syncBotsHomeWorkspace()
assert.deepEqual(t.opened, [])
// And the remote row keeps its previous guidance toast instead.
await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'researcher', remoteSource: true })
assert.equal(t.notifications.length, 1)
// Guidance is presentation only; remote mention delivery remains backend-owned.
assert.match(t.notifications[0].message, /message @researcher from a Bot Chat/)
assertNothingRouted(t, 'remote row on an older shell')
})
// ── view contracts ──────────────────────────────────────────────────────────
test('the home Tip wraps exactly one element (Radix asChild)', () => {
const start = pluginSource.indexOf('function BotsHomeView(')
assert.ok(start >= 0)
const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace('))
assert.match(view, /jsx\(Tip, \{\s*\n\s*label:[^\n]*\n\s*children: jsxs\('div'/, 'one child element, not an array')
assert.doesNotMatch(view, /jsxs\(Tip, \{/, 'jsxs would pass multiple children and break the trigger')
// The screen-reader text rides INSIDE the trigger element.
assert.match(view, /className: 'sr-only'/)
})
test('the home shows a loading state rather than flashing “No bots”', () => {
const start = pluginSource.indexOf('function BotsHomeView(')
const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace('))
assert.match(view, /if \(!rosterHydrated \|\| !selectionHydrated\) \{[\s\S]{0,220}?GlyphSpinner/)
const spinnerAt = view.indexOf('GlyphSpinner')
const emptyAt = view.indexOf("title: roster.length ? 'Choose a bot or group chat' : 'No bots yet'")
assert.ok(spinnerAt >= 0 && emptyAt > spinnerAt, 'the empty state is only reachable after both hydrations')
})
test('the home uses the neutral workspace surface instead of a transient blue tint', () => {
const start = pluginSource.indexOf('function BotsHomeView(')
const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace('))
assert.match(view, /className: 'flex h-full min-h-0 flex-col bg-background'/)
assert.doesNotMatch(view, /bg-\(--ui-bg-primary\)/)
})
test('roster hydration and selection reconciliation run after render', () => {
const start = pluginSource.indexOf('function BotsPane(')
const pane = pluginSource.slice(start, pluginSource.indexOf('// ── registration'))
assert.match(
pane,
/useEffect\(\(\) => \{[\s\S]{0,500}?\$rosterHydrated\.set\(true\)[\s\S]{0,300}?reconcileRosterSelection\(roster, sourceSnapshot, allMeta\)[\s\S]{0,220}?\}, \[data, error, selectionHydrated, roster, sourceSnapshot, allMeta\]\)/,
'persisted roster ownership must reconcile from an effect, never from a replayable render'
)
assert.equal(
(pane.match(/reconcileRosterSelection\(roster, sourceSnapshot, allMeta\)/g) || []).length,
1,
'BotsPane has one effect-bound reconciliation path'
)
assert.match(
pane,
/sourceWithSelectedOwner = selectionHydrated && rosterHydrated[\s\S]{0,180}?rosterWithSelectedOwner/,
'an unavailable-owner placeholder cannot bypass initial roster hydration'
)
assert.match(
pane,
/\$lastRoster\.set\(roster\.filter\(row => !row\?\.ghost\)\)/,
'presentation-only owner placeholders never enter shared roster state'
)
})
test('an unavailable owner offers retry instead of a dead Open chat button', () => {
const start = pluginSource.indexOf('function BotsHomeView(')
const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace('))
assert.match(view, /unavailable && !sourceRemoved\s*\n\s*\? jsx\(Button, \{[\s\S]{0,200}?children: 'Retry'/)
// Retry re-polls the roster; it must not activate or route anything.
assert.match(view, /queryClient\.invalidateQueries\(\{ queryKey: ROSTER_KEY \}\)/)
assert.doesNotMatch(view, /ensureAgent|requestProfile|newChat/)
assert.match(view, /\$\{gateway\} is unavailable\. Retry when it is back online\./)
assert.match(view, /\$\{gateway\} was removed\. Choose another bot from the sidebar\./)
assert.doesNotMatch(view, /This bot remains selected/)
assert.doesNotMatch(view, /its work keeps running on that gateway/)
})
test('an available remote owner explains the supported Bot Chat path without a fake direct action', () => {
const start = pluginSource.indexOf('function BotsHomeView(')
const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace('))
assert.match(view, /unavailable \|\| !bot\.remoteSource/)
assert.match(view, /This bot lives on \$\{gateway\}\. Mention it from any Bot Chat to send it a message\./)
assert.doesNotMatch(view, /Copy @/)
assert.doesNotMatch(view, /remoteCopy/)
assert.doesNotMatch(view, /ensureAgent|requestProfile|newChat/)
})
test('an unavailable owner never presents a guessed mention handle', () => {
const start = pluginSource.indexOf('function BotsHomeView(')
const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace('))
assert.match(view, /const handle = bot\.ghost \? '' : botHandle\(bot\.name, bot\)/)
assert.match(view, /handle\s*\n\s*\? jsx\('span'/)
})
test('Bot Mode copy says bot, not agent', () => {
assert.doesNotMatch(pluginSource, /Name the agent first/)
assert.doesNotMatch(pluginSource, /create agents first/)
assert.doesNotMatch(pluginSource, /children: busy \? 'Creating…' : 'Create Agent'/)
assert.doesNotMatch(pluginSource, /`Agent "\$\{displayName\(\{ name: slug, title \}\)\}" created/)
assert.match(pluginSource, /Name the bot first/)
assert.match(pluginSource, /children: busy \? 'Creating…' : 'Create Bot'/)
assert.match(pluginSource, /`Bot "\$\{displayName\(\{ name: slug, title \}\)\}" created/)
})
@@ -11,7 +11,7 @@ function loadFilter() {
assert.ok(start >= 0 && end > start, 'bot identity helper block must remain extractable')
const context = {}
const context = { botActivitySession: bot => bot?.canonical_session || bot?.last_session || null }
vm.runInNewContext(
`${source.slice(start, end)}\nglobalThis.__filterBots = filterBots;`,
context
@@ -56,6 +56,23 @@ test('bot search matches profile handles and preserves roster order', () => {
)
})
test('bot search also matches roles, message previews, and gateway labels', () => {
const filterBots = loadFilter()
const richerRoster = [
{
name: 'reviewer',
connectionLabel: 'Work Studio',
description: 'Release quality and compliance',
last_session: { preview: 'Checked the deployment checklist' }
},
{ name: 'writer', description: 'Editorial support' }
]
assert.equal(filterBots(richerRoster, {}, 'compliance')[0].name, 'reviewer')
assert.equal(filterBots(richerRoster, {}, 'deployment checklist')[0].name, 'reviewer')
assert.equal(filterBots(richerRoster, {}, 'work studio')[0].name, 'reviewer')
})
test('blank bot search returns the existing roster reference', () => {
const filterBots = loadFilter()
@@ -63,7 +80,16 @@ test('blank bot search returns the existing roster reference', () => {
})
test('Bot pane renders the canonical search field and no-match state', () => {
assert.match(source, /jsx\(SearchField,\s*\{[\s\S]*?placeholder: 'Search bots…'/)
assert.match(
source,
/const showRosterSearch =\s*gatewayOptions\.length > 1 \|\| rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD/
)
assert.match(source, /jsx\(SearchField,\s*\{[\s\S]*?placeholder: 'Search bots and group chats…'/)
assert.match(source, /query \? 'opacity-100!' : 'opacity-50 focus-within:opacity-100'/)
assert.match(source, /placeholder:text-\(--ui-text-tertiary\)/)
assert.match(source, /children: jsx\(Codicon, \{ name: 'list-filter' \}\)/)
assert.match(source, /'aria-live': 'polite'/)
assert.match(source, /No bots match “\$\{query\.trim\(\)\}”/)
assert.match(source, /No bots or group chats match “\$\{query\.trim\(\)\}”/)
assert.match(source, /const initialRosterLoading = !data && !error && roster\.length === 0/)
assert.match(source, /\(isLoading \|\| initialRosterLoading\) && !roster\.length/)
})
@@ -4,21 +4,23 @@ import test from 'node:test'
const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
// Discord-style creation flow: the header "+" is a dropdown (New Agent /
// Discord-style creation flow: the header "+" is a dropdown (New Bot /
// New Group Chat), and New Group Chat opens a checkbox-picker modal with
// search, a name input, and a Create button. Source-contract style, like
// the other roster affordance tests.
test('source contract: header + is a dropdown offering agent and group chat', () => {
assert.match(pluginSource, /DropdownMenuTrigger/)
assert.match(pluginSource, /'New Agent'/)
assert.match(pluginSource, /'New Bot'/)
assert.match(pluginSource, /'New Group Chat'/)
})
test('source contract: create-group modal has search, checkboxes, name, create', () => {
assert.match(pluginSource, /function CreateGroupChatDialog\(/)
// An outage placeholder preserves identity, but cannot receive a message.
assert.match(pluginSource, /const selectableRoster = roster\.filter\(bot => !bot\?\.ghost\)/)
// Reuses the roster search filter so name/@handle/title all match.
assert.match(pluginSource, /const visible = filterBots\(roster, allMeta, query\)/)
assert.match(pluginSource, /const visible = filterBots\(selectableRoster, allMeta, query\)/)
// Selection is checkbox-driven and capped at the room member limit.
assert.match(pluginSource, /const atCap = selected\.length >= GROUP_CHAT_MAX_MEMBERS/)
// Create requires 2+ members. Membership mutation is covered by the
@@ -40,8 +40,11 @@ test('source contract: room picture persists and hydrates with the room record',
})
test('source contract: room picture shows in the roster row and room header', () => {
// Roster row: picture wins over the fanned member faces when set.
assert.match(pluginSource, /children: room\.image\s*\n\s*\? jsx\('img', \{/)
// Roster row: one room picture wins over a generic room icon when set.
const rowSource = pluginSource.slice(pluginSource.indexOf('function GroupRow('), pluginSource.indexOf('function RosterSectionHeader('))
assert.match(rowSource, /room\.image\s*\n\s*\? jsx\('img', \{/)
assert.match(rowSource, /name: 'organization'/)
assert.doesNotMatch(rowSource, /members\.slice\([^)]*\)\.map/)
// Header: picture leads the title.
assert.match(pluginSource, /Room picture \(set via Group settings\) leads the title when present/)
})
@@ -8,10 +8,12 @@ import test from 'node:test'
// so every boot re-homes a stacked install — no heal token, no
// user-placed exemption (the retired one-shot heal left users who had
// dragged panes stuck stacked forever);
// - the Cronjobs (routines) pane only exists while the Bots pane is on
// screen — registered/unregistered through the contribution disposer,
// driven by the feature-detected host.paneVisibility SDK export, with the
// always-registered fallback kept for older desktops.
// - the Cronjobs (routines) pane only exists while a BOT CHAT owns the main
// workspace and the Bots pane is on screen — registered/unregistered
// through the contribution disposer, driven by the feature-detected
// host.paneVisibility SDK export, with the always-registered fallback kept
// for older desktops. Cronjobs are bot-scoped, so the tile must not sit
// beside the ownerless Bots home or a group chat.
const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
@@ -34,8 +36,12 @@ test('routines pane rides Bots visibility via feature-detected host.paneVisibili
// Transitions register/unregister through the tracked disposer.
assert.match(source, /unregisterRoutines \?\?= registerRoutinesPane\(\)/)
assert.match(source, /unregisterRoutines\(\)\s*\n\s*unregisterRoutines = null/)
// The visibility listener must not survive plugin disable.
assert.match(source, /ctx\.onDispose\(stopRoutinesSync\)/)
// Ownership, not mere visibility: an actual bot chat must own the center.
assert.match(source, /if \(botChatOwnsWorkspace\(\)\) \{/)
// None of the lifecycle listeners may survive plugin disable.
assert.match(source, /ctx\.onDispose\(\(\) => \{\s*\n\s*stopSidebarSync\(\)/)
assert.match(source, /stopGroupSync\(\)/)
assert.match(source, /stopFocusSync\?\.\(\)/)
})
test('older desktops without the SDK export keep the always-registered pane', () => {
@@ -125,6 +125,34 @@ test('groupChatMemberBots: seats local meta members plus stored remote descripto
assert.equal(members[2], roster[2])
})
test('groupChatMemberBots: stored descriptors beat presentation-only ghosts', () => {
const { groupChatMemberBots, $groupChats } = load()
const ghost = {
name: 'spark',
remoteSource: true,
sourceScoped: true,
ghost: true,
connectionId: 'c1',
connectionLabel: 'Workshop'
}
const stored = {
name: 'spark',
handle: 'spark-work',
title: 'Spark',
remoteSource: true,
sourceScoped: true,
connectionId: 'c1',
connectionLabel: 'Workshop'
}
$groupChats.set({ Research: { log: [], members: [stored] } })
const members = groupChatMemberBots('Research', [ghost], {})
assert.equal(members.length, 1)
assert.equal(members[0], stored)
assert.equal(members[0].handle, 'spark-work')
})
test('durableGroupChatMembers: retains active and remote source identities', () => {
const { durableGroupChatMembers } = load()
const members = durableGroupChatMembers([
@@ -201,10 +229,11 @@ test('stripPreviewMarkdown: flattens bold, quotes, code, and links out of previe
assert.equal(stripPreviewMarkdown(''), '')
})
test('source contract: the roster stays a flat list of bot and group rows', () => {
// Ordering is deliberately unchanged in this PR; sectioned ordering follows separately.
assert.doesNotMatch(pluginSource, /function groupRoster\(/)
assert.match(pluginSource, /rosterRows\.map\(row =>/)
test('source contract: the roster progressively groups multiple gateways and keeps group chats distinct', () => {
assert.match(pluginSource, /function rosterGatewaySections\(/)
assert.match(pluginSource, /options\.length <= 1/)
assert.match(pluginSource, /gatewayFilter !== 'all'/)
assert.match(pluginSource, /label: 'Group chats'/)
assert.match(pluginSource, /function GroupRow\(/)
assert.match(pluginSource, /onGroup: setGrouping/)
})
@@ -105,6 +105,7 @@ function renderRuntime() {
ContextMenuItem: 'ContextMenuItem',
ContextMenuSeparator: 'ContextMenuSeparator',
ContextMenuTrigger: 'ContextMenuTrigger',
Tip: 'Tip',
haptic: () => undefined,
host: {
state: {
@@ -173,12 +174,12 @@ const DM_BOT = {
}
}
test('render: BotRow shows the sender badge and stripped DM preview', () => {
test('render: BotRow shows a clean stripped DM preview without delivery furniture', () => {
const r = renderRuntime()
const tree = r.__BotRow({ bot: DM_BOT, onEdit: () => undefined })
const text = textOf(tree)
assert.match(text, /@manager/)
assert.match(text, /Learn-share/)
assert.doesNotMatch(text, /@manager/)
assert.doesNotMatch(text, /Message from/)
})
@@ -200,10 +201,11 @@ test('render: BotRow tolerates a fresh bot with no sessions yet', () => {
const r = renderRuntime()
const tree = r.__BotRow({ bot: { name: 'newbie', title: '', description: 'Fresh bot' }, onEdit: () => undefined })
const text = textOf(tree)
assert.match(text, /Fresh bot/)
assert.match(text, /Newbie/)
assert.doesNotMatch(text, /No conversations yet/)
})
test('render: a remote gateway name is not squeezed out by its handle', () => {
test('render: a remote bot keeps its identity while gateway details stay in the tooltip', () => {
const r = renderRuntime()
const tree = r.__BotRow({
bot: {
@@ -215,15 +217,14 @@ test('render: a remote gateway name is not squeezed out by its handle', () => {
},
onEdit: () => undefined
})
const name = findNode(tree, node => node.type === 'span' && textOf(node) === 'Studio over SSH')
const handle = findNode(tree, node => node.type === 'span' && textOf(node) === '@default-studio-over-ssh')
const name = findNode(tree, node => node.type === 'span' && textOf(node) === 'Hermes')
const button = findNode(tree, node => node.type === 'button' && node.props?.['aria-label'])
assert.ok(name)
assert.match(name.props.className, /shrink-0/)
assert.ok(handle)
assert.match(handle.props.className, /min-w-0/)
assert.match(handle.props.className, /truncate/)
assert.doesNotMatch(handle.props.className, /shrink-0/)
assert.ok(button)
assert.match(button.props['aria-label'], /Hermes/)
assert.match(button.props['aria-label'], /Studio over SSH/)
assert.doesNotMatch(textOf(button), /Studio over SSH/)
})
test('render: BotRow previews the pinned canonical chat, not an unrelated latest session', () => {