diff --git a/changelog.d/2855-room-read-take-line-seq.fixed.md b/changelog.d/2855-room-read-take-line-seq.fixed.md new file mode 100644 index 000000000..4763f397d --- /dev/null +++ b/changelog.d/2855-room-read-take-line-seq.fixed.md @@ -0,0 +1 @@ +`read_group_chat` no longer counts a share-seq reservation as a line the room said: a mention that reserves a remote line number writes nothing to the room log, so reserving one after a five-line log used to read `Latest in "Ops" (newest 5 of 6, oldest first):` and grew one phantom line per mention; the header stays `Latest in "Ops" (oldest first):` and the reservation still gets a new number, so a second mention is never a redelivery. A room whose log really was trimmed still reports the lines the cap dropped. See [#2855](https://github.com/ThinkFlowLab/FlyBot/issues/2855). diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index a7b875f80..1d871a715 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -1244,17 +1244,23 @@ export function install(rt) { } : {}), })) }, - // #1126: the next share seq for a mention. Same counter a stored line uses (`room.seq`), so a - // second mention is a new remote line instead of a redelivery of seq 0. + // #1126: the next share seq for a mention. A reservation writes no line, so it must not move + // `room.seq`: that counter is how many lines the room said, and the room read subtracts the + // retained log from it to report what the cap dropped — a reservation on the same counter read + // as a line that had been cut (#2855). `shareSeq` starts above everything already said, so a + // second mention is still a new remote line instead of a redelivery of seq 0. takeLineSeq(roomId) { const room = rt.roomOf?.(String(roomId ?? '')) if (!room) return 1 - if (typeof room.seq !== 'number' || !Number.isFinite(room.seq) || room.seq < 0) { - room.seq = Array.isArray(room.log) ? room.log.length : 0 + if (typeof room.shareSeq !== 'number' || !Number.isFinite(room.shareSeq) || room.shareSeq < 0) { + const said = typeof room.seq === 'number' && Number.isFinite(room.seq) && room.seq >= 0 + ? room.seq + : Array.isArray(room.log) ? room.log.length : 0 + room.shareSeq = said } - room.seq += 1 + room.shareSeq += 1 void Promise.resolve(rt.save?.()).catch(() => {}) - return room.seq + return room.shareSeq }, removeParticipant: (roomId, ref) => rt.removeRoomParticipant?.(roomId, ref), upsertFace: spec => rt.upsertFaceRoom?.(spec), diff --git a/core/test/room-read-trimmed-count.test.js b/core/test/room-read-trimmed-count.test.js index a65c5d815..fac898596 100644 --- a/core/test/room-read-trimmed-count.test.js +++ b/core/test/room-read-trimmed-count.test.js @@ -95,3 +95,20 @@ test('P-684 guard: a room under the cap is byte-identical', async (t) => { const out = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') assert.equal(out, `Latest in "Ops" (oldest first):\n${said.map(l => `${l.who}: ${l.text}`).join('\n')}`) }) + +// RED today (#2855): `takeLineSeq` reserves a remote line number for a co-seat mention and never writes +// a line, so a gap the counter opened is read as a line the cap dropped and every reservation adds one +// phantom line to the header. A read must not report a cut that never happened. +test('#2855: a share-seq reservation writes no line, so the read is byte-identical', async (t) => { + const said = lines(5) + const host = await start(t, said) + const before = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.equal(before, `Latest in "Ops" (oldest first):\n${said.map(l => `${l.who}: ${l.text}`).join('\n')}`) + const reserved = host.rt.flybot.rooms.takeLineSeq('r1') + assert.equal(reserved, 6, 'the reservation continues the room counter') + const after = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.equal(after, before, 'a reservation writes no line, so nothing was left out') + // A second reservation stays above the first and still writes nothing. + assert.equal(host.rt.flybot.rooms.takeLineSeq('r1'), 7) + assert.equal(await host.tool('read_group_chat', { group: 'Ops' }, 'gb'), before) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 21c3731c5..af33350b1 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -433,7 +433,7 @@ has lines (#2790, `core/test/room-read-floor-clamp.test.js`). ### Group reads say what a cap left out (#2780) -- `read_group_chat` shows at most `ROOM_READ_MAX` (30) of the room lines visible to the caller, the newest ones, and the cut used to be silent: `Latest in "" (oldest first):` printed as if those 30 were the whole log, so a Bot asked "didn't we decide X?" read a truncated log as the whole log — the same failure #1906 fixed for the own-chat read. The header now names the cut, `Latest in "" (newest 30 of 45, oldest first):`, and a read that fits the cap stays byte-identical; lines before the member's `joinedSeq` are not counted as left out, and a room whose log was trimmed counts the lines it dropped (the store keeps only the newest `ROOM_LOG_MAX` lines, 60 here and 200 in `room-membership.js`, while `room.seq` keeps counting every line, so 500 lines said and 60 kept read `newest 30 of 500`). Covered by `core/test/room-read-honest-count.test.js`, `core/test/room-read-trimmed-count.test.js` and `core/test/group-work.test.js`. +- `read_group_chat` shows at most `ROOM_READ_MAX` (30) of the room lines visible to the caller, the newest ones, and the cut used to be silent: `Latest in "" (oldest first):` printed as if those 30 were the whole log, so a Bot asked "didn't we decide X?" read a truncated log as the whole log — the same failure #1906 fixed for the own-chat read. The header now names the cut, `Latest in "" (newest 30 of 45, oldest first):`, and a read that fits the cap stays byte-identical; lines before the member's `joinedSeq` are not counted as left out, and a room whose log was trimmed counts the lines it dropped (the store keeps only the newest `ROOM_LOG_MAX` lines, 60 here and 200 in `room-membership.js`, while `room.seq` keeps counting every line, so 500 lines said and 60 kept read `newest 30 of 500`). A share-seq reservation for a co-seat mention writes no line, so it keeps its own counter (`room.shareSeq`, starting above everything already said) and never moves `room.seq`: counting a reservation there read as a line the cap had dropped and grew the header by one per mention (#2855). Covered by `core/test/room-read-honest-count.test.js`, `core/test/room-read-trimmed-count.test.js` and `core/test/group-work.test.js`. ### Chat anchors name their part (#2710) diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 220a4ac5e..f927069dd 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -88,7 +88,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Inbound shared rooms on `flybot.rooms` - `flybot.rooms.applyInbound` turns a Bot Social inbox envelope into the owner's inbound-share projection; `leaveInbound` hides those rooms when the invited Bot is removed or its grant is revoked. Face-to-face methods are unchanged. Still not team mode. -- `flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke })` registers Bot Social's record of a local room's invite tickets (#884; `rt.roomInviteServer`, see core-host's "A local room's invite link"); it returns the unregister function. Registering it also runs `rt.identifyInviteBots` once (#899), so a link minted on a room-only Bot gets that Bot's full identity through `identify`. `mint` answers `undefined` when the server has no `local_room_invites`, and a refusal throws an Error whose `code` is Bot Social's. `flybot.rooms.seatOpenClaims(opts)` reads those claims and seats them without waiting for the accepting knock (#1064; `rt.seatOpenClaims`). `flybot.rooms.ticketPeers()` lists the pairs a link opens (`{ from, to, scope, ticket }`, reach.js `ticketPeers`), so a transport can let a room share through to exactly those, under `scope`, and to nobody else off its roster. `flybot.rooms.takeLineSeq(roomId)` advances that room's `seq` the same way a stored line does and returns the new number (#1126), so a shared-room mention's envelope is a new remote line. `list()` copies an inbound mirror's `sharedFrom.remoteRoomId`, `ticket` and `inviterHandle` for that envelope. +- `flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke })` registers Bot Social's record of a local room's invite tickets (#884; `rt.roomInviteServer`, see core-host's "A local room's invite link"); it returns the unregister function. Registering it also runs `rt.identifyInviteBots` once (#899), so a link minted on a room-only Bot gets that Bot's full identity through `identify`. `mint` answers `undefined` when the server has no `local_room_invites`, and a refusal throws an Error whose `code` is Bot Social's. `flybot.rooms.seatOpenClaims(opts)` reads those claims and seats them without waiting for the accepting knock (#1064; `rt.seatOpenClaims`). `flybot.rooms.ticketPeers()` lists the pairs a link opens (`{ from, to, scope, ticket }`, reach.js `ticketPeers`), so a transport can let a room share through to exactly those, under `scope`, and to nobody else off its roster. `flybot.rooms.takeLineSeq(roomId)` advances that room's own `shareSeq` — starting above everything already said, never `room.seq` — and returns the new number (#1126, #2855), so a shared-room mention's envelope is a new remote line while a reservation writes no line and cannot be read as one the log dropped. `list()` copies an inbound mirror's `sharedFrom.remoteRoomId`, `ticket` and `inviterHandle` for that envelope. ### Face-to-face owner lines diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index a7b875f80..1d871a715 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -1244,17 +1244,23 @@ export function install(rt) { } : {}), })) }, - // #1126: the next share seq for a mention. Same counter a stored line uses (`room.seq`), so a - // second mention is a new remote line instead of a redelivery of seq 0. + // #1126: the next share seq for a mention. A reservation writes no line, so it must not move + // `room.seq`: that counter is how many lines the room said, and the room read subtracts the + // retained log from it to report what the cap dropped — a reservation on the same counter read + // as a line that had been cut (#2855). `shareSeq` starts above everything already said, so a + // second mention is still a new remote line instead of a redelivery of seq 0. takeLineSeq(roomId) { const room = rt.roomOf?.(String(roomId ?? '')) if (!room) return 1 - if (typeof room.seq !== 'number' || !Number.isFinite(room.seq) || room.seq < 0) { - room.seq = Array.isArray(room.log) ? room.log.length : 0 + if (typeof room.shareSeq !== 'number' || !Number.isFinite(room.shareSeq) || room.shareSeq < 0) { + const said = typeof room.seq === 'number' && Number.isFinite(room.seq) && room.seq >= 0 + ? room.seq + : Array.isArray(room.log) ? room.log.length : 0 + room.shareSeq = said } - room.seq += 1 + room.shareSeq += 1 void Promise.resolve(rt.save?.()).catch(() => {}) - return room.seq + return room.shareSeq }, removeParticipant: (roomId, ref) => rt.removeRoomParticipant?.(roomId, ref), upsertFace: spec => rt.upsertFaceRoom?.(spec), diff --git a/patches/ds-bot/files/test/room-read-trimmed-count.test.js b/patches/ds-bot/files/test/room-read-trimmed-count.test.js index a65c5d815..fac898596 100644 --- a/patches/ds-bot/files/test/room-read-trimmed-count.test.js +++ b/patches/ds-bot/files/test/room-read-trimmed-count.test.js @@ -95,3 +95,20 @@ test('P-684 guard: a room under the cap is byte-identical', async (t) => { const out = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') assert.equal(out, `Latest in "Ops" (oldest first):\n${said.map(l => `${l.who}: ${l.text}`).join('\n')}`) }) + +// RED today (#2855): `takeLineSeq` reserves a remote line number for a co-seat mention and never writes +// a line, so a gap the counter opened is read as a line the cap dropped and every reservation adds one +// phantom line to the header. A read must not report a cut that never happened. +test('#2855: a share-seq reservation writes no line, so the read is byte-identical', async (t) => { + const said = lines(5) + const host = await start(t, said) + const before = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.equal(before, `Latest in "Ops" (oldest first):\n${said.map(l => `${l.who}: ${l.text}`).join('\n')}`) + const reserved = host.rt.flybot.rooms.takeLineSeq('r1') + assert.equal(reserved, 6, 'the reservation continues the room counter') + const after = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.equal(after, before, 'a reservation writes no line, so nothing was left out') + // A second reservation stays above the first and still writes nothing. + assert.equal(host.rt.flybot.rooms.takeLineSeq('r1'), 7) + assert.equal(await host.tool('read_group_chat', { group: 'Ops' }, 'gb'), before) +})