diff --git a/changelog.d/1949-rename-push.fixed.md b/changelog.d/1949-rename-push.fixed.md new file mode 100644 index 000000000..0ee9c386a --- /dev/null +++ b/changelog.d/1949-rename-push.fixed.md @@ -0,0 +1 @@ +Renaming a group chat now reaches the guests already seated in it ([#1949](https://github.com/ThinkFlowLab/FlyBot/issues/1949)): the host pushed a membership envelope only when the notice changed, so a shared room kept its old title on the other homes until the next chat line, and a quiet room kept it indefinitely. A rename rides the same one `join` the notice edit already used — the envelope carries the name field it always carried — and a settings save that changes both the name and the notice still sends a single envelope, while a save that writes back the values the room already holds sends nothing. diff --git a/core/src/host/roster.js b/core/src/host/roster.js index 957999b58..8d69feae1 100644 --- a/core/src/host/roster.js +++ b/core/src/host/roster.js @@ -462,15 +462,17 @@ export function install(rt) { for (const ref of patch.removeParticipants) await rt.removeRoomParticipant(room.id, ref, actor) } if (!room.named) room.name = autoRoomName(room.members, room) - if (room.name !== before) { + const nameChanged = room.name !== before + if (nameChanged) { changes.push(`renamed it "${room.name}"`) await retitleRoom(room) } await rt.save() - // A notice edit is a membership push like a seat change: the guests already in the room hold the old - // notice and nothing else tells them. The same roster snapshot carries it, only this field differs, - // so the envelope repeats no row. Sent after the save, like the join at creation. - if (noticeChanged) void rt.notifyRoomShare?.(room, { op: 'join' }) + // A rename and a notice edit are membership pushes like a seat change: the guests already in the room + // hold the old name and the old notice and nothing else tells them. One `join` carries both fields + // (#1949), so a rename is not waiting on the next line and a save that changes both sends one envelope, + // not two. Sent after the save, like the join at creation; a save that changes neither sends nothing. + if (nameChanged || noticeChanged) void rt.notifyRoomShare?.(room, { op: 'join' }) return { room, changes } } diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index abb53720c..19ffdbe46 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -2980,3 +2980,79 @@ test('a seat line body that arrives ill formed is made well formed where it is u assert.equal(shown.at(-1).text, 'hi \u{1F600} there') assert.equal(turns.at(-1).text, 'hi \u{1F600} there', 'a real pair is not replaced') }) + +// #1949: a rename is a membership change like a notice edit. `updateRoom` pushed a join only when the +// notice moved, so the guests already seated in the room kept the old name until the next line — a quiet +// shared room kept it indefinitely. The join envelope already carries `name`, so a rename rides that same +// one push; writing back the name the room already has still sends nothing (#1939). +test('#1949: a rename reaches the guests already seated, once, and a write-back sends nothing', async (t) => { + const a = host(t, TEAM) + const b = host(t, BOB_HOME) + installRoster(a.rt) + await a.rt.load() + await b.rt.load() + const invite = await a.rt.createInvitation({ createdByMemberId: 'owner-1' }) + const alice = await a.rt.redeemInvitation(invite.token, { displayName: 'Alice' }) + const actorA = await a.rt.authenticate(alice.sessionToken) + a.rt.state.bots['chief-a'].fields = { social: { member: { handle: '@chief.alice.home', home: '@alice.home', ownerName: 'Alice' } } } + a.rt.state.bots['chief-b'].fields = { social: { member: { handle: '@helper.alice.home', home: '@alice.home', ownerName: 'Alice' } } } + publish(a.rt, [{ handle: '@chief.bob.home', name: 'Chief', home: '@bob.home', homeName: 'Bob', owner: 'Bob', plugin: 'social', remote: true }]) + a.rt.roomInviteServer = { mint: async () => ({ ticket: 'Tkt0001AbCdEfGhIjKlMnO', expiresAt: null }) } + await a.rt.mintRoomInvite('war', actorA) + const traffic = [] + const wire = (from, to, home) => { + from.rt.flybotSend = async (msg) => { + const verdict = from.rt.reach(msg) + traffic.push({ op: parseRoomShare(msg.text)?.op, action: verdict.action }) + if (verdict.action !== 'allow') return { action: 'deny', reason: verdict.reason } + const localBotId = Object.keys(to.rt.state.bots).find(id => to.rt.state.bots[id]?.fields?.social?.member?.handle === msg.to) + const sender = from.rt.state.bots[msg.from] + await to.rt.applyInboundRoomShare({ + text: msg.text, + fromHandle: sender?.fields?.social?.member?.handle, + fromHome: home, + fromName: sender?.fields?.social?.member?.ownerName || 'Alice', + localBotId, + }) + return { action: 'sent' } + } + } + wire(a, b, '@alice.home') + wire(b, a, '@bob.home') + await a.rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@chief.bob.home' }, actorA) + await settleShares() + const guest = () => Object.values(b.rt.state.rooms).find(room => room.origin === 'inbound-share') + assert.ok(guest(), 'the seat projected the room on the friend\'s home') + assert.equal(guest().name, 'War room', 'the guest starts with the name the seat carried') + const joins = () => traffic.filter(item => item.op === 'join' && item.action === 'allow').length + const seats = JSON.stringify(guest().participants) + const logLength = guest().log.length + + // The rule #1939 locked: a patch that writes back the values the room already holds pushes nothing. + const before = joins() + await a.rt.updateRoom('war', { name: 'War room', mode: 'everyone' }, undefined, actorA) + await settleShares() + assert.equal(joins(), before, 'a name written back at its own value sends no join') + + // The rename itself travels now, as one join, and the seated guest holds the new name with no line. + await a.rt.updateRoom('war', { name: 'Renamed war' }, undefined, actorA) + await settleShares() + assert.equal(joins(), before + 1, 'one rename is one join') + assert.equal(guest().name, 'Renamed war', 'the guest holds the new name with no further message') + assert.equal(JSON.stringify(guest().participants), seats, 'the seats in that envelope are unchanged') + assert.equal(guest().log.length, logLength, 'the push is the envelope, not a chat line') + + // A notice-only change still pushes, and a call that changes both is still exactly one push. + const afterNotice = joins() + await a.rt.updateRoom('war', { notice: 'Stand up' }, undefined, actorA) + await settleShares() + assert.equal(joins(), afterNotice + 1, 'a changed notice sends one join') + assert.equal(guest().notice, 'Stand up', 'the guest takes the notice from that join') + + const afterBoth = joins() + await a.rt.updateRoom('war', { name: 'Renamed again', notice: 'Sit down' }, undefined, actorA) + await settleShares() + assert.equal(joins(), afterBoth + 1, 'a rename and a notice change in one call send one join, not two') + assert.equal(guest().name, 'Renamed again', 'the one envelope carries the new name') + assert.equal(guest().notice, 'Sit down', 'and the new notice') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 049b6cc5d..5e2ee4a84 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. `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. `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 ae0906c4c..4bc0a7cb4 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -565,11 +565,12 @@ diff --git a/src/host/roster.js b/src/host/roster.js room.notice = notice changes.push(notice ? 'updated the notice' : 'cleared the notice') } -@@ -371,20 +371,33 @@ export function install(rt) { +@@ -371,20 +371,35 @@ export function install(rt) { delete room.named } } - if (!room.named) room.name = autoRoomName(room.members) +- if (room.name !== before) { + if (Array.isArray(patch.addParticipants)) { + for (const draft of patch.addParticipants) await rt.addRoomParticipant(room.id, draft, actor) + } @@ -577,15 +578,17 @@ diff --git a/src/host/roster.js b/src/host/roster.js + for (const ref of patch.removeParticipants) await rt.removeRoomParticipant(room.id, ref, actor) + } + if (!room.named) room.name = autoRoomName(room.members, room) - if (room.name !== before) { ++ const nameChanged = room.name !== before ++ if (nameChanged) { changes.push(`renamed it "${room.name}"`) await retitleRoom(room) } await rt.save() -+ // A notice edit is a membership push like a seat change: the guests already in the room hold the old -+ // notice and nothing else tells them. The same roster snapshot carries it, only this field differs, -+ // so the envelope repeats no row. Sent after the save, like the join at creation. -+ if (noticeChanged) void rt.notifyRoomShare?.(room, { op: 'join' }) ++ // A rename and a notice edit are membership pushes like a seat change: the guests already in the room ++ // hold the old name and the old notice and nothing else tells them. One `join` carries both fields ++ // (#1949), so a rename is not waiting on the next line and a save that changes both sends one envelope, ++ // not two. Sent after the save, like the join at creation; a save that changes neither sends nothing. ++ if (nameChanged || noticeChanged) void rt.notifyRoomShare?.(room, { op: 'join' }) return { room, changes } } diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index abb53720c..19ffdbe46 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -2980,3 +2980,79 @@ test('a seat line body that arrives ill formed is made well formed where it is u assert.equal(shown.at(-1).text, 'hi \u{1F600} there') assert.equal(turns.at(-1).text, 'hi \u{1F600} there', 'a real pair is not replaced') }) + +// #1949: a rename is a membership change like a notice edit. `updateRoom` pushed a join only when the +// notice moved, so the guests already seated in the room kept the old name until the next line — a quiet +// shared room kept it indefinitely. The join envelope already carries `name`, so a rename rides that same +// one push; writing back the name the room already has still sends nothing (#1939). +test('#1949: a rename reaches the guests already seated, once, and a write-back sends nothing', async (t) => { + const a = host(t, TEAM) + const b = host(t, BOB_HOME) + installRoster(a.rt) + await a.rt.load() + await b.rt.load() + const invite = await a.rt.createInvitation({ createdByMemberId: 'owner-1' }) + const alice = await a.rt.redeemInvitation(invite.token, { displayName: 'Alice' }) + const actorA = await a.rt.authenticate(alice.sessionToken) + a.rt.state.bots['chief-a'].fields = { social: { member: { handle: '@chief.alice.home', home: '@alice.home', ownerName: 'Alice' } } } + a.rt.state.bots['chief-b'].fields = { social: { member: { handle: '@helper.alice.home', home: '@alice.home', ownerName: 'Alice' } } } + publish(a.rt, [{ handle: '@chief.bob.home', name: 'Chief', home: '@bob.home', homeName: 'Bob', owner: 'Bob', plugin: 'social', remote: true }]) + a.rt.roomInviteServer = { mint: async () => ({ ticket: 'Tkt0001AbCdEfGhIjKlMnO', expiresAt: null }) } + await a.rt.mintRoomInvite('war', actorA) + const traffic = [] + const wire = (from, to, home) => { + from.rt.flybotSend = async (msg) => { + const verdict = from.rt.reach(msg) + traffic.push({ op: parseRoomShare(msg.text)?.op, action: verdict.action }) + if (verdict.action !== 'allow') return { action: 'deny', reason: verdict.reason } + const localBotId = Object.keys(to.rt.state.bots).find(id => to.rt.state.bots[id]?.fields?.social?.member?.handle === msg.to) + const sender = from.rt.state.bots[msg.from] + await to.rt.applyInboundRoomShare({ + text: msg.text, + fromHandle: sender?.fields?.social?.member?.handle, + fromHome: home, + fromName: sender?.fields?.social?.member?.ownerName || 'Alice', + localBotId, + }) + return { action: 'sent' } + } + } + wire(a, b, '@alice.home') + wire(b, a, '@bob.home') + await a.rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@chief.bob.home' }, actorA) + await settleShares() + const guest = () => Object.values(b.rt.state.rooms).find(room => room.origin === 'inbound-share') + assert.ok(guest(), 'the seat projected the room on the friend\'s home') + assert.equal(guest().name, 'War room', 'the guest starts with the name the seat carried') + const joins = () => traffic.filter(item => item.op === 'join' && item.action === 'allow').length + const seats = JSON.stringify(guest().participants) + const logLength = guest().log.length + + // The rule #1939 locked: a patch that writes back the values the room already holds pushes nothing. + const before = joins() + await a.rt.updateRoom('war', { name: 'War room', mode: 'everyone' }, undefined, actorA) + await settleShares() + assert.equal(joins(), before, 'a name written back at its own value sends no join') + + // The rename itself travels now, as one join, and the seated guest holds the new name with no line. + await a.rt.updateRoom('war', { name: 'Renamed war' }, undefined, actorA) + await settleShares() + assert.equal(joins(), before + 1, 'one rename is one join') + assert.equal(guest().name, 'Renamed war', 'the guest holds the new name with no further message') + assert.equal(JSON.stringify(guest().participants), seats, 'the seats in that envelope are unchanged') + assert.equal(guest().log.length, logLength, 'the push is the envelope, not a chat line') + + // A notice-only change still pushes, and a call that changes both is still exactly one push. + const afterNotice = joins() + await a.rt.updateRoom('war', { notice: 'Stand up' }, undefined, actorA) + await settleShares() + assert.equal(joins(), afterNotice + 1, 'a changed notice sends one join') + assert.equal(guest().notice, 'Stand up', 'the guest takes the notice from that join') + + const afterBoth = joins() + await a.rt.updateRoom('war', { name: 'Renamed again', notice: 'Sit down' }, undefined, actorA) + await settleShares() + assert.equal(joins(), afterBoth + 1, 'a rename and a notice change in one call send one join, not two') + assert.equal(guest().name, 'Renamed again', 'the one envelope carries the new name') + assert.equal(guest().notice, 'Sit down', 'and the new notice') +})