diff --git a/changelog.d/1962-room-settings-line.fixed.md b/changelog.d/1962-room-settings-line.fixed.md new file mode 100644 index 000000000..55bc71703 --- /dev/null +++ b/changelog.d/1962-room-settings-line.fixed.md @@ -0,0 +1 @@ +A group settings change now leaves one line in the room: renaming the room, editing or clearing the notice, switching the reply mode, or naming a new admin or task lead posts a single quiet event that says what changed and who changed it, opens no Bot turn, and stays quiet when a patch writes back the values the room already holds or when an unnamed room's title follows its members ([#1962](https://github.com/ThinkFlowLab/FlyBot/issues/1962)). diff --git a/core/src/host/roster.js b/core/src/host/roster.js index 8d69feae1..8f296f715 100644 --- a/core/src/host/roster.js +++ b/core/src/host/roster.js @@ -408,6 +408,10 @@ export function install(rt) { const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined const changes = [] + // What the room itself says about this patch. A seat change already writes its own joined/left line, + // and a working folder is not something the others need a line about, so only the room's own settings + // are collected here: one patch draws at most one event. + const settings = [] const names = ids => ids.map(member => rt.botOf(member)?.name).filter(Boolean).join(', ') const added = members.filter(member => !room.members.includes(member)) const removed = room.members.filter(member => !members.includes(member)) @@ -424,21 +428,30 @@ export function install(rt) { } room.members = members } - if (admin !== (room.admin ?? null)) changes.push(admin ? `made ${rt.botOf(admin).name} the admin` : 'removed the admin') + if (admin !== (room.admin ?? null)) { + changes.push(admin ? `made ${rt.botOf(admin).name} the admin` : 'removed the admin') + // A seat change that empties the admin says "removed the admin" in `changes` alone: the room's line + // about it would repeat the joined/left line the seat change already writes. + if (patch.admin !== undefined) settings.push(admin ? `made ${rt.botOf(admin)?.name ?? 'someone'} the admin` : 'removed the admin') + } room.admin = admin if (taskLead !== (room.taskLead ?? null)) { if (taskLead) room.taskLead = taskLead else delete room.taskLead - changes.push(taskLead ? `made ${rt.botOf(taskLead).name} the task lead` : 'the admin leads the board again') + const lead = taskLead ? rt.botOf(taskLead).name : '' + changes.push(taskLead ? `made ${lead} the task lead` : 'the admin leads the board again') + if (patch.taskLead !== undefined) settings.push(taskLead ? `made ${lead} the task lead` : 'let the admin lead the board again') } if (patch.mode !== undefined && patch.mode !== room.mode) { room.mode = patch.mode changes.push(patch.mode === 'admin' ? 'plain messages now go to the admin first' : 'every member now replies in turn') + settings.push(patch.mode === 'admin' ? 'switched plain messages to the admin first' : 'switched the room to every member replying in turn') } const noticeChanged = notice !== undefined && notice !== (room.notice ?? '') if (noticeChanged) { room.notice = notice changes.push(notice ? 'updated the notice' : 'cleared the notice') + settings.push(notice ? 'updated the group notice' : 'cleared the notice') } // Members' group conversations move on to the folder at their next turn there. if (moving && workDir !== room.workDir) { @@ -465,6 +478,9 @@ export function install(rt) { const nameChanged = room.name !== before if (nameChanged) { changes.push(`renamed it "${room.name}"`) + // An unnamed room's title follows its members; that move is not a rename someone asked for, so only + // an explicit name in the patch draws a line. + if (patch.name !== undefined) settings.push(`renamed the room to “${room.name}”`) await retitleRoom(room) } await rt.save() @@ -473,9 +489,24 @@ export function install(rt) { // (#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' }) + // The same settings change is a line in the room itself: the envelope above tells the guests, this + // tells everyone already holding the room that something moved. It is the quiet room sender, not a + // Bot, so it draws as an event and opens no turn. + if (settings.length > 0) showSettings(room, settings, actor, actorId) return { room, changes } } + function showSettings(room, settings, actor, actorId) { + const who = actor?.displayName ?? rt.botOf(actorId)?.name ?? 'Someone' + const said = settings.length === 1 ? settings[0] : `${settings.slice(0, -1).join(', ')} and ${settings.at(-1)}` + try { + void Promise.resolve(rt.postToRoom?.(room, { id: 'face-notice', name: who }, `${who} ${said}`, { quiet: true, notice: true })) + .catch(error => rt.warn('ds-bot: the settings line for %s was not shown: %s', room.name, error)) + } catch (error) { + rt.warn('ds-bot: the settings line for %s was not shown: %s', room.name, error) + } + } + async function deleteRoom(id) { const room = rt.roomOf(id) if (room === undefined) throw new Error('Unknown group chat') diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 19ffdbe46..e1a6be352 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -2436,6 +2436,73 @@ test('#1939: a notice the host edits reaches the guests already seated, once, an assert.equal(applied.room.notice, '', 'the guest clears its copy too') }) +// P-303: a settings change wrote the state and the `changes` list and left the room itself silent, so +// nobody seated in it learned that the notice, the name, the reply mode, the admin or the task lead had +// moved. One patch is now at most one quiet notice line — the `face-notice` sender the client already +// draws as an event — and it opens no Bot turn. A patch that writes back the values already there says +// nothing, and a seat change keeps its own joined/left line instead of adding a settings echo. +test('a settings patch leaves one visible line in the room, and only when a value changed', async (t) => { + const { rt } = host(t, JSON.parse(JSON.stringify(TEAM))) + installRoster(rt) + await rt.load() + const invite = await rt.createInvitation({ createdByMemberId: 'owner-1' }) + const alice = await rt.redeemInvitation(invite.token, { displayName: 'Alice' }) + const actorA = await rt.authenticate(alice.sessionToken) + const events = [] + rt.postToRoom = async (room, sender, text, extra) => { events.push({ room: room.id, sender, text, extra }) } + const count = () => events.length + const last = () => events.at(-1) + + await rt.updateRoom('war', { name: 'Renamed war' }, undefined, actorA) + assert.equal(count(), 1, `one rename is one line: ${JSON.stringify(events)}`) + assert.deepEqual(last().sender, { id: 'face-notice', name: 'Alice' }, 'the line is the quiet room sender, not a Bot') + assert.equal(last().text, 'Alice renamed the room to “Renamed war”') + assert.deepEqual(last().extra, { quiet: true, notice: true }, 'quiet and a notice, so no Bot turn opens') + + await rt.updateRoom('war', { name: 'Renamed war' }, undefined, actorA) + assert.equal(count(), 1, 'a name written back at its own value says nothing') + + await rt.updateRoom('war', { notice: 'Stand up' }, undefined, actorA) + assert.equal(last().text, 'Alice updated the group notice') + await rt.updateRoom('war', { notice: '' }, undefined, actorA) + assert.equal(last().text, 'Alice cleared the notice') + await rt.updateRoom('war', { notice: '' }, undefined, actorA) + assert.equal(count(), 3, 'an empty notice written back at its own value says nothing') + + await rt.updateRoom('war', { admin: 'chief-b' }, undefined, actorA) + assert.equal(last().text, 'Alice made Chief B the admin') + await rt.updateRoom('war', { admin: 'chief-b' }, undefined, actorA) + assert.equal(count(), 4, 'the admin set again to the same Bot says nothing') + + await rt.updateRoom('war', { mode: 'admin' }, undefined, actorA) + assert.equal(last().text, 'Alice switched plain messages to the admin first') + await rt.updateRoom('war', { mode: 'everyone' }, undefined, actorA) + assert.equal(last().text, 'Alice switched the room to every member replying in turn') + + await rt.updateRoom('war', { taskLead: 'chief-a' }, undefined, actorA) + assert.equal(last().text, 'Alice made Chief A the task lead') + await rt.updateRoom('war', { taskLead: null }, undefined, actorA) + assert.equal(last().text, 'Alice let the admin lead the board again') + + // Two settings in one patch are still one line, both named, so a save never draws two events. + const before = count() + await rt.updateRoom('war', { name: 'Renamed again', notice: 'Sit down' }, undefined, actorA) + assert.equal(count(), before + 1, 'a rename and a notice change in one call are one line') + assert.equal(last().text, 'Alice updated the group notice and renamed the room to “Renamed again”') + + // A seat change already has its joined/left line, and leaving with the admin seat empties it through the + // member list rather than through a settings field: neither draws a settings line. + const seats = count() + await rt.updateRoom('war', { add: ['spec-a'] }, undefined, actorA) + assert.equal(count(), seats, 'a seat change is not a settings line') + await rt.updateRoom('war', { remove: ['chief-b'] }, undefined, actorA) + assert.equal(count(), seats, 'a seat change that empties the admin is not a settings line either') + + // The tool path passes no actor, only the Bot's own id: the line still says who did it. + await rt.updateRoom('war', { notice: 'From the tool' }, 'chief-a') + assert.equal(last().text, 'Chief A updated the group notice') +}) + // #904: a roster is stranger-written text, so the envelope that carries one is gated: only the room's // own inviter, only the room this mirror is bound to, and nothing it says widens reach by itself. test('#904: a roster from another home or for another room changes no participant', async (t) => { diff --git a/core/test/task-drive.test.js b/core/test/task-drive.test.js index 8b4bf966e..690d6eda9 100644 --- a/core/test/task-drive.test.js +++ b/core/test/task-drive.test.js @@ -387,6 +387,10 @@ test('only the task lead assigns, the card is claimed, and the assignee is woken await app.rt.updateRoom('war', { taskLead: null }, 'a') assert.equal(app.rt.roomOf('war').taskLead, undefined) await app.rt.updateRoom('war', { taskLead: 's' }, 'a') + // The three settings patches above draw their own room lines; what this test guards is that the driver + // adds none of its own, so the count is taken after the setup and compared, not pinned to zero. + await new Promise(resolve => setImmediate(resolve)) + const posted = app.calls('war').length const room = app.rt.roomOf('war') room.members = ['s', 'b'] @@ -415,7 +419,7 @@ test('only the task lead assigns, the card is claimed, and the assignee is woken assert.match(text, /Task board \(assign, claim, and progress live here/) assert.match(text, /Ship the note/) assert.match(text, /Bob writes/) - assert.equal(app.calls('war').length, 0, 'the driver does not post the cue into the room') + assert.equal(app.calls('war').length, posted, 'the driver does not post the cue into the room') assert.equal(app.calls('b').length, 1) const linked = await app.track('b', { tasks: [{ title: 'Bob extra', parent: 'Ship the note', group: 'War' }] }) @@ -426,7 +430,7 @@ test('only the task lead assigns, the card is claimed, and the assignee is woken app.emit('b', 'assistant/message', { message: { content: [{ type: 'text', text: 'Which file should the note use?' }] } }) app.emit('b', 'turn/end', { reason: { kind: 'completed' } }) await app.ctx.taskDrive.settled() - assert.equal(app.calls('war').length, 0, 'the own-chat reply is not posted into the group') + assert.equal(app.calls('war').length, posted, 'the own-chat reply is not posted into the group') assert.equal(app.calls('b').length, 1, 'a wake that did not move the board does not start another') }) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index a24f21f04..2c18c1dd7 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. `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. 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 4bc0a7cb4..1040119dd 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -528,7 +528,16 @@ diff --git a/src/host/roster.js b/src/host/roster.js const name = patch.name === undefined ? undefined : cleanRoomName(patch.name, room.id) const notice = patch.notice === undefined ? undefined : cleanNotice(patch.notice) const moving = patch.workDir !== undefined -@@ -340,19 +340,30 @@ export function install(rt) { +@@ -335,26 +335,50 @@ export function install(rt) { + const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined + + const changes = [] ++ // What the room itself says about this patch. A seat change already writes its own joined/left line, ++ // and a working folder is not something the others need a line about, so only the room's own settings ++ // are collected here: one patch draws at most one event. ++ const settings = [] + const names = ids => ids.map(member => rt.botOf(member)?.name).filter(Boolean).join(', ') + const added = members.filter(member => !room.members.includes(member)) const removed = room.members.filter(member => !members.includes(member)) if (added.length > 0) changes.push(`added ${names(added)}`) if (removed.length > 0) changes.push(`removed ${names(removed)}`) @@ -546,26 +555,38 @@ diff --git a/src/host/roster.js b/src/host/roster.js + rt.dropGroupSession(room, member) + } + room.members = members ++ } ++ if (admin !== (room.admin ?? null)) { ++ changes.push(admin ? `made ${rt.botOf(admin).name} the admin` : 'removed the admin') ++ // A seat change that empties the admin says "removed the admin" in `changes` alone: the room's line ++ // about it would repeat the joined/left line the seat change already writes. ++ if (patch.admin !== undefined) settings.push(admin ? `made ${rt.botOf(admin)?.name ?? 'someone'} the admin` : 'removed the admin') } - room.members = members - if (admin !== (room.admin ?? null)) changes.push(admin ? `made ${rt.botOf(admin).name} the admin` : 'removed the admin') +- if (admin !== (room.admin ?? null)) changes.push(admin ? `made ${rt.botOf(admin).name} the admin` : 'removed the admin') room.admin = admin + if (taskLead !== (room.taskLead ?? null)) { + if (taskLead) room.taskLead = taskLead + else delete room.taskLead -+ changes.push(taskLead ? `made ${rt.botOf(taskLead).name} the task lead` : 'the admin leads the board again') ++ const lead = taskLead ? rt.botOf(taskLead).name : '' ++ changes.push(taskLead ? `made ${lead} the task lead` : 'the admin leads the board again') ++ if (patch.taskLead !== undefined) settings.push(taskLead ? `made ${lead} the task lead` : 'let the admin lead the board again') + } if (patch.mode !== undefined && patch.mode !== room.mode) { room.mode = patch.mode changes.push(patch.mode === 'admin' ? 'plain messages now go to the admin first' : 'every member now replies in turn') ++ settings.push(patch.mode === 'admin' ? 'switched plain messages to the admin first' : 'switched the room to every member replying in turn') } - if (notice !== undefined && notice !== (room.notice ?? '')) { + const noticeChanged = notice !== undefined && notice !== (room.notice ?? '') + if (noticeChanged) { room.notice = notice changes.push(notice ? 'updated the notice' : 'cleared the notice') ++ settings.push(notice ? 'updated the group notice' : 'cleared the notice') } -@@ -371,20 +371,35 @@ export function install(rt) { + // Members' group conversations move on to the folder at their next turn there. + if (moving && workDir !== room.workDir) { +@@ -371,20 +371,53 @@ export function install(rt) { delete room.named } } @@ -581,6 +602,9 @@ diff --git a/src/host/roster.js b/src/host/roster.js + const nameChanged = room.name !== before + if (nameChanged) { changes.push(`renamed it "${room.name}"`) ++ // An unnamed room's title follows its members; that move is not a rename someone asked for, so only ++ // an explicit name in the patch draws a line. ++ if (patch.name !== undefined) settings.push(`renamed the room to “${room.name}”`) await retitleRoom(room) } await rt.save() @@ -589,9 +613,24 @@ diff --git a/src/host/roster.js b/src/host/roster.js + // (#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' }) ++ // The same settings change is a line in the room itself: the envelope above tells the guests, this ++ // tells everyone already holding the room that something moved. It is the quiet room sender, not a ++ // Bot, so it draws as an event and opens no turn. ++ if (settings.length > 0) showSettings(room, settings, actor, actorId) return { room, changes } } ++ function showSettings(room, settings, actor, actorId) { ++ const who = actor?.displayName ?? rt.botOf(actorId)?.name ?? 'Someone' ++ const said = settings.length === 1 ? settings[0] : `${settings.slice(0, -1).join(', ')} and ${settings.at(-1)}` ++ try { ++ void Promise.resolve(rt.postToRoom?.(room, { id: 'face-notice', name: who }, `${who} ${said}`, { quiet: true, notice: true })) ++ .catch(error => rt.warn('ds-bot: the settings line for %s was not shown: %s', room.name, error)) ++ } catch (error) { ++ rt.warn('ds-bot: the settings line for %s was not shown: %s', room.name, error) ++ } ++ } ++ async function deleteRoom(id) { const room = rt.roomOf(id) if (room === undefined) throw new Error('Unknown group chat') @@ -614,7 +653,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js next = others.with(at, id) } else if (primary) { next = [id, ...others] -@@ -417,12 +417,43 @@ export function install(rt) { +@@ -417,14 +417,45 @@ export function install(rt) { } // The sidebar shows every Main Bot, so a hidden Bot would vanish again once demoted. if (main) delete bot.hidden @@ -624,9 +663,9 @@ diff --git a/src/host/roster.js b/src/host/roster.js - } state.mainBotIds = next + rt.syncDesignatedChief() -+ await rt.save() -+ } -+ + await rt.save() + } + + // #638: retire a Bot instead of deleting it. The tree already reads fields['flybot-tree'].retired: + // adoption skips the record and it no longer counts against maxChildren, while its chat, its parts + // and its memory stay where they are. `hidden` keeps it out of the default Bot list the way the @@ -657,11 +696,13 @@ diff --git a/src/host/roster.js b/src/host/roster.js + delete state.grants[id] + await rt.reachChanged?.(id) + } - await rt.save() ++ await rt.save() + return { bot, freed } - } - ++ } ++ async function deleteBot(id) { + const bot = rt.botOf(id) + if (bot === undefined) throw new Error('Unknown Bot') @@ -434,22 +434,42 @@ export function install(rt) { const current = rt.chatOf(id) // Questions and exchange turns are kept per Session, so every part of the chat is cleared. diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 19ffdbe46..e1a6be352 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -2436,6 +2436,73 @@ test('#1939: a notice the host edits reaches the guests already seated, once, an assert.equal(applied.room.notice, '', 'the guest clears its copy too') }) +// P-303: a settings change wrote the state and the `changes` list and left the room itself silent, so +// nobody seated in it learned that the notice, the name, the reply mode, the admin or the task lead had +// moved. One patch is now at most one quiet notice line — the `face-notice` sender the client already +// draws as an event — and it opens no Bot turn. A patch that writes back the values already there says +// nothing, and a seat change keeps its own joined/left line instead of adding a settings echo. +test('a settings patch leaves one visible line in the room, and only when a value changed', async (t) => { + const { rt } = host(t, JSON.parse(JSON.stringify(TEAM))) + installRoster(rt) + await rt.load() + const invite = await rt.createInvitation({ createdByMemberId: 'owner-1' }) + const alice = await rt.redeemInvitation(invite.token, { displayName: 'Alice' }) + const actorA = await rt.authenticate(alice.sessionToken) + const events = [] + rt.postToRoom = async (room, sender, text, extra) => { events.push({ room: room.id, sender, text, extra }) } + const count = () => events.length + const last = () => events.at(-1) + + await rt.updateRoom('war', { name: 'Renamed war' }, undefined, actorA) + assert.equal(count(), 1, `one rename is one line: ${JSON.stringify(events)}`) + assert.deepEqual(last().sender, { id: 'face-notice', name: 'Alice' }, 'the line is the quiet room sender, not a Bot') + assert.equal(last().text, 'Alice renamed the room to “Renamed war”') + assert.deepEqual(last().extra, { quiet: true, notice: true }, 'quiet and a notice, so no Bot turn opens') + + await rt.updateRoom('war', { name: 'Renamed war' }, undefined, actorA) + assert.equal(count(), 1, 'a name written back at its own value says nothing') + + await rt.updateRoom('war', { notice: 'Stand up' }, undefined, actorA) + assert.equal(last().text, 'Alice updated the group notice') + await rt.updateRoom('war', { notice: '' }, undefined, actorA) + assert.equal(last().text, 'Alice cleared the notice') + await rt.updateRoom('war', { notice: '' }, undefined, actorA) + assert.equal(count(), 3, 'an empty notice written back at its own value says nothing') + + await rt.updateRoom('war', { admin: 'chief-b' }, undefined, actorA) + assert.equal(last().text, 'Alice made Chief B the admin') + await rt.updateRoom('war', { admin: 'chief-b' }, undefined, actorA) + assert.equal(count(), 4, 'the admin set again to the same Bot says nothing') + + await rt.updateRoom('war', { mode: 'admin' }, undefined, actorA) + assert.equal(last().text, 'Alice switched plain messages to the admin first') + await rt.updateRoom('war', { mode: 'everyone' }, undefined, actorA) + assert.equal(last().text, 'Alice switched the room to every member replying in turn') + + await rt.updateRoom('war', { taskLead: 'chief-a' }, undefined, actorA) + assert.equal(last().text, 'Alice made Chief A the task lead') + await rt.updateRoom('war', { taskLead: null }, undefined, actorA) + assert.equal(last().text, 'Alice let the admin lead the board again') + + // Two settings in one patch are still one line, both named, so a save never draws two events. + const before = count() + await rt.updateRoom('war', { name: 'Renamed again', notice: 'Sit down' }, undefined, actorA) + assert.equal(count(), before + 1, 'a rename and a notice change in one call are one line') + assert.equal(last().text, 'Alice updated the group notice and renamed the room to “Renamed again”') + + // A seat change already has its joined/left line, and leaving with the admin seat empties it through the + // member list rather than through a settings field: neither draws a settings line. + const seats = count() + await rt.updateRoom('war', { add: ['spec-a'] }, undefined, actorA) + assert.equal(count(), seats, 'a seat change is not a settings line') + await rt.updateRoom('war', { remove: ['chief-b'] }, undefined, actorA) + assert.equal(count(), seats, 'a seat change that empties the admin is not a settings line either') + + // The tool path passes no actor, only the Bot's own id: the line still says who did it. + await rt.updateRoom('war', { notice: 'From the tool' }, 'chief-a') + assert.equal(last().text, 'Chief A updated the group notice') +}) + // #904: a roster is stranger-written text, so the envelope that carries one is gated: only the room's // own inviter, only the room this mirror is bound to, and nothing it says widens reach by itself. test('#904: a roster from another home or for another room changes no participant', async (t) => { diff --git a/patches/ds-bot/files/test/task-drive.test.js b/patches/ds-bot/files/test/task-drive.test.js index 8b4bf966e..690d6eda9 100644 --- a/patches/ds-bot/files/test/task-drive.test.js +++ b/patches/ds-bot/files/test/task-drive.test.js @@ -387,6 +387,10 @@ test('only the task lead assigns, the card is claimed, and the assignee is woken await app.rt.updateRoom('war', { taskLead: null }, 'a') assert.equal(app.rt.roomOf('war').taskLead, undefined) await app.rt.updateRoom('war', { taskLead: 's' }, 'a') + // The three settings patches above draw their own room lines; what this test guards is that the driver + // adds none of its own, so the count is taken after the setup and compared, not pinned to zero. + await new Promise(resolve => setImmediate(resolve)) + const posted = app.calls('war').length const room = app.rt.roomOf('war') room.members = ['s', 'b'] @@ -415,7 +419,7 @@ test('only the task lead assigns, the card is claimed, and the assignee is woken assert.match(text, /Task board \(assign, claim, and progress live here/) assert.match(text, /Ship the note/) assert.match(text, /Bob writes/) - assert.equal(app.calls('war').length, 0, 'the driver does not post the cue into the room') + assert.equal(app.calls('war').length, posted, 'the driver does not post the cue into the room') assert.equal(app.calls('b').length, 1) const linked = await app.track('b', { tasks: [{ title: 'Bob extra', parent: 'Ship the note', group: 'War' }] }) @@ -426,7 +430,7 @@ test('only the task lead assigns, the card is claimed, and the assignee is woken app.emit('b', 'assistant/message', { message: { content: [{ type: 'text', text: 'Which file should the note use?' }] } }) app.emit('b', 'turn/end', { reason: { kind: 'completed' } }) await app.ctx.taskDrive.settled() - assert.equal(app.calls('war').length, 0, 'the own-chat reply is not posted into the group') + assert.equal(app.calls('war').length, posted, 'the own-chat reply is not posted into the group') assert.equal(app.calls('b').length, 1, 'a wake that did not move the board does not start another') })