From 9bea439189cec804bda6136ac183b6063181cd0a Mon Sep 17 00:00:00 2001 From: Goktug Vatandas Date: Tue, 18 Aug 2026 05:11:40 +0300 Subject: [PATCH] feat(bot-mode): support multiple groups per bot --- .../desktop/src/plugins/hermes-bots/plugin.js | 183 ++++++++++++------ .../tests/active-now-strip.test.mjs | 2 +- .../hermes-bots/tests/bot-meta-sync.test.mjs | 19 ++ .../tests/create-group-chat.test.mjs | 12 +- .../hermes-bots/tests/group-chat.test.mjs | 39 +++- .../tests/profile-prewarm.test.mjs | 2 + .../hermes-bots/tests/roster-groups.test.mjs | 69 +++++-- website/docs/user-guide/bot-mode.md | 8 +- 8 files changed, 254 insertions(+), 80 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 66f8c6cd83..16368aa5e2 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -585,6 +585,18 @@ function mergeServerMeta(roster) { delete merged.chat } + // Canonical multi-group metadata is authoritative for the compatibility + // scalar too. A server-side `group: null` is represented by omission, + // so retaining the local scalar would resurrect a membership that another + // desktop just removed. + if ( + Array.isArray(server.groups) && + Object.prototype.hasOwnProperty.call(mine, 'group') && + !Object.prototype.hasOwnProperty.call(server, 'group') + ) { + delete merged.group + } + if (JSON.stringify(next[bot.name] || null) !== JSON.stringify(merged)) { next[bot.name] = merged changed = true @@ -3151,6 +3163,46 @@ function stripPreviewMarkdown(text) { .trim() } +/** Canonical multi-group read with legacy scalar compatibility. Profiles that + * predate `groups` still fall back to `group`; once the canonical array exists, + * it is authoritative. Writes keep `group` as a first-membership projection so + * older desktops can still display one room without corrupting the array. */ +function botGroups(meta) { + const groups = [] + const seen = new Set() + const values = Array.isArray(meta?.groups) ? meta.groups : [meta?.group] + + for (const value of values) { + if (typeof value !== 'string') { + continue + } + + const group = value.trim() + + if (group && !seen.has(group)) { + seen.add(group) + groups.push(group) + } + } + + return groups +} + +function groupMembershipPatch(meta, group, enabled) { + const name = String(group || '').trim() + let groups = botGroups(meta) + + if (enabled) { + if (name && !groups.includes(name)) { + groups = [...groups, name] + } + } else { + groups = groups.filter(existing => existing !== name) + } + + return { groups, group: groups[0] || null } +} + /** Group chats that should hold a roster row: every group named in bot meta * (local members) plus every room record that still has stored members or * log — cross-connection rooms whose members can't ride bot-meta. */ @@ -3179,7 +3231,7 @@ function groupLastActivity(room) { * Prefers the LIVE roster row for a stored descriptor when present. */ function groupChatMemberBots(group, roster, metaByName) { const local = (roster || []).filter( - bot => !bot.remoteSource && (botRosterMeta(bot, metaByName)?.group || '').trim() === group + bot => !bot.remoteSource && botGroups(botRosterMeta(bot, metaByName)).includes(group) ) const stored = ($groupChats.get()[group] || {}).members || [] const seated = new Set(local.map(botRosterKey)) @@ -3214,14 +3266,12 @@ function durableGroupChatMembers(bots) { })) } -/** Existing group names, alphabetical — feeds the Move-to-group dialog. */ +/** Existing group names, alphabetical — feeds the Manage-groups dialog. */ function knownGroups(metaByName) { const names = new Set() for (const meta of Object.values(metaByName || {})) { - const group = (meta?.group || '').trim() - - if (group) { + for (const group of botGroups(meta)) { names.add(group) } } @@ -3462,13 +3512,12 @@ function updateGroupChat(group, mutate) { return next } -/** Soft-disband a group chat: clear every member's group assignment (the - * grouping is bot-meta, so the disband syncs cross-machine via ui_meta), - * drop the room log from the atom + plugin storage, and close the room view - * if it's open. The members' per-group gateway sessions ("Group: ") - * are intentionally KEPT — they stay reachable through each bot's session - * browser. */ -async function disbandGroupChat(group, memberNames) { +/** Soft-disband a group chat: remove only this group from every local member's + * membership list (the metadata syncs cross-machine via ui_meta), drop the + * room log from the atom + plugin storage, and close the room view if it's + * open. Other group memberships and the members' per-group gateway sessions + * ("Group: ") are intentionally KEPT. */ +async function disbandGroupChat(group, members) { // Invalidate any in-flight round-robin FIRST: bump the epoch so a running // drive bails at its next member boundary instead of appending to a room // the user just discarded. @@ -3503,7 +3552,12 @@ async function disbandGroupChat(group, memberNames) { for (const [name, room] of Object.entries($groupChats.get())) { if (name !== group && Array.isArray(room.log)) { - durable[name] = { log: room.log, watermarks: room.watermarks, sessions: room.sessions || {} } + durable[name] = { + log: room.log, + watermarks: room.watermarks, + sessions: room.sessions || {}, + members: Array.isArray(room.members) ? room.members : [] + } } } @@ -3512,11 +3566,16 @@ async function disbandGroupChat(group, memberNames) { /* storage unavailable — the atom reset above still empties the room */ } - // Ungroup the members last. saveBotMeta never throws (local storage + + // Remove this membership last. saveBotMeta never throws (local storage + // best-effort profiles.configure per member), so a flaky gateway can't // strand the disband halfway with the room log already gone. - for (const name of memberNames) { - await saveBotMeta(name, { group: null }) + for (const member of members) { + if (!member?.name || member.remoteSource) { + continue + } + + const meta = $botMeta.get()[member.name] || {} + await saveBotMeta(member.name, groupMembershipPatch(meta, group, false)) } } @@ -4121,6 +4180,7 @@ function activeBots(roster, activeProfile, gatewayState, now = Date.now()) { function BotRow({ bot, onDelete, onEdit, onGroup }) { const activeProfile = useValue(host.state.profile) const meta = botRosterMeta(bot, useValue($botMeta)) + const groups = botGroups(meta) const last = bot.last_session const isActive = !bot.remoteSource && bot.name === activeProfile const { shape, color, image } = botAppearance(bot.name, meta) @@ -4378,10 +4438,12 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { children: 'Sessions' }), jsx(ContextMenuItem, { onSelect: () => onEdit(bot), children: 'Edit Profile' }), - jsx(ContextMenuItem, { - onSelect: () => onGroup(bot), - children: meta?.group ? `Group: ${meta.group}…` : 'Move to group…' - }), + !bot.remoteSource + ? jsx(ContextMenuItem, { + onSelect: () => onGroup(bot), + children: groups.length ? `Groups: ${groups.join(', ')}…` : 'Manage groups…' + }) + : null, jsx(ContextMenuItem, { onSelect: () => { host.notify({ kind: 'info', message: `Duplicating ${displayName(bot, meta)}…` }) @@ -7247,24 +7309,23 @@ function ActiveNowStrip({ roster, activeProfile, gatewayState, metaByName, onOpe }) } -/** Assign a bot to a group (or clear it). Existing groups are one-click; - * the input creates a new one. The group is a bot-meta field, so it syncs - * cross-machine via ui_meta like pin/title. */ +/** Manage a bot's group-chat memberships. Existing groups are independent + * toggles; the input creates and joins a new one. The canonical groups array + * and legacy scalar projection both ride ui_meta like pin/title. */ function GroupDialog({ bot, onClose }) { const meta = useValue($botMeta) const [name, setName] = useState('') - const current = (meta[bot?.name]?.group || '').trim() + const current = botGroups(meta[bot?.name]) const groups = knownGroups(meta) - const assign = group => { - saveBotMeta(bot.name, { group: group || null }) + const setMembership = (group, enabled) => { + saveBotMeta(bot.name, groupMembershipPatch(meta[bot.name], group, enabled)) host.notify({ kind: 'info', - message: group - ? `${displayName(bot, meta[bot.name])} moved to “${group}”` - : `${displayName(bot, meta[bot.name])} removed from its group` + message: enabled + ? `${displayName(bot, meta[bot.name])} added to “${group}”` + : `${displayName(bot, meta[bot.name])} removed from “${group}”` }) - onClose() } return jsx(Dialog, { @@ -7279,23 +7340,34 @@ function GroupDialog({ bot, onClose }) { children: [ jsxs(DialogHeader, { children: [ - jsx(DialogTitle, { children: 'Move to group' }), + jsx(DialogTitle, { children: 'Manage groups' }), jsx(DialogDescription, { - children: 'Groups render as labeled sections in the Bots roster and sync to every machine.' + children: 'A bot can join multiple group chats. Memberships sync to every machine.' }) ] }), groups.length ? jsx('div', { - className: 'flex flex-wrap gap-1.5', - children: groups.map(group => - jsx(Button, { - variant: group === current ? 'default' : 'secondary', - size: 'sm', - onClick: () => assign(group), - children: group - }, group) - ) + className: 'grid gap-1.5', + children: groups.map(group => { + const enabled = current.includes(group) + + return jsxs( + 'label', + { + className: + 'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-(--chrome-action-hover)', + children: [ + jsx(Checkbox, { + checked: enabled, + onCheckedChange: checked => setMembership(group, checked === true) + }), + jsx('span', { children: group }) + ] + }, + group + ) + }) }) : null, jsxs('form', { @@ -7305,7 +7377,8 @@ function GroupDialog({ bot, onClose }) { const trimmed = name.trim() if (trimmed) { - assign(trimmed) + setMembership(trimmed, true) + setName('') } }, children: [ @@ -7315,16 +7388,16 @@ function GroupDialog({ bot, onClose }) { value: name, onChange: event => setName(event.target.value) }), - jsx(Button, { type: 'submit', size: 'sm', disabled: !name.trim(), children: 'Create' }) + jsx(Button, { type: 'submit', size: 'sm', disabled: !name.trim(), children: 'Create & join' }) ] }), - current + current.length ? jsx(Button, { variant: 'ghost', size: 'sm', className: 'justify-self-start', - onClick: () => assign(null), - children: `Remove from “${current}”` + onClick: () => saveBotMeta(bot.name, { groups: [], group: null }), + children: 'Remove from all groups' }) : null ] @@ -7333,9 +7406,9 @@ function GroupDialog({ bot, onClose }) { } /** Discord-style group chat creation: pick 2+ bots via checkboxes (with - * search), name the group, create. Assignment is the existing per-bot - * `group` meta field, so the room appears in the roster and syncs - * cross-machine via ui_meta exactly like Move-to-group. */ + * search), name the group, create. Assignment appends to each local bot's + * group membership list, so the room appears in the roster and syncs + * cross-machine via ui_meta without replacing its other groups. */ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) { const allMeta = useValue($botMeta) const [query, setQuery] = useState('') @@ -7393,7 +7466,7 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) { for (const bot of selected) { if (!bot.remoteSource) { - void saveBotMeta(bot.name, { group: groupName }) + void saveBotMeta(bot.name, groupMembershipPatch(botRosterMeta(bot, allMeta), groupName, true)) } } @@ -7426,7 +7499,7 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) { children: [ jsx(DialogTitle, { children: 'New Group Chat' }), jsx(DialogDescription, { - children: `Pick 2–${GROUP_CHAT_MAX_MEMBERS} bots. The room lives in the Bots roster and syncs to every machine.` + children: `Pick 2–${GROUP_CHAT_MAX_MEMBERS} bots. Local memberships sync through each Bot profile; cross-machine members stay scoped to this room.` }) ] }), @@ -7464,7 +7537,7 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) { const { shape, color, image } = botAppearance(bot.name, meta) const isChecked = Boolean(checked[botRosterKey(bot)]) const disabled = !isChecked && atCap - const currentGroup = (meta?.group || '').trim() + const currentGroups = botGroups(meta) return jsxs('label', { className: cn( @@ -7486,8 +7559,8 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) { jsx('div', { className: 'truncate text-[0.625rem] text-(--ui-text-quaternary)', children: [ - currentGroup - ? `@${botHandle(bot.name, bot)} · in “${currentGroup}”` + currentGroups.length + ? `@${botHandle(bot.name, bot)} · in ${currentGroups.map(group => `“${group}”`).join(', ')}` : `@${botHandle(bot.name, bot)}`, bot.remoteSource && bot.connectionLabel ? ` · ${bot.connectionLabel}` : '' ].join('') @@ -7771,7 +7844,7 @@ function GroupChatWorkspace({ group, members, onBack }) { doneLabel: 'Disbanded', onClose: () => setConfirmDisband(false), onConfirm: async () => { - await disbandGroupChat(group, members.map(bot => bot.name)) + await disbandGroupChat(group, members) host.notify({ kind: 'success', message: `Disbanded “${group}”` }) } }) 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 59d0a36c70..3859adced7 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 @@ -90,7 +90,7 @@ test('ActiveNowStrip renders above the roster, is a live region, and is click-ac 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) + assert.match(source, /\}, botRosterKey\(bot\)\)\s*\}\)\s*\]\s*\}\)\s*\}\s*\/\*\* Manage a bot's group-chat memberships/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\)/) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bot-meta-sync.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bot-meta-sync.test.mjs index 63b30b3cc0..503123839c 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bot-meta-sync.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bot-meta-sync.test.mjs @@ -68,6 +68,25 @@ test('regression: authoritative server metadata removes stale local canonical ch assert.equal(Object.hasOwn(writes.at(-1).value.default, 'chat'), false) }) +test('regression: authoritative groups remove a stale local legacy group projection', () => { + const writes = [] + const sync = load() + sync.set({ researcher: { groups: ['Old'], group: 'Old', title: 'Researcher' } }) + sync.setPluginCtx({ storage: { set: (key, value) => writes.push({ key, value }) } }) + + sync.mergeServerMeta([ + { + name: 'researcher', + ui_meta: { 'hermes-bots': { groups: [], title: 'Researcher' } } + } + ]) + + const current = sync.get().researcher + assert.deepEqual(current.groups, []) + assert.equal(Object.hasOwn(current, 'group'), false) + assert.equal(Object.hasOwn(writes.at(-1).value.researcher, 'group'), false) +}) + test('compatibility: local canonical chat survives when gateway has no server bot metadata', () => { const writes = [] const sync = load() diff --git a/apps/desktop/src/plugins/hermes-bots/tests/create-group-chat.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/create-group-chat.test.mjs index 56473f0c1c..f4e6f8b9da 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/create-group-chat.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/create-group-chat.test.mjs @@ -21,10 +21,16 @@ test('source contract: create-group modal has search, checkboxes, name, create', assert.match(pluginSource, /const visible = filterBots\(roster, 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 and writes the existing group meta field, - // so the room rides the ui_meta sync path (no new persistence). + // Create requires 2+ members and appends to the canonical multi-group field + // without replacing existing memberships. assert.match(pluginSource, /selected\.length >= 2/) - assert.match(pluginSource, /saveBotMeta\(bot\.name, \{ group: groupName \}\)/) + assert.match( + pluginSource, + /groupMembershipPatch\(botRosterMeta\(bot, allMeta\), groupName, true\)/ + ) + // Existing membership labels enumerate every canonical group. + assert.match(pluginSource, /const currentGroups = botGroups\(meta\)/) + assert.match(pluginSource, /currentGroups\.map\(group => `“\$\{group\}”`\)\.join\(', '\)/) // Creating drops the user straight into the room (main window when the // desktop offers host.openWorkspace, in-panel fallback otherwise). assert.match(pluginSource, /onCreated: groupName => openGroupChat\(groupName\)/) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs index b5a608ee74..1bc27bf49f 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs @@ -252,7 +252,7 @@ test('source contract: workspace + main-window door + prompt rules are wired', ( assert.match(pluginSource, /\[Group chat: "\$\{groupName\}"\]/) }) -test('disband: clears grouping meta, room log, workspace, needs-you; keeps sessions in storage map only for other rooms', async () => { +test('disband: removes only this membership, room log, workspace, and needs-you state', async () => { const gc = load(() => '(pass)') // Two rooms; disband one. @@ -265,11 +265,21 @@ test('disband: clears grouping meta, room log, workspace, needs-you; keeps sessi await new Promise(resolve => setImmediate(resolve)) } - gc.$botMeta.set({ builder: { group: 'Gone' }, research: { group: 'Keep' } }) + const rooms = { ...gc.$groupChats.get() } + rooms.Keep = { + ...rooms.Keep, + members: [{ name: 'remote', remoteSource: true, sourceScoped: true, connectionId: 'remote-1' }] + } + gc.$groupChats.set(rooms) + + gc.$botMeta.set({ + builder: { groups: ['Gone', 'Keep'], group: 'Gone' }, + research: { groups: ['Keep'], group: 'Keep' } + }) gc.$groupChatWorkspace.set('Gone') gc.$groupNeedsYou.set({ Gone: true, Keep: true }) - await gc.disbandGroupChat('Gone', ['builder']) + await gc.disbandGroupChat('Gone', [{ name: 'builder' }]) // Room state: gone from the atom (no running drive, so no tombstone). assert.equal(gc.$groupChats.get().Gone, undefined) @@ -278,13 +288,30 @@ test('disband: clears grouping meta, room log, workspace, needs-you; keeps sessi assert.equal(gc.$groupChatWorkspace.get(), null) assert.equal(gc.$groupNeedsYou.get().Gone, undefined) assert.equal(gc.$groupNeedsYou.get().Keep, true) - // Members ungrouped; other bots keep their group. - assert.equal(gc.$botMeta.get().builder.group, null) + // Disband removes only this membership; other groups survive. + assert.equal(JSON.stringify(gc.$botMeta.get().builder.groups), JSON.stringify(['Keep'])) + assert.equal(gc.$botMeta.get().builder.group, 'Keep') + assert.equal(JSON.stringify(gc.$botMeta.get().research.groups), JSON.stringify(['Keep'])) assert.equal(gc.$botMeta.get().research.group, 'Keep') // Persisted room map no longer carries the room. const durable = gc.storageWrites.get('group-chats') assert.ok(durable && !('Gone' in durable), 'disbanded room not persisted') assert.ok('Keep' in durable, 'surviving room still persisted') + assert.equal(durable.Keep.members.length, 1, 'surviving room keeps remote member descriptors') + assert.equal(durable.Keep.members[0].connectionId, 'remote-1') +}) + +test('disband: skips source-qualified remote members instead of mutating same-named local metadata', async () => { + const gc = load(() => '(pass)') + gc.$botMeta.set({ builder: { groups: ['Keep'], group: 'Keep' } }) + + await gc.disbandGroupChat('Remote', [ + { name: 'builder', remoteSource: true, sourceScoped: true, connectionId: 'remote-1' } + ]) + + assert.equal(JSON.stringify(gc.$botMeta.get().builder.groups), JSON.stringify(['Keep'])) + assert.equal(gc.$botMeta.get().builder.group, 'Keep') + assert.equal(gc.$botMeta.get()['[object Object]'], undefined) }) test('disband: a running room leaves an epoch-bumped empty tombstone so in-flight turns bail', async () => { @@ -300,7 +327,7 @@ test('disband: a running room leaves an epoch-bumped empty tombstone so in-fligh rooms.Live = { ...rooms.Live, running: true, epoch: 3 } gc.$groupChats.set(rooms) - await gc.disbandGroupChat('Live', ['research']) + await gc.disbandGroupChat('Live', [{ name: 'research' }]) const tomb = gc.$groupChats.get().Live assert.ok(tomb, 'tombstone present while a drive is mid-turn') diff --git a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs index a092730ff6..00a2f65340 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs @@ -44,6 +44,7 @@ function renderBotRow(input = 'alpha') { $lastRoster: atom([]), $selectedBot: atom('default'), botAppearance: () => ({ shape: 'round', color: '#000', image: null }), + botGroups: () => [], botHandle: value => value, botRosterMeta: (_bot, metaByName) => metaByName?.[_bot.name] ?? null, cn: (...values) => values.filter(Boolean).join(' '), @@ -169,6 +170,7 @@ test('behavior: remote default does not open this-device chat when the source di $lastRoster: atom([]), $selectedBot: atom('default'), botAppearance: () => ({ shape: 'round', color: '#000', image: null }), + botGroups: () => [], botHandle: value => value, botRosterMeta: () => null, cn: (...values) => values.filter(Boolean).join(' '), 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 fdb70652ed..2081192ca3 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 @@ -30,15 +30,54 @@ function load() { .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') .replace('export default {', 'globalThis.plugin = {') .concat( - '\nglobalThis.__groups = { groupChatNames, groupLastActivity, groupChatMemberBots, durableGroupChatMembers, knownGroups, stripPreviewMarkdown, $groupChats };\n' + '\nglobalThis.__groups = { botGroups, groupMembershipPatch, groupChatNames, groupLastActivity, groupChatMemberBots, durableGroupChatMembers, knownGroups, stripPreviewMarkdown, $groupChats };\n' ) vm.runInNewContext(source, context, { filename: 'plugin.js' }) return context.__groups } +test('botGroups: normalizes canonical and legacy membership without duplicates', () => { + const { botGroups } = load() + + assert.equal( + JSON.stringify( + botGroups({ groups: [' Engineering ', '', 'Research', 'Engineering', null, 7, { name: 'Nope' }], group: 'Operations' }) + ), + JSON.stringify(['Engineering', 'Research']) + ) + assert.equal(JSON.stringify(botGroups({ group: 'Legacy' })), JSON.stringify(['Legacy'])) + assert.equal(JSON.stringify(botGroups({ groups: [] })), JSON.stringify([])) +}) + +test('groupMembershipPatch: toggles one membership and keeps the legacy projection compatible', () => { + const { groupMembershipPatch } = load() + const meta = { groups: ['Engineering', 'Research'], group: 'Engineering' } + + assert.equal( + JSON.stringify(groupMembershipPatch(meta, 'Engineering', true)), + JSON.stringify({ groups: ['Engineering', 'Research'], group: 'Engineering' }) + ) + assert.equal( + JSON.stringify(groupMembershipPatch(meta, 'Operations', true)), + JSON.stringify({ groups: ['Engineering', 'Research', 'Operations'], group: 'Engineering' }) + ) + assert.equal( + JSON.stringify(groupMembershipPatch(meta, 'Engineering', false)), + JSON.stringify({ groups: ['Research'], group: 'Research' }) + ) + assert.equal( + JSON.stringify(groupMembershipPatch({ group: 'Legacy' }, 'Legacy', false)), + JSON.stringify({ groups: [], group: null }) + ) +}) + test('groupChatNames: unions bot-meta groups with room records that carry members or log', () => { const { groupChatNames } = load() - const meta = { researcher: { group: 'Research' }, pm: { group: 'Ops' } } + const meta = { + researcher: { group: 'Research' }, + pm: { groups: ['Ops', 'Research'], group: 'Ops' }, + scout: { groups: ['External'], group: 'Stale' } + } const rooms = { Research: { log: [], members: [] }, // already known via meta Remote: { log: [], members: [{ name: 'spark', remoteSource: true }] }, @@ -48,7 +87,10 @@ test('groupChatNames: unions bot-meta groups with room records that carry member const names = groupChatNames(meta, rooms) - assert.equal(JSON.stringify([...names].sort()), JSON.stringify(['Chatty', 'Ops', 'Remote', 'Research'])) + assert.equal( + JSON.stringify([...names].sort()), + JSON.stringify(['Chatty', 'External', 'Ops', 'Remote', 'Research']) + ) }) test('groupLastActivity: newest room-log timestamp, 0 for silence', () => { @@ -75,12 +117,12 @@ test('groupChatMemberBots: seats local meta members plus stored remote descripto const members = groupChatMemberBots('Research', roster, { researcher: { group: 'Research' }, - builder: { group: 'Ops' } + builder: { groups: ['Ops', 'Research'], group: 'Ops' } }) - assert.equal(JSON.stringify(members.map(m => m.name)), JSON.stringify(['researcher', 'spark'])) + assert.equal(JSON.stringify(members.map(m => m.name)), JSON.stringify(['researcher', 'builder', 'spark'])) // The LIVE roster row was preferred over the stored descriptor. - assert.equal(members[1], roster[2]) + assert.equal(members[2], roster[2]) }) test('durableGroupChatMembers: retains active and remote source identities', () => { @@ -128,13 +170,13 @@ test('knownGroups: unique, trimmed, alphabetical', () => { const groups = knownGroups({ a: { group: 'research' }, - b: { group: 'Ops' }, - c: { group: 'research' }, + b: { groups: ['Ops', 'research'], group: 'Ops' }, + c: { groups: ['Design'] }, d: { group: '' }, e: {} }) - assert.equal(JSON.stringify(groups), JSON.stringify(['Ops', 'research'])) + assert.equal(JSON.stringify(groups), JSON.stringify(['Design', 'Ops', 'research'])) }) test('stripPreviewMarkdown: flattens bold, quotes, code, and links out of previews', () => { @@ -147,13 +189,16 @@ test('stripPreviewMarkdown: flattens bold, quotes, code, and links out of previe assert.equal(stripPreviewMarkdown(''), '') }) -test('source contract: the roster is a flat list of bot + group rows and the row menu offers grouping', () => { - // Flat Discord-style list — the sectioned groupRoster presentation is gone. +test('source contract: the roster stays flat while the row menu manages multiple groups', () => { + // Ordering is deliberately unchanged in this PR; sectioned ordering follows separately. assert.doesNotMatch(pluginSource, /function groupRoster\(/) assert.match(pluginSource, /rosterRows\.map\(row =>/) assert.match(pluginSource, /function GroupRow\(/) assert.match(pluginSource, /onGroup: setGrouping/) - assert.match(pluginSource, /'Move to group…'/) + assert.match(pluginSource, /'Manage groups…'/) + assert.match(pluginSource, /!bot\.remoteSource\s*\? jsx\(ContextMenuItem, \{\s*onSelect: \(\) => onGroup\(bot\)/) + assert.match(pluginSource, /botGroups\(meta\[bot\?\.name\]\)/) + assert.match(pluginSource, /groupMembershipPatch\(meta\[bot\.name\], group, enabled\)/) }) test('source contract: group rows carry the needs-you badge and open via openGroupChat', () => { diff --git a/website/docs/user-guide/bot-mode.md b/website/docs/user-guide/bot-mode.md index 322ff9df7d..3eda420575 100644 --- a/website/docs/user-guide/bot-mode.md +++ b/website/docs/user-guide/bot-mode.md @@ -75,12 +75,14 @@ Routines are plain [Hermes cron jobs](./features/cron.md) namespaced `[bot:` session, so room context survives like any other conversation. - **Not every Bot replies to every message.** Speaking is each member's own choice — a Bot replies only when it has something new to add and passes otherwise, and @-mentioning specific members scopes the round to them. Expect the members you addressed (or whoever has something to say) to speak, and the rest to stay quiet.