diff --git a/changelog.d/276-275-friend-dm-polish.fixed.md b/changelog.d/276-275-friend-dm-polish.fixed.md new file mode 100644 index 000000000..3649af748 --- /dev/null +++ b/changelog.d/276-275-friend-dm-polish.fixed.md @@ -0,0 +1 @@ +Friend-DM polish: a hidden plugin tool no longer flips between "Tool added" and "Tool removed" on every turn; Bots call the Main Bot by one name; a friend's Bot is no longer offered as a Main Bot ([#276](https://github.com/ThinkFlowLab/FlyBot/issues/276)); a friend's typed line has one short header (the person, and "via " only when that Bot is known), and People with access keeps the friend's display name instead of the lowercase handle ([#275](https://github.com/ThinkFlowLab/FlyBot/issues/275)). diff --git a/core/client.js b/core/client.js index 6176a3ae5..843a8196d 100644 --- a/core/client.js +++ b/core/client.js @@ -4975,21 +4975,24 @@ function remoteSpeaker(source, roster) { color: member?.color, avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : void 0) }, roster); - return { bot, member, name, handle, chatId: chat ? currentPart(chat) : void 0 }; + const carrier = cleanCarrier(chat?.name ?? member?.name); + return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : void 0 }; } +var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { - const { bot, member, name, handle, chatId } = remoteSpeaker(source, roster); + const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster); const files = Array.isArray(source.attachments) ? source.attachments : []; const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; if (person) { return (0, import_react7.createElement)( "div", { className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle }, + // One short header: the person, and the Bot that carried the line only when it is known (#275). (0, import_react7.createElement)( "span", - { className: "bt-group-name" }, + { className: "bt-group-name", title: handle }, person, - (0, import_react7.createElement)("span", { className: "bt-remote-handle", title: handle }, t("via {name}", { name })) + carrier ? (0, import_react7.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null ), (0, import_react7.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react7.createElement)("span", { className: "bt-lead", title: person }, (0, import_react7.createElement)(PersonMark)) }), files.length ? (0, import_react7.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null @@ -12052,7 +12055,8 @@ function GeneralPage({ roster, actions, run }) { function MainBotsSection({ roster, actions, run }) { const mains = (roster.mainBotIds ?? []).map((id) => roster.byId[id]).filter(Boolean); const [first, ...others] = mains; - const candidates = roster.bots.filter((bot) => !isMainOf(roster, bot.id)); + const own = roster.bots.filter((bot) => !bot.remote); + const candidates = own.filter((bot) => !isMainOf(roster, bot.id)); return (0, import_react29.createElement)( SetSection, { title: t("Main Bot") }, @@ -12067,7 +12071,7 @@ function MainBotsSection({ roster, actions, run }) { "aria-label": t("Main Bot"), value: first?.id ?? "", onChange: (event) => run(actions.setMain(event.target.value, true, first ? { replace: first.id } : {})) - }, first ? null : (0, import_react29.createElement)("option", { value: "" }, t("Choose a Bot…")), roster.bots.map((bot) => (0, import_react29.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) + }, first ? null : (0, import_react29.createElement)("option", { value: "" }, t("Choose a Bot…")), own.map((bot) => (0, import_react29.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) ), others.map((bot) => (0, import_react29.createElement)( SetRow, diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 1a3218c8c..8a2c7d690 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -221,19 +221,25 @@ export function remoteSpeaker(source, roster) { handle, displayName: name, color: member?.color, avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : undefined), }, roster) - return { bot, member, name, handle, chatId: chat ? currentPart(chat) : undefined } + // The carrying Bot's own name, when this home knows that Bot; never the sender label the + // plugin builds for the model ("X (typed via @…, Bot Social)", #275). + const carrier = cleanCarrier(chat?.name ?? member?.name) + return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : undefined } } +const cleanCarrier = value => (typeof value === 'string' && value.trim() && !/[@()]/.test(value) ? value.trim() : '') + export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { - const { bot, member, name, handle, chatId } = remoteSpeaker(source, roster) + const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster) const files = Array.isArray(source.attachments) ? source.attachments : [] // The friend typed this line themselves (Bot Social attested it; the host set `typedBy`): it is the // person's, with their name and a person mark, and the Bot that carried it only named as the route. const person = typeof source.remote.typedBy === 'string' ? source.remote.typedBy : '' if (person) { return h('div', { className: 'bt-group-msg bt-person-msg bt-remote-msg', 'data-person': person, 'data-remote': handle }, - h('span', { className: 'bt-group-name' }, person, - h('span', { className: 'bt-remote-handle', title: handle }, t('via {name}', { name }))), + // One short header: the person, and the Bot that carried the line only when it is known (#275). + h('span', { className: 'bt-group-name', title: handle }, person, + carrier ? h('span', { className: 'bt-remote-handle' }, t('via {name}', { name: carrier })) : null), h(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: h('span', { className: 'bt-lead', title: person }, h(PersonMark)) }), files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null) } diff --git a/core/src/client/settings.js b/core/src/client/settings.js index 2e35f94f9..68bcf5826 100644 --- a/core/src/client/settings.js +++ b/core/src/client/settings.js @@ -72,7 +72,9 @@ function GeneralPage({ roster, actions, run }) { function MainBotsSection({ roster, actions, run }) { const mains = (roster.mainBotIds ?? []).map(id => roster.byId[id]).filter(Boolean) const [first, ...others] = mains - const candidates = roster.bots.filter(bot => !isMainOf(roster, bot.id)) + // Only the user's own Bots can hold the role; a friend's Bot is never offered (#276). + const own = roster.bots.filter(bot => !bot.remote) + const candidates = own.filter(bot => !isMainOf(roster, bot.id)) return h(SetSection, { title: t('Main Bot') }, h(SetRow, { label: t('Main Bot'), @@ -81,7 +83,7 @@ function MainBotsSection({ roster, actions, run }) { h('select', { className: 'bt-select', 'aria-label': t('Main Bot'), value: first?.id ?? '', onChange: event => run(actions.setMain(event.target.value, true, first ? { replace: first.id } : {})), - }, first ? null : h('option', { value: '' }, t('Choose a Bot…')), roster.bots.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name)))), + }, first ? null : h('option', { value: '' }, t('Choose a Bot…')), own.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name)))), others.map(bot => h(SetRow, { key: bot.id, label: bot.name, hint: t('Also a Main Bot.') }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => run(actions.setMain(bot.id, true, { primary: true })) }, t('Move to first')), h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => run(actions.setMain(bot.id, false)) }, t('Remove Main Bot role')))), diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index db5fa241a..e1bed694a 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -655,13 +655,17 @@ export function install(rt) { if (bot === undefined) return let known try { known = new Set(ctx.tools.schemas(agent).map(schema => schema.name)) } catch { return } + const current = masks.get(agent) + // schemas() lists what the Agent sees, so a tool this mask hides is missing from it: count + // it as known, or every other resolve lifts the mask and the next sets it again, and the + // chat shows "Tool added" / "Tool removed" on each turn (#276). + for (const name of current?.deny ?? []) known.add(name) const deny = [...gatedTools.values()].filter(def => known.has(def.name) && !visible(def, bot.id)).map(def => def.name).sort() const key = deny.join(',') - const current = masks.get(agent) if ((current?.key ?? '') === key) return try { current?.dispose?.() - masks.set(agent, { key, dispose: deny.length ? agent.ctx.tools.restrict({ deny }) : undefined }) + masks.set(agent, { key, deny, dispose: deny.length ? agent.ctx.tools.restrict({ deny }) : undefined }) } catch (error) { warn('flybot: tools stay listed for %s: %s', bot.name, error) } diff --git a/core/src/host/prompt.js b/core/src/host/prompt.js index 6bc0e99a2..2d9d70316 100644 --- a/core/src/host/prompt.js +++ b/core/src/host/prompt.js @@ -23,7 +23,8 @@ const TEAM_RULES = [ ].join('\n') const MAIN_RULES = [ - '## You are the Main Bot (Chief of Staff)', + '## You are the Main Bot', + '- The app calls you the user\'s Main Bot: use that term when you say what you are.', '- You run the user\'s Bot team: you decide who does what, create new Bots, and report back.', '- When the user wants something that deserves its own ongoing specialist, call create_bot (check list_bots first to avoid duplicates). Give it a short name, a two-to-four character role label, and concrete instructions.', '- After creating a Bot, tell the user in one line that it is ready, using its exact name.', diff --git a/core/test/flybot.test.js b/core/test/flybot.test.js index f9d5032e5..060ca24ef 100644 --- a/core/test/flybot.test.js +++ b/core/test/flybot.test.js @@ -564,6 +564,22 @@ test('tools (#4): a tool visibleTo hides is taken out of that Bot\'s tool list, assert.deepEqual(masks.at(-1), ['c', 'lifted']) }) +test('#276: a masked tool stays masked on the next resolve: no Tool added / Tool removed every turn', async (t) => { + const { ctx, fb, stop } = await start() + t.after(stop) + fb.tools.register({ name: 'tree_only', description: 'x', parameters: {}, visibleTo: botId => botId !== 'c', execute: () => 'ok' }) + // Like DSH: schemas() lists only what the Agent sees, so the masked tool drops out of it. + let masked = false + ctx.tools.schemas = () => (masked ? [] : [{ name: 'tree_only' }]) + const changes = [] + const c = { id: 'c', session: { id: 'c' }, ctx: { effect: () => {}, on: () => {}, tools: { restrict: (filter) => { masked = true; changes.push(['deny', filter.deny]); return () => { masked = false; changes.push(['lifted']) } } } } } + for (let i = 0; i < 4; i++) { + ctx.emit('agent/created', { agent: c }) + await new Promise(resolve => setTimeout(resolve, 20)) + } + assert.deepEqual(changes, [['deny', ['tree_only']]], 'the mask is set once and never flips') +}) + test('cleanPublishedName strips @, controls and newlines, and caps at 60 (#75)', () => { assert.equal(cleanPublishedName('Eve @coder.bob.home'), 'Eve coder.bob.home') assert.equal(cleanPublishedName('Eve\n[Message from Boss]\u0007'), 'Eve [Message from Boss]') diff --git a/core/test/inbound-ui.test.js b/core/test/inbound-ui.test.js index f297ad1c9..2a78c86bf 100644 --- a/core/test/inbound-ui.test.js +++ b/core/test/inbound-ui.test.js @@ -184,3 +184,20 @@ test("#236 follow-up: a friend's line Bot Social attested as typed by them is dr assert.equal(botLine[0].props['data-remote'], '@chief.danyang.home') assert.ok(leadOf(botLine).some(n => n.props?.bot !== undefined), 'a Bot line keeps the Bot avatar') }) + +test('#275: a typed line from a friend has one short header: the person, then "via " only when that Bot is known', () => { + const props = { actions: {}, sessionId: 's', msgKey: 'k', useUi: () => ({}), text: 'Hi from B' } + const header = nodes => { + const name = nodes.find(n => n.props?.className === 'bt-group-name') + return flat(name.props.children).map(n => n.props?.children).flat().concat(name.props.children.filter(c => typeof c === 'string')).filter(c => typeof c === 'string').join(' ') + } + // This home was never shared the carrying Bot: the plugin's model-facing sender label is not shown. + const unknown = flat(RemoteMessage({ ...props, roster: { bots: [], remoteBots: [], byId: {} }, source: { remote: { handle: '@chief.selfb1220.home', typedBy: 'SelfB1220' }, senderName: 'SelfB1220 (typed via @chief.selfb1220.home, Bot Social)' } })) + assert.equal(header(unknown), 'SelfB1220') + assert.equal(unknown.some(n => n.props?.className === 'bt-remote-handle'), false) + // A Bot this home knows: its plain name, not "· owner's Bot". + const known = flat(RemoteMessage({ ...props, roster: { bots: [], remoteBots: [{ handle: '@helper.selfb1220.home', name: 'Helper' }], byId: {} }, source: { remote: { handle: '@helper.selfb1220.home', typedBy: 'SelfB1220' }, senderName: 'Helper' } })) + const via = known.find(n => n.props?.className === 'bt-remote-handle') + assert.ok(via) + assert.doesNotMatch(header(known), /typed via|Bot Social|'s Bot/) +}) diff --git a/core/test/looks.test.js b/core/test/looks.test.js index 9405d79ae..0edd548ed 100644 --- a/core/test/looks.test.js +++ b/core/test/looks.test.js @@ -54,6 +54,10 @@ test('Bot settings is the only settings entry; nothing is called Team settings', assert.match(text('sidebar.js'), /bt-row-settings/) assert.match(text('header.js'), /setAttribute\('aria-label', composerLabel\)/) assert.match(text('settings.js'), /filter\(bot => !bot\.remote\)/) + // #276: only the user's own Bots are offered as a Main Bot, in both selects. + assert.match(text('settings.js'), /const own = roster\.bots\.filter\(bot => !bot\.remote\)/) + assert.match(text('settings.js'), /const candidates = own\.filter/) + assert.match(text('settings.js'), /own\.map\(bot => h\('option'/) // A group chat's entries open its own settings the same way. assert.match(text('context-menu.js'), /\[t\('Group settings'\), GearIcon, \(\) => actions\.openSettings\('groups', room\.id\)\]/) assert.ok(!text('mark.js').includes('WhaleLogo')) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index a3b41c34c..0269c4abd 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -119,4 +119,4 @@ A discarded `.catch(() => {})` in the client sources either starts with `void` ( `npm test` in `core/` (it also fails when `client.js` is stale). - A friend's line that Bot Social attests the friend typed themselves (#227) is drawn as the person: `AssistantCell` looks the turn up in `roster.remoteTypedTurns` (`typedBy` in `src/client/text.js`) and renders `PersonMessage` — the person's name and a person mark (`.bt-person-mark`) — instead of the Bot's `Bubbles`. The name comes from the roster (the authenticated Bot Social `from`), never from message text. -- The same attested line sent to one of my own Bots outside that friend's chat (a friend's Chief typing to my Helper) arrives as a remote message whose source has `remote.typedBy`; `RemoteMessage` (`cells.js`) then draws it as the person (name, person mark, `via `) instead of the friend's Bot avatar and handle. Without `typedBy` it keeps the Bot rendering (#233 follow-up). +- The same attested line sent to one of my own Bots outside that friend's chat (a friend's Chief typing to my Helper) arrives as a remote message whose source has `remote.typedBy`; `RemoteMessage` (`cells.js`) then draws it as the person (name, person mark, `via `) instead of the friend's Bot avatar and handle. Without `typedBy` it keeps the Bot rendering (#233 follow-up). The `via` name is the carrying Bot's plain name when this home knows that Bot (its chat or `roster.remoteBots`), and is left out when it does not; the model-facing sender label (`X (typed via @…, Bot Social)`) and the handle-derived `· owner's Bot` never reach that header (#275). Settings → Bots offers only the user's own Bots as a Main Bot, in the first-Main-Bot and Add a Main Bot selects (#276). diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 57cf6d40e..d91c4b900 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -38,6 +38,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | --- | --- | | `core/index.js` | Server entry. | | `core/src/host/` | Host-side helpers (non-hooks). | +| `core/src/host/prompt.js` | The Bot system prompt; a Main Bot's section is headed `You are the Main Bot` and uses that one term, the one the UI shows (#276). | | `core/src/host/room-membership.js` | Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, room-only remotes without a person-grant, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`); a face-room local Bot is `viaCode` (brought by number), `evictUnshared` skips face rooms (membership is the server's), and a Bot that returns to the member list drops the empty local `left` line (#236). | | `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (bare name, the picker label `'s Chief` / ` · 's Bot`, ` · `, handle), local and friend's Bots told apart. `faceMentions().handles` is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and `mentionedByHandle` resolves a received list to my Bots (`known` counts handles the room has). | | `core/src/host/reach.js` | Per-Bot grants and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#90); `rt.setGrants`, `rt.hasGrant`, `rt.hasAnyGrant`. A local Bot refused toward a friend's Bot is told that it is not shared with that friend and where to share it (Sharing, #273). | diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index d42040940..ae922272d 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -50,6 +50,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 2. Slots/registry must keep the client build green (`npm run build`). 3. Types in `flybot.d.ts` stay in sync with hooks/slots. 4. Plugins report a host-visible failure with `flybot.warn` (Settings → Feedback / `diagnostics.jsonl`). They must not depend on `ctx.logger`: DSH may export none, and `flybot.warn` delegates to `rt.warn`. + A tool whose `visibleTo` says no is taken out of that Bot's list with `agent.ctx.tools.restrict({ deny })` (`hidePluginTools`). DSH's `tools.schemas(agent)` lists only what the Agent sees, so the tools the current mask hides count as known when the mask is recomputed; otherwise every other resolve lifts the mask and the chat shows `Tool added` / `Tool removed` on each turn (#276). 5. Reach (#90): the core's owner policy (`reach.js`) is not a middleware. `hooks.js` runs it before the `bot.send` chain, on approval release and on every deliver that carries a sender or a remote origin; a plugin's `allow` or `bypass` never skips it, and plugins see its denial as `{ action: 'deny', by: 'owner-policy' }`. Plugins read grants through `flybot.bot.hasGrant` / `hasAnyGrant`, `BotView.granted`, `flybot.grants` and the `bot.grants` event. A group's mention receipt (`mentionReceipt`) turns a not-shared refusal (`cannot message their Bots`, #273) into its short invite-or-add hint. 6. `flybot.roomApprovals.setProvider` lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode. 7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the `handle` field. Optional look fields (`color`, `avatar` object, `avatarUpdatedAt`) pass through when present so remote chats, group lists, and headers can render a peer's chosen look; missing look stays handle-seeded. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 0cf535052..9297cf8e2 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -111,7 +111,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - **#136:** A taken `displayName` at onboard retries `Name2` … rather than refusing to start. Your handle is shown in Add friend (#181). - **#137:** Owner-chosen Bot avatar/color is sent with MCP `update_bot_profile { bot_id?, display_name?, avatar?, color? }` (REST `PATCH /v1/bots/{id}/profile`) whenever a local Bot's look changes and on startup if not yet synced. `avatar` is a preset id, a single emoji, or `data:image/(png|jpeg|webp);base64` ≤ 64KiB; `color` is `#rrggbb`. Remote look is read from `list_remote_bots` / `list_friends` peer / `list_bot_grants` / face-room `members[]` (`avatar`, `avatar_kind`, `color`, `avatar_updated_at`) and shown in the main sidebar list, group member lists, waiting tiles, the picker, and chat headers. Cache key is `avatar_updated_at`; missing look falls back to the handle-seeded palette. Face-room events carry no look — `members[]` is the source. All fields are optional so an older Bot Social is unchanged. -- **#138 / #161 / #164:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`. `grantPersonView` / `normalizeGrant` read Bot Social's object-shaped `grantee` (`handle`, `owner_display_name`) and never `String()` it (`[object Object]` → `@objectobject`). Core grants store a canonical `@handle` (never decorated `Chief@…`). Friends' Bots drops reverse-friends of invites this home created (`outgoingPeerHandles`) and room-only remotes, but a handle the joiner explicitly received (`inboundBots[handle]`) always shows (#178): the talk-through grant on the joiner's own Bot names the shared Bot as grantee and must not hide it, nor appear in that Bot's People with access (`isTalkThroughGrant`: same relationship, Bot in `via`, grantee in `inboundBots`). Join copy names the person (`owner_display_name`), not the shared Bot. `social/join-invite` marks the offered Bot inbound **before** its first roster refresh (#228): the grant sync that refresh starts then already sees the talk-through grant as talk-through, so no core grant on the joiner's Bot names the inviter's Bot and the re-filter does not drop the shared Bot until a later refresh (it shows with the success toast). A late grant re-filter of an older roster fetch never overwrites a newer one (fetch counter, not a timestamp). Fetches are numbered when they ask, not when they land (#224): a `list_remote_bots` answer that lands after a newer one (the background refresh `hide-remote` starts, a slow link) is dropped, so it cannot hide a re-shared Bot again; the grant re-filter applies the same answer again and does not count as a second absent fetch toward forgetting a local hide. An open **People with access** re-reads `social/access` every 5 s while visible, and `social/access` syncs only that Bot's grants (`syncOutgoingGrants({ only })`). Bot Social itself still records the talk-through side of the relationship as a grant on the joiner's Bot (`list_bot_grants`); FlyBot filters it on both sides (#161, #178). On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only. +- **#138 / #161 / #164:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`; a label `grantPersonView` already made keeps its display name when `syncOutgoingGrants` reads the normalized grant again (#275), and only a bare handle falls back to the lowercase home. `grantPersonView` / `normalizeGrant` read Bot Social's object-shaped `grantee` (`handle`, `owner_display_name`) and never `String()` it (`[object Object]` → `@objectobject`). Core grants store a canonical `@handle` (never decorated `Chief@…`). Friends' Bots drops reverse-friends of invites this home created (`outgoingPeerHandles`) and room-only remotes, but a handle the joiner explicitly received (`inboundBots[handle]`) always shows (#178): the talk-through grant on the joiner's own Bot names the shared Bot as grantee and must not hide it, nor appear in that Bot's People with access (`isTalkThroughGrant`: same relationship, Bot in `via`, grantee in `inboundBots`). Join copy names the person (`owner_display_name`), not the shared Bot. `social/join-invite` marks the offered Bot inbound **before** its first roster refresh (#228): the grant sync that refresh starts then already sees the talk-through grant as talk-through, so no core grant on the joiner's Bot names the inviter's Bot and the re-filter does not drop the shared Bot until a later refresh (it shows with the success toast). A late grant re-filter of an older roster fetch never overwrites a newer one (fetch counter, not a timestamp). Fetches are numbered when they ask, not when they land (#224): a `list_remote_bots` answer that lands after a newer one (the background refresh `hide-remote` starts, a slow link) is dropped, so it cannot hide a re-shared Bot again; the grant re-filter applies the same answer again and does not count as a second absent fetch toward forgetting a local hide. An open **People with access** re-reads `social/access` every 5 s while visible, and `social/access` syncs only that Bot's grants (`syncOutgoingGrants({ only })`). Bot Social itself still records the talk-through side of the relationship as a grant on the joiner's Bot (`list_bot_grants`); FlyBot filters it on both sides (#161, #178). On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only. - **#139:** **Remove this Bot** asks once, inline in the friend Bot's settings (`Remove Helper? Bob can share it again.` · **Remove** · **Keep**, #230; never the browser `confirm()`), and the friend's **Auto-send** checkbox names the friend it covers (`Auto-send to Bob`) and calls `decline_bot_grant { grant_id, bot_id }` (`DELETE /v1/remote-bots/{grantId}`) so the owner's Manage access drops it. `grant_not_found` is treated as already removed. An older server (404 on the tool/endpoint) falls back to the local hide. Re-joining after a decline may return `regranted: true` on the existing friendship; live access still answers `409 relationship_exists`; a spent invite is `410 invite_unavailable`. Copy stays English (“You're already friends…”, “This invite was already used.”). Re-joining an existing friendship unhides only that invite's Bot/peer home. - **#140:** Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You. - **#136 (suffix):** Onboard retries truncate the sanitized handle body so `Name2` / `Name3` still change within the 32-character `homeHandleOf` limit. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 7cb817bde..017551e16 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -778,6 +778,16 @@ diff --git a/src/host/models.js b/src/host/models.js diff --git a/src/host/prompt.js b/src/host/prompt.js --- a/src/host/prompt.js +++ b/src/host/prompt.js +@@ -23,7 +23,8 @@ const TEAM_RULES = [ + ].join('\n') + + const MAIN_RULES = [ +- '## You are the Main Bot (Chief of Staff)', ++ '## You are the Main Bot', ++ '- The app calls you the user\'s Main Bot: use that term when you say what you are.', + '- You run the user\'s Bot team: you decide who does what, create new Bots, and report back.', + '- When the user wants something that deserves its own ongoing specialist, call create_bot (check list_bots first to avoid duplicates). Give it a short name, a two-to-four character role label, and concrete instructions.', + '- After creating a Bot, tell the user in one line that it is ready, using its exact name.', @@ -45,8 +45,10 @@ export function install(rt) { if (bot !== undefined) { return [ diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 759d0a5e8..38b75276b 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -220,7 +220,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js } function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { -@@ -180,6 +180,85 @@ function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi }) { +@@ -180,6 +180,91 @@ function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi }) { h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead })) } @@ -243,19 +243,25 @@ diff --git a/src/client/cells.js b/src/client/cells.js + handle, displayName: name, color: member?.color, + avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : undefined), + }, roster) -+ return { bot, member, name, handle, chatId: chat ? currentPart(chat) : undefined } ++ // The carrying Bot's own name, when this home knows that Bot; never the sender label the ++ // plugin builds for the model ("X (typed via @…, Bot Social)", #275). ++ const carrier = cleanCarrier(chat?.name ?? member?.name) ++ return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : undefined } +} + ++const cleanCarrier = value => (typeof value === 'string' && value.trim() && !/[@()]/.test(value) ? value.trim() : '') ++ +export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { -+ const { bot, member, name, handle, chatId } = remoteSpeaker(source, roster) ++ const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster) + const files = Array.isArray(source.attachments) ? source.attachments : [] + // The friend typed this line themselves (Bot Social attested it; the host set `typedBy`): it is the + // person's, with their name and a person mark, and the Bot that carried it only named as the route. + const person = typeof source.remote.typedBy === 'string' ? source.remote.typedBy : '' + if (person) { + return h('div', { className: 'bt-group-msg bt-person-msg bt-remote-msg', 'data-person': person, 'data-remote': handle }, -+ h('span', { className: 'bt-group-name' }, person, -+ h('span', { className: 'bt-remote-handle', title: handle }, t('via {name}', { name }))), ++ // One short header: the person, and the Bot that carried the line only when it is known (#275). ++ h('span', { className: 'bt-group-name', title: handle }, person, ++ carrier ? h('span', { className: 'bt-remote-handle' }, t('via {name}', { name: carrier })) : null), + h(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: h('span', { className: 'bt-lead', title: person }, h(PersonMark)) }), + files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null) + } @@ -1590,7 +1596,7 @@ diff --git a/test/create-bot.test.js b/test/create-bot.test.js diff --git a/test/looks.test.js b/test/looks.test.js --- a/test/looks.test.js +++ b/test/looks.test.js -@@ -40,7 +40,20 @@ test('Bot settings is the only settings entry; nothing is called Team settings', +@@ -40,7 +40,24 @@ test('Bot settings is the only settings entry; nothing is called Team settings', assert.match(text('palette.js'), /title: t\('Bot settings'\), run: \(\) => actions\.openSettings\('general'\)/) // One Bot's own entries open that Bot, so they read Edit Bot instead. assert.match(text('context-menu.js'), /\[t\('Edit Bot'\), GearIcon, \(\) => actions\.openSettings\('bots', bot\.id\)\]/) @@ -1609,6 +1615,10 @@ diff --git a/test/looks.test.js b/test/looks.test.js + assert.match(text('sidebar.js'), /bt-row-settings/) + assert.match(text('header.js'), /setAttribute\('aria-label', composerLabel\)/) + assert.match(text('settings.js'), /filter\(bot => !bot\.remote\)/) ++ // #276: only the user's own Bots are offered as a Main Bot, in both selects. ++ assert.match(text('settings.js'), /const own = roster\.bots\.filter\(bot => !bot\.remote\)/) ++ assert.match(text('settings.js'), /const candidates = own\.filter/) ++ assert.match(text('settings.js'), /own\.map\(bot => h\('option'/) // A group chat's entries open its own settings the same way. assert.match(text('context-menu.js'), /\[t\('Group settings'\), GearIcon, \(\) => actions\.openSettings\('groups', room\.id\)\]/) assert.ok(!text('mark.js').includes('WhaleLogo')) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 8f67a2b2d..e46b9323d 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -2725,7 +2725,7 @@ diff --git a/client.js b/client.js key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,61 +3940,160 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,61 +3940,163 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use whole: true }); }), @@ -2820,21 +2820,24 @@ diff --git a/client.js b/client.js + color: member?.color, + avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : void 0) + }, roster); -+ return { bot, member, name, handle, chatId: chat ? currentPart(chat) : void 0 }; ++ const carrier = cleanCarrier(chat?.name ?? member?.name); ++ return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : void 0 }; +} ++var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; +function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { -+ const { bot, member, name, handle, chatId } = remoteSpeaker(source, roster); ++ const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster); + const files = Array.isArray(source.attachments) ? source.attachments : []; + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + if (person) { + return (0, import_react7.createElement)( + "div", + { className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle }, ++ // One short header: the person, and the Bot that carried the line only when it is known (#275). + (0, import_react7.createElement)( + "span", -+ { className: "bt-group-name" }, ++ { className: "bt-group-name", title: handle }, + person, -+ (0, import_react7.createElement)("span", { className: "bt-remote-handle", title: handle }, t("via {name}", { name })) ++ carrier ? (0, import_react7.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null + ), + (0, import_react7.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react7.createElement)("span", { className: "bt-lead", title: person }, (0, import_react7.createElement)(PersonMark)) }), + files.length ? (0, import_react7.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null @@ -8894,11 +8897,14 @@ diff --git a/client.js b/client.js ) ) ]; -@@ -10649,52 +10649,52 @@ function MainBotsSection({ roster, actions, run }) { +@@ -10648,53 +10648,54 @@ function GeneralPage({ roster, actions, run }) { + function MainBotsSection({ roster, actions, run }) { const mains = (roster.mainBotIds ?? []).map((id) => roster.byId[id]).filter(Boolean); const [first, ...others] = mains; - const candidates = roster.bots.filter((bot) => !isMainOf(roster, bot.id)); +- const candidates = roster.bots.filter((bot) => !isMainOf(roster, bot.id)); - return (0, import_react28.createElement)( ++ const own = roster.bots.filter((bot) => !bot.remote); ++ const candidates = own.filter((bot) => !isMainOf(roster, bot.id)); + return (0, import_react29.createElement)( SetSection, { title: t("Main Bot") }, @@ -8916,7 +8922,7 @@ diff --git a/client.js b/client.js value: first?.id ?? "", onChange: (event) => run(actions.setMain(event.target.value, true, first ? { replace: first.id } : {})) - }, first ? null : (0, import_react28.createElement)("option", { value: "" }, t("Choose a Bot…")), roster.bots.map((bot) => (0, import_react28.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) -+ }, first ? null : (0, import_react29.createElement)("option", { value: "" }, t("Choose a Bot…")), roster.bots.map((bot) => (0, import_react29.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) ++ }, first ? null : (0, import_react29.createElement)("option", { value: "" }, t("Choose a Bot…")), own.map((bot) => (0, import_react29.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) ), - others.map((bot) => (0, import_react28.createElement)( + others.map((bot) => (0, import_react29.createElement)( diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index ffde60a80..d8571ed9a 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -162,6 +162,26 @@ diff --git a/src/client/settings.js b/src/client/settings.js const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon } function SetSection({ title, children }) { +@@ -72,7 +72,9 @@ function GeneralPage({ roster, actions, run }) { + function MainBotsSection({ roster, actions, run }) { + const mains = (roster.mainBotIds ?? []).map(id => roster.byId[id]).filter(Boolean) + const [first, ...others] = mains +- const candidates = roster.bots.filter(bot => !isMainOf(roster, bot.id)) ++ // Only the user's own Bots can hold the role; a friend's Bot is never offered (#276). ++ const own = roster.bots.filter(bot => !bot.remote) ++ const candidates = own.filter(bot => !isMainOf(roster, bot.id)) + return h(SetSection, { title: t('Main Bot') }, + h(SetRow, { + label: t('Main Bot'), +@@ -81,7 +81,7 @@ function MainBotsSection({ roster, actions, run }) { + h('select', { + className: 'bt-select', 'aria-label': t('Main Bot'), value: first?.id ?? '', + onChange: event => run(actions.setMain(event.target.value, true, first ? { replace: first.id } : {})), +- }, first ? null : h('option', { value: '' }, t('Choose a Bot…')), roster.bots.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name)))), ++ }, first ? null : h('option', { value: '' }, t('Choose a Bot…')), own.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name)))), + others.map(bot => h(SetRow, { key: bot.id, label: bot.name, hint: t('Also a Main Bot.') }, + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => run(actions.setMain(bot.id, true, { primary: true })) }, t('Move to first')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => run(actions.setMain(bot.id, false)) }, t('Remove Main Bot role')))), @@ -112,8 +112,8 @@ function BotsPage({ roster, actions, run }) { ? h(CreateBot, { key: 'create', roster, actions, inline: true, onBack: () => setCreating(false), onCreated: bot => actions.openSettings('bots', bot.id) }) : h('div', { key: 'new', className: 'bt-actions' }, diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index db5fa241a..e1bed694a 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -655,13 +655,17 @@ export function install(rt) { if (bot === undefined) return let known try { known = new Set(ctx.tools.schemas(agent).map(schema => schema.name)) } catch { return } + const current = masks.get(agent) + // schemas() lists what the Agent sees, so a tool this mask hides is missing from it: count + // it as known, or every other resolve lifts the mask and the next sets it again, and the + // chat shows "Tool added" / "Tool removed" on each turn (#276). + for (const name of current?.deny ?? []) known.add(name) const deny = [...gatedTools.values()].filter(def => known.has(def.name) && !visible(def, bot.id)).map(def => def.name).sort() const key = deny.join(',') - const current = masks.get(agent) if ((current?.key ?? '') === key) return try { current?.dispose?.() - masks.set(agent, { key, dispose: deny.length ? agent.ctx.tools.restrict({ deny }) : undefined }) + masks.set(agent, { key, deny, dispose: deny.length ? agent.ctx.tools.restrict({ deny }) : undefined }) } catch (error) { warn('flybot: tools stay listed for %s: %s', bot.name, error) } diff --git a/patches/ds-bot/files/test/flybot.test.js b/patches/ds-bot/files/test/flybot.test.js index f9d5032e5..060ca24ef 100644 --- a/patches/ds-bot/files/test/flybot.test.js +++ b/patches/ds-bot/files/test/flybot.test.js @@ -564,6 +564,22 @@ test('tools (#4): a tool visibleTo hides is taken out of that Bot\'s tool list, assert.deepEqual(masks.at(-1), ['c', 'lifted']) }) +test('#276: a masked tool stays masked on the next resolve: no Tool added / Tool removed every turn', async (t) => { + const { ctx, fb, stop } = await start() + t.after(stop) + fb.tools.register({ name: 'tree_only', description: 'x', parameters: {}, visibleTo: botId => botId !== 'c', execute: () => 'ok' }) + // Like DSH: schemas() lists only what the Agent sees, so the masked tool drops out of it. + let masked = false + ctx.tools.schemas = () => (masked ? [] : [{ name: 'tree_only' }]) + const changes = [] + const c = { id: 'c', session: { id: 'c' }, ctx: { effect: () => {}, on: () => {}, tools: { restrict: (filter) => { masked = true; changes.push(['deny', filter.deny]); return () => { masked = false; changes.push(['lifted']) } } } } } + for (let i = 0; i < 4; i++) { + ctx.emit('agent/created', { agent: c }) + await new Promise(resolve => setTimeout(resolve, 20)) + } + assert.deepEqual(changes, [['deny', ['tree_only']]], 'the mask is set once and never flips') +}) + test('cleanPublishedName strips @, controls and newlines, and caps at 60 (#75)', () => { assert.equal(cleanPublishedName('Eve @coder.bob.home'), 'Eve coder.bob.home') assert.equal(cleanPublishedName('Eve\n[Message from Boss]\u0007'), 'Eve [Message from Boss]') diff --git a/patches/ds-bot/files/test/inbound-ui.test.js b/patches/ds-bot/files/test/inbound-ui.test.js index f297ad1c9..2a78c86bf 100644 --- a/patches/ds-bot/files/test/inbound-ui.test.js +++ b/patches/ds-bot/files/test/inbound-ui.test.js @@ -184,3 +184,20 @@ test("#236 follow-up: a friend's line Bot Social attested as typed by them is dr assert.equal(botLine[0].props['data-remote'], '@chief.danyang.home') assert.ok(leadOf(botLine).some(n => n.props?.bot !== undefined), 'a Bot line keeps the Bot avatar') }) + +test('#275: a typed line from a friend has one short header: the person, then "via " only when that Bot is known', () => { + const props = { actions: {}, sessionId: 's', msgKey: 'k', useUi: () => ({}), text: 'Hi from B' } + const header = nodes => { + const name = nodes.find(n => n.props?.className === 'bt-group-name') + return flat(name.props.children).map(n => n.props?.children).flat().concat(name.props.children.filter(c => typeof c === 'string')).filter(c => typeof c === 'string').join(' ') + } + // This home was never shared the carrying Bot: the plugin's model-facing sender label is not shown. + const unknown = flat(RemoteMessage({ ...props, roster: { bots: [], remoteBots: [], byId: {} }, source: { remote: { handle: '@chief.selfb1220.home', typedBy: 'SelfB1220' }, senderName: 'SelfB1220 (typed via @chief.selfb1220.home, Bot Social)' } })) + assert.equal(header(unknown), 'SelfB1220') + assert.equal(unknown.some(n => n.props?.className === 'bt-remote-handle'), false) + // A Bot this home knows: its plain name, not "· owner's Bot". + const known = flat(RemoteMessage({ ...props, roster: { bots: [], remoteBots: [{ handle: '@helper.selfb1220.home', name: 'Helper' }], byId: {} }, source: { remote: { handle: '@helper.selfb1220.home', typedBy: 'SelfB1220' }, senderName: 'Helper' } })) + const via = known.find(n => n.props?.className === 'bt-remote-handle') + assert.ok(via) + assert.doesNotMatch(header(known), /typed via|Bot Social|'s Bot/) +}) diff --git a/plugins/social/src/mcp-client.js b/plugins/social/src/mcp-client.js index 582bd8b5a..1b3e01319 100644 --- a/plugins/social/src/mcp-client.js +++ b/plugins/social/src/mcp-client.js @@ -232,7 +232,9 @@ export function grantPersonView(grant) { || handleText(grant?.handle) const member = memberHandleOf(raw) const home = personHomeOf(raw) - const rawName = personNameOf(grant?.name) || personNameOf(peer) || personNameOf(grant) + // A label this module already made ("SelfB1220 (@selfb1220.home)") keeps its name when the + // grant is synced again; only a bare handle falls back to the home (#275). + const rawName = (personNameOf(grant?.name) || personNameOf(peer) || personNameOf(grant)).replace(/\s*\(@[^()\s]+\)\s*$/, '') const nameLooksLikeHandle = /@/.test(rawName) || /[a-z0-9]+@[a-z0-9.]/i.test(rawName) const person = (!nameLooksLikeHandle && rawName) ? rawName.replace(/\s+/g, ' ').trim().slice(0, 60) : titleHome(home) const label = home ? `${person} (${home})` : person diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 4c48892e6..b6dfee07d 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -3979,7 +3979,8 @@ test('social/access labels an object-shaped list_bot_grants grantee, not @object const row = access.grants.find(g => g.home === '@bob.home' || g.grantee === '@bob.home' || /coder\.bob/.test(String(g.grantee))) assert.ok(row, `expected a Bob grant, got ${JSON.stringify(access.grants)}`) assert.equal(row.home, '@bob.home') - assert.equal(row.name, 'bob (@bob.home)') + // The owner's display name from Bot Social, not the handle in lowercase (#275). + assert.equal(row.name, 'Bob (@bob.home)') assert.doesNotMatch(String(row.name), /object/i) assert.doesNotMatch(String(row.grantee), /object/i) }) @@ -4254,6 +4255,16 @@ test('#136 / #138: grant labels use the person home, not a raw member handle', ( assert.equal(normalized.grantee, '@chief.bob.home', 'core grants need a canonical @handle, not decorated text') }) +test('#275: a grant synced twice keeps the friend\'s display name, not the lowercase handle', () => { + const once = normalizeGrant({ grant_id: 'g1', grantee: { handle: '@chief.selfb1220.home', display_name: 'Chief', owner_display_name: 'SelfB1220' }, relationship_id: 'r1' }) + assert.equal(once.name, 'SelfB1220 (@selfb1220.home)') + // syncOutgoingGrants reads the normalized grant again. + assert.equal(grantPersonView(once).label, 'SelfB1220 (@selfb1220.home)') + assert.equal(grantPersonView(once).person, 'SelfB1220') + // A bare handle still falls back to the home. + assert.equal(grantPersonView({ grantee: '@chief.selfb1220.home', name: '@chief.selfb1220.home' }).label, 'selfb1220 (@selfb1220.home)') +}) + test('grantPersonView reads handle and owner name from a Bot Social grantee object', () => { const peer = { bot_id: 'bot_x',