From c46d5c71efef1a2c1e29e91ea01d0808181bbf69 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:44:53 +0800 Subject: [PATCH] host: push an unnamed group's rename to the guests already seated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `followMemberNames` recomputes the title of a room without a chosen name when a Bot in it is renamed or removed, and retitles the room locally. It pushed nothing, so a guest already seated kept the name it was given at join until the next chat line — and a quiet group kept it indefinitely. An explicit rename already sends one `join` (#1949); a member rename is the same membership change and now sends that same envelope. The `next === room.name` guard above is the "the name really moved" condition, so a rename that leaves the computed name alone still sends nothing. Fixes #1968 --- changelog.d/1968-rename-push-auto.fixed.md | 1 + core/src/host/roster.js | 4 + core/test/remote-room.test.js | 103 ++++++++++++++++++ docs/subsystems/core-host.md | 2 +- patches/ds-bot/0002-flybot-api-host.patch | 6 +- patches/ds-bot/files/test/remote-room.test.js | 103 ++++++++++++++++++ 6 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 changelog.d/1968-rename-push-auto.fixed.md diff --git a/changelog.d/1968-rename-push-auto.fixed.md b/changelog.d/1968-rename-push-auto.fixed.md new file mode 100644 index 000000000..4cc4c2ea7 --- /dev/null +++ b/changelog.d/1968-rename-push-auto.fixed.md @@ -0,0 +1 @@ +Renaming a Bot now reaches the guests already seated in every unnamed group it sits in ([#1968](https://github.com/ThinkFlowLab/FlyBot/issues/1968)): the host recomputed the room's title from its members and retitled the room locally, but pushed nothing, so a shared group kept its old title on the other homes until the next chat line and a quiet group kept it indefinitely. The automatic rename now rides the same one `join` an explicit rename already used (#1949) and carries the new name, while a rename that leaves the computed title alone — a Bot outside the group, a group with a chosen name, a group with no remote guest — still sends nothing. diff --git a/core/src/host/roster.js b/core/src/host/roster.js index 8f296f715..902782aad 100644 --- a/core/src/host/roster.js +++ b/core/src/host/roster.js @@ -287,6 +287,10 @@ export function install(rt) { if (next === room.name) continue room.name = next await retitleRoom(room) + // The guests already seated hold the name they were given at join, and nothing else tells them: the + // same one `join` as an explicit rename (#1949). The guard above is the "the name really moved" + // condition, so a rename that leaves the computed name alone still sends nothing. + void rt.notifyRoomShare?.(room, { op: 'join' }) } } diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index e1a6be352..2afae16aa 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -3123,3 +3123,106 @@ test('#1949: a rename reaches the guests already seated, once, and a write-back assert.equal(guest().name, 'Renamed again', 'the one envelope carries the new name') assert.equal(guest().notice, 'Sit down', 'and the new notice') }) + +// A member's rename moves an unnamed group's title: `followMemberNames` recomputes it and retitles the +// Session. Nothing told the guests already seated, so they kept the name they were given at join until +// the next line or the next join envelope. A rename is a membership push exactly like the room rename +// #1949 covers, and it rides the same single `join` — once, and only when the computed name moved. +test('a member rename reaches the guest already seated, once', async (t) => { + const { rt, sent } = host(t, TEAM) + installRoster(rt) + rt.resolveAgent = async () => ({ session: 'session' }) + rt.chatOf = id => id + rt.ctx.sessionTitle = { rename: async () => {} } + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) + const shares = () => sent.filter(msg => msg.via === 'room-share') + assert.equal(shares().length, 1, 'the seat join itself pushed once') + sent.length = 0 + + await rt.updateBot('chief-b', { name: 'Chief C' }) + + assert.equal(room.name, 'Alice, Chief A, Chief C, Coder', 'the unnamed group follows its members') + const after = shares() + assert.equal(after.length, 1, 'the rename reaches the guest already seated') + const join = parseRoomShare(after[0].text) + assert.equal(join.op, 'join') + assert.equal(join.name, room.name, 'the envelope carries the new name') + assert.equal(sent.length, 1, 'one envelope, not one per guest record') +}) + +// Control 1: a group with a chosen name does not follow its members, so a rename is not a change to it. +test('a rename in a named group sends nothing', async (t) => { + const { rt, sent } = host(t, TEAM) + installRoster(rt) + rt.resolveAgent = async () => ({ session: 'session' }) + rt.chatOf = id => id + rt.ctx.sessionTitle = { rename: async () => {} } + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + assert.equal(room.named, true, 'the fixture group has a chosen name') + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) + sent.length = 0 + + await rt.updateBot('chief-b', { name: 'Chief C' }) + + assert.equal(room.name, 'War room', 'a chosen name does not move') + assert.equal(sent.filter(msg => msg.via === 'room-share').length, 0, 'nothing to tell the guest') +}) + +// Control 2: the name moved, but there is nobody outside this home to tell. +test('a rename in a group with no remote guest sends nothing', async (t) => { + const { rt, sent } = host(t, TEAM) + installRoster(rt) + rt.resolveAgent = async () => ({ session: 'session' }) + rt.chatOf = id => id + rt.ctx.sessionTitle = { rename: async () => {} } + await rt.load() + publish(rt) + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + + await rt.updateBot('chief-b', { name: 'Chief C' }) + + assert.equal(room.name, 'Alice, Chief A, Chief C', 'the unnamed group follows its members') + assert.equal(sent.filter(msg => msg.via === 'room-share').length, 0, 'no guest to tell') +}) + +// Control 3: a Bot that sits in no group is renamed — every room's name is what it was, so is the wire. +test('a rename that leaves the computed name alone sends nothing', async (t) => { + const { rt, sent } = host(t, TEAM) + installRoster(rt) + rt.resolveAgent = async () => ({ session: 'session' }) + rt.chatOf = id => id + rt.ctx.sessionTitle = { rename: async () => {} } + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) + sent.length = 0 + + await rt.updateBot('spec-a', { name: 'Writer C' }) + + assert.equal(room.name, 'Alice, Chief A, Chief B', 'a Bot outside the group does not rename it') + assert.equal(sent.filter(msg => msg.via === 'room-share').length, 0, 'the computed name did not move') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 1b671490f..a0a63c101 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -326,7 +326,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi ### Inbound shared rooms -- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden (#1167: the host sends that leave while the seat is still a live member, because reach and a room link's pairs only see current participants). The mirror then stays hidden: a later line is refused and a join that is not newer than that leave does not open it again; a newer join can. A hidden mirror sends nothing further. A projection is **named** from the envelope and **matched** by the transport (#873, #909): `applyInboundRoomShare` still shows `data.inviterName` (so a room carried here by a Chief's Bot reads after the person who invited it), but which room a share may touch is decided by `event.fromHome` / `event.fromHandle` — a share naming a room of mine while arriving from a home that room does not belong to is refused `not-inviter`, and the recorded `sharedFrom` identity only ever follows the transport, so an envelope can be displayed but never re-write whose room it is. Only `op: 'join` projects a room: a `line` envelope for an unknown remote room is refused `unknown-room` instead of opening one named by its own payload. A line keeps its own `who`, or the sender when it names nobody, never the inviter; its `authorId` follows the payload only when the handle traces to the transport sender, a remote seat of that room, or its inviter **from the home that invited the room** (#918 — the seat branch and the inviter branch now ask the same question) — otherwise the line is shown as the Bot that delivered it with `unverified: true`, and it is still kept so a forged claim cannot silence a message. The room match stays on the inviter handle alone, so a line presenting that handle from another home still lands and is only attributed to its sender: a room may match where a signature does not. Which room a share belongs to is recorded by a `join` alone; a `line` follows that record and never rewrites the inviter home or handle, or every message would make its sender the inviter. A room recorded before #873 may hold no inviter home at all, and then the only signature it keeps is the transport naming the sender itself (`claim === sender`); the next `join` records the home and the room stops being legacy. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. #904: the projection now records the host's side too. `recordInboundSharedRoom` keeps the guest's own Bot **and** one remote participant for the Bot that carried the join (`draft.inviterHandle`, with `admittedBy: 'ticket'` / `roomOnly` when a link seated it) — `invitedHandle` on this side names one of the guest's own Bots, so it is never recorded as a remote, and the friend path keeps its one remote. The rest of the host's room rides the `join` as `roster` and `notice`: `notifyRoomShare` sends `{ handle, home, name, ownerName }` for every Bot of this home in the room, and `{ name, ownerName, connected: false }` for one with no Bot Social identity; nothing of another guest's home is published, and the roster is re-sent whenever the host adds or removes a Bot of its own. A notice the host edits or clears in the room's settings now pushes that same `join` too (`roster.js` `updateRoom`, after the save): a guest already seated holds the old notice and no seat change is coming, so the envelope carries the same roster snapshot with only the notice field changed, and a settings save that leaves the notice alone — or writes back the value the room already holds — sends nothing. A rename is that same push ([#1949](https://github.com/ThinkFlowLab/FlyBot/issues/1949)): `updateRoom` sends the one `join` when the name changed too, so a guest already seated holds the new name without waiting for the next line, and a save that changes the name and the notice together still sends one envelope. The same save also leaves one line in the room itself ([#1962](https://github.com/ThinkFlowLab/FlyBot/issues/1962)): `updateRoom` posts a single quiet `face-notice` event naming who changed the notice (`updated the group notice` / `cleared the notice`), the room name (`renamed the room to “X”`), the reply mode, the admin or the task lead, so everyone already holding the room learns it without opening the settings. It is one line for the whole patch however many of these fields it changes, it opens no Bot turn, a seat change keeps its own joined/left line and draws no settings line, and a patch that writes back the values the room already holds — or an unnamed room's title following its members — says nothing. `applyInboundRoomShare` reads a roster only when the envelope names this mirror's room (`data.roomId` = `sharedFrom.remoteRoomId`) and comes from its bound inviter, and merges it into the participants: one remote participant per entry (`fromRoster`, `roomOnly`), keyed by `(home, handle)` so two same-named Bots of different homes stay distinct, `verified` only for the seat or a published friend Bot. Verification is a signature, not a mention: an unverified entry is displayed and is not who a line may claim to be, an identity-less one is shown as not connected, and an @ of either — the title the @ menu inserts, or the roster name when it has no handle — still starts that Bot's turn on its own home (#949). A roster is the whole list, so a `join` that drops an entry marks it left (soft leave: the entry stays with `leftSeq`, history still resolves) and a re-sent `join` clears the mark; an empty `roster: []` clears the list, while a `join` without the field changes nothing. The notice is replaced by the next `join` that carries one; an explicit empty string clears it and a missing field leaves it. #923: the one pair a room link opened is enumerated even when the seat record predates it. A seat a link admitted is a pair through its own `admittedBy: 'ticket'` / `ticket`, and, when that record is older, through the link's own `homes` — the home-to-handles map the accept writes and keeps — reached from the Bot whose handle it knocked on, or from the room's lead when the seat did not even keep that. So a group envelope from this home reaches that guest as the room's traffic (`sendTicketShare` names the room the link was minted for) instead of falling back to a friendship the two homes never had; a friend's Bot added by hand is named by no link and stays out. This is FlyBot's half of the pair: Bot Social still decides at send time, so a room-started conversation it refuses needs the server-side fix. +- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden (#1167: the host sends that leave while the seat is still a live member, because reach and a room link's pairs only see current participants). The mirror then stays hidden: a later line is refused and a join that is not newer than that leave does not open it again; a newer join can. A hidden mirror sends nothing further. A projection is **named** from the envelope and **matched** by the transport (#873, #909): `applyInboundRoomShare` still shows `data.inviterName` (so a room carried here by a Chief's Bot reads after the person who invited it), but which room a share may touch is decided by `event.fromHome` / `event.fromHandle` — a share naming a room of mine while arriving from a home that room does not belong to is refused `not-inviter`, and the recorded `sharedFrom` identity only ever follows the transport, so an envelope can be displayed but never re-write whose room it is. Only `op: 'join` projects a room: a `line` envelope for an unknown remote room is refused `unknown-room` instead of opening one named by its own payload. A line keeps its own `who`, or the sender when it names nobody, never the inviter; its `authorId` follows the payload only when the handle traces to the transport sender, a remote seat of that room, or its inviter **from the home that invited the room** (#918 — the seat branch and the inviter branch now ask the same question) — otherwise the line is shown as the Bot that delivered it with `unverified: true`, and it is still kept so a forged claim cannot silence a message. The room match stays on the inviter handle alone, so a line presenting that handle from another home still lands and is only attributed to its sender: a room may match where a signature does not. Which room a share belongs to is recorded by a `join` alone; a `line` follows that record and never rewrites the inviter home or handle, or every message would make its sender the inviter. A room recorded before #873 may hold no inviter home at all, and then the only signature it keeps is the transport naming the sender itself (`claim === sender`); the next `join` records the home and the room stops being legacy. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. #904: the projection now records the host's side too. `recordInboundSharedRoom` keeps the guest's own Bot **and** one remote participant for the Bot that carried the join (`draft.inviterHandle`, with `admittedBy: 'ticket'` / `roomOnly` when a link seated it) — `invitedHandle` on this side names one of the guest's own Bots, so it is never recorded as a remote, and the friend path keeps its one remote. The rest of the host's room rides the `join` as `roster` and `notice`: `notifyRoomShare` sends `{ handle, home, name, ownerName }` for every Bot of this home in the room, and `{ name, ownerName, connected: false }` for one with no Bot Social identity; nothing of another guest's home is published, and the roster is re-sent whenever the host adds or removes a Bot of its own. A notice the host edits or clears in the room's settings now pushes that same `join` too (`roster.js` `updateRoom`, after the save): a guest already seated holds the old notice and no seat change is coming, so the envelope carries the same roster snapshot with only the notice field changed, and a settings save that leaves the notice alone — or writes back the value the room already holds — sends nothing. A rename is that same push ([#1949](https://github.com/ThinkFlowLab/FlyBot/issues/1949)): `updateRoom` sends the one `join` when the name changed too, so a guest already seated holds the new name without waiting for the next line, and a save that changes the name and the notice together still sends one envelope. A member's rename moves an unnamed group's title the same way ([#1968](https://github.com/ThinkFlowLab/FlyBot/issues/1968)): `followMemberNames` recomputes the name after a Bot is renamed or removed and sends that one `join` too, because the guests already seated hold the name they were given at join and nothing else tells them; the guard that skips a room whose computed name did not move is what keeps it to real changes. The same save also leaves one line in the room itself ([#1962](https://github.com/ThinkFlowLab/FlyBot/issues/1962)): `updateRoom` posts a single quiet `face-notice` event naming who changed the notice (`updated the group notice` / `cleared the notice`), the room name (`renamed the room to “X”`), the reply mode, the admin or the task lead, so everyone already holding the room learns it without opening the settings. It is one line for the whole patch however many of these fields it changes, it opens no Bot turn, a seat change keeps its own joined/left line and draws no settings line, and a patch that writes back the values the room already holds — or an unnamed room's title following its members — says nothing. `applyInboundRoomShare` reads a roster only when the envelope names this mirror's room (`data.roomId` = `sharedFrom.remoteRoomId`) and comes from its bound inviter, and merges it into the participants: one remote participant per entry (`fromRoster`, `roomOnly`), keyed by `(home, handle)` so two same-named Bots of different homes stay distinct, `verified` only for the seat or a published friend Bot. Verification is a signature, not a mention: an unverified entry is displayed and is not who a line may claim to be, an identity-less one is shown as not connected, and an @ of either — the title the @ menu inserts, or the roster name when it has no handle — still starts that Bot's turn on its own home (#949). A roster is the whole list, so a `join` that drops an entry marks it left (soft leave: the entry stays with `leftSeq`, history still resolves) and a re-sent `join` clears the mark; an empty `roster: []` clears the list, while a `join` without the field changes nothing. The notice is replaced by the next `join` that carries one; an explicit empty string clears it and a missing field leaves it. #923: the one pair a room link opened is enumerated even when the seat record predates it. A seat a link admitted is a pair through its own `admittedBy: 'ticket'` / `ticket`, and, when that record is older, through the link's own `homes` — the home-to-handles map the accept writes and keeps — reached from the Bot whose handle it knocked on, or from the room's lead when the seat did not even keep that. So a group envelope from this home reaches that guest as the room's traffic (`sendTicketShare` names the room the link was minted for) instead of falling back to a friendship the two homes never had; a friend's Bot added by hand is named by no link and stays out. This is FlyBot's half of the pair: Bot Social still decides at send time, so a room-started conversation it refuses needs the server-side fix. - A Bot named in a shared room answers in that room (#825). `fanoutRoomLine` (`src/host/room-membership.js`) puts the calls a line makes in the envelope's `calls` (resolved where the room's mentions are resolved: the social handle, the roster name, and the title the @ menu inserts, so `@Chief (owner)` and `@Data (Friend)` name the same Bot a bare name would; a Bot with no social handle is called by that roster name, and a `not-connected:` id is never a token — #949). A fenced or quoted `@name` stays literal text. The envelope also carries the line's `hop`: a person's line is hop 0 and a Bot's answer is one hop on. The receiving `applyInboundRoomShare` keeps only the calls that name this room's own Bot (`localCalls`), opens **one** turn for each through `rt.startInboundTurn` (`groups.js`: `participant.open` + `participant.runTurn` + `appendRoom`), and skips it when the line's hop has reached the room's `maxHops` (kept on the room, `DEFAULT_MAX_HOPS` 3) or when the same remote line was already answered — the last 200 keys `remoteRoomId:seq:botId` live in `room.answered`. That turn's answer is an ordinary room line, so `fanoutRoomLine` sends it back to the inviter as an `op: 'line'` envelope under the *inviter's* `roomId`, one hop on; an answer its owner's approval holds or refuses still shows as one `notice` line in the room instead of silence. Remote text stays inside the `fencedUntrusted` fence. ### A local room's invite link (#852) diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 1040119dd..e49930bce 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -416,7 +416,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js if (next !== '' && rt.findRooms(next).some(room => room.id !== selfId)) throw new Error(`Another group chat is already called "${next}"`) return next } -@@ -257,21 +257,36 @@ export function install(rt) { +@@ -257,21 +257,40 @@ export function install(rt) { async function followMemberNames(rooms = Object.values(state.rooms)) { for (const room of rooms) { if (room.named) continue @@ -425,6 +425,10 @@ diff --git a/src/host/roster.js b/src/host/roster.js if (next === room.name) continue room.name = next await retitleRoom(room) ++ // The guests already seated hold the name they were given at join, and nothing else tells them: the ++ // same one `join` as an explicit rename (#1949). The guard above is the "the name really moved" ++ // condition, so a rename that leaves the computed name alone still sends nothing. ++ void rt.notifyRoomShare?.(room, { op: 'join' }) } } diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index e1a6be352..2afae16aa 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -3123,3 +3123,106 @@ test('#1949: a rename reaches the guests already seated, once, and a write-back assert.equal(guest().name, 'Renamed again', 'the one envelope carries the new name') assert.equal(guest().notice, 'Sit down', 'and the new notice') }) + +// A member's rename moves an unnamed group's title: `followMemberNames` recomputes it and retitles the +// Session. Nothing told the guests already seated, so they kept the name they were given at join until +// the next line or the next join envelope. A rename is a membership push exactly like the room rename +// #1949 covers, and it rides the same single `join` — once, and only when the computed name moved. +test('a member rename reaches the guest already seated, once', async (t) => { + const { rt, sent } = host(t, TEAM) + installRoster(rt) + rt.resolveAgent = async () => ({ session: 'session' }) + rt.chatOf = id => id + rt.ctx.sessionTitle = { rename: async () => {} } + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) + const shares = () => sent.filter(msg => msg.via === 'room-share') + assert.equal(shares().length, 1, 'the seat join itself pushed once') + sent.length = 0 + + await rt.updateBot('chief-b', { name: 'Chief C' }) + + assert.equal(room.name, 'Alice, Chief A, Chief C, Coder', 'the unnamed group follows its members') + const after = shares() + assert.equal(after.length, 1, 'the rename reaches the guest already seated') + const join = parseRoomShare(after[0].text) + assert.equal(join.op, 'join') + assert.equal(join.name, room.name, 'the envelope carries the new name') + assert.equal(sent.length, 1, 'one envelope, not one per guest record') +}) + +// Control 1: a group with a chosen name does not follow its members, so a rename is not a change to it. +test('a rename in a named group sends nothing', async (t) => { + const { rt, sent } = host(t, TEAM) + installRoster(rt) + rt.resolveAgent = async () => ({ session: 'session' }) + rt.chatOf = id => id + rt.ctx.sessionTitle = { rename: async () => {} } + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + assert.equal(room.named, true, 'the fixture group has a chosen name') + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) + sent.length = 0 + + await rt.updateBot('chief-b', { name: 'Chief C' }) + + assert.equal(room.name, 'War room', 'a chosen name does not move') + assert.equal(sent.filter(msg => msg.via === 'room-share').length, 0, 'nothing to tell the guest') +}) + +// Control 2: the name moved, but there is nobody outside this home to tell. +test('a rename in a group with no remote guest sends nothing', async (t) => { + const { rt, sent } = host(t, TEAM) + installRoster(rt) + rt.resolveAgent = async () => ({ session: 'session' }) + rt.chatOf = id => id + rt.ctx.sessionTitle = { rename: async () => {} } + await rt.load() + publish(rt) + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + + await rt.updateBot('chief-b', { name: 'Chief C' }) + + assert.equal(room.name, 'Alice, Chief A, Chief C', 'the unnamed group follows its members') + assert.equal(sent.filter(msg => msg.via === 'room-share').length, 0, 'no guest to tell') +}) + +// Control 3: a Bot that sits in no group is renamed — every room's name is what it was, so is the wire. +test('a rename that leaves the computed name alone sends nothing', async (t) => { + const { rt, sent } = host(t, TEAM) + installRoster(rt) + rt.resolveAgent = async () => ({ session: 'session' }) + rt.chatOf = id => id + rt.ctx.sessionTitle = { rename: async () => {} } + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) + sent.length = 0 + + await rt.updateBot('spec-a', { name: 'Writer C' }) + + assert.equal(room.name, 'Alice, Chief A, Chief B', 'a Bot outside the group does not rename it') + assert.equal(sent.filter(msg => msg.via === 'room-share').length, 0, 'the computed name did not move') +})