From a2b157e744106de819841b883a81f93592532a52 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:08:57 +0800 Subject: [PATCH] fix(rooms): clamp a Bot's own room read to the room's seq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `read_group_chat` resolved its floor from the Bot's participant record and filtered `room.log` on the raw `joinedSeq`, so the #2754 clamp — which the human read paths already use — never ran for a Bot's read. A stored floor above the room's own `room.seq` hid every line from that reader and answered `"" has no messages yet` in a room everyone else still reads. The clamp body is now `clampedFloorOf(room, record, reader)` and the read asks for its seat's floor through `rt.seatFloor`, so the memo, the one-time warning and the no-`seq` fallback are the same one rule. `core/test/ room-read-floor-clamp.test.js` covers the clamp, the window healing on the next line, healthy floors, an empty room and a room saved before `seq`. --- .../2790-read-group-floor-clamp.fixed.md | 1 + core/src/host/groups.js | 4 +- core/src/host/room-membership.js | 10 +- core/test/room-read-floor-clamp.test.js | 111 ++++++++++++++++++ docs/subsystems/core-host.md | 4 +- patches/ds-bot/0012-room-membership.patch | 6 +- .../ds-bot/files/src/host/room-membership.js | 10 +- .../files/test/room-read-floor-clamp.test.js | 111 ++++++++++++++++++ 8 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 changelog.d/2790-read-group-floor-clamp.fixed.md create mode 100644 core/test/room-read-floor-clamp.test.js create mode 100644 patches/ds-bot/files/test/room-read-floor-clamp.test.js diff --git a/changelog.d/2790-read-group-floor-clamp.fixed.md b/changelog.d/2790-read-group-floor-clamp.fixed.md new file mode 100644 index 000000000..a9aa0d9e5 --- /dev/null +++ b/changelog.d/2790-read-group-floor-clamp.fixed.md @@ -0,0 +1 @@ +`read_group_chat` resolved its floor from the Bot's own participant record and filtered the room log on the raw `joinedSeq`, so it never ran the #2754 clamp: a stored floor above the room's own `room.seq` — the shape a host counter left on a participant record before #2612/#2613 stopped the write — hid every line from that reader and answered `"" has no messages yet` in a room the pane and every other reader still showed. The read now asks the membership window for its seat floor (`rt.seatFloor`), which applies the same clamp, memo and one-time warning as the human reads, and it keeps answering from `room.seq` when the room was saved before `seq` existed ([#2790](https://github.com/ThinkFlowLab/FlyBot/issues/2790)). diff --git a/core/src/host/groups.js b/core/src/host/groups.js index d02002077..ce6a73080 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -1042,7 +1042,9 @@ export function install(rt) { if (error) return error ensureRoomLog(room) const botPart = rt.participantsOf?.(room)?.find(p => p.kind === 'bot' && p.id === self) - const from = botPart?.joinedSeq ?? 0 + // A seat's stored floor can sit above the room's own counter (#2754); this read took the raw + // number, so it hid every line and answered "has no messages yet" in a room others still read. + const from = rt.seatFloor?.(room, botPart) ?? botPart?.joinedSeq ?? 0 const kept = room.log.filter(line => (line.seq ?? 0) >= from) const lines = kept.slice(-ROOM_READ_MAX) if (lines.length === 0) return `"${room.name}" has no messages yet.` diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index c2c669ee3..e4953bcc7 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -474,12 +474,12 @@ export function install(rt) { if (typeof room?.seq === 'number') return room.seq return (room?.log ?? []).reduce((max, line) => Math.max(max, line.seq ?? 0), 0) } - const floorOf = (room, actor) => { - const record = floorRecordOf(room, actor, state) + // The clamp over one seat's stored floor. `reader` keys the memo: a human seat and a Bot seat can + // carry the same id, so the kind belongs in the key — a Bot's read uses its own seat. + const clampedFloorOf = (room, record, reader) => { const floor = record?.joinedSeq ?? 0 const joinedAt = record?.joinedAt ?? null const roomId = room?.id ?? '' - const reader = actor?.memberId ?? '' let perRoom = clampedFloors.get(roomId) const memo = perRoom?.get(reader) if (memo) { @@ -496,6 +496,7 @@ export function install(rt) { } return seq } + const floorOf = (room, actor) => clampedFloorOf(room, floorRecordOf(room, actor, state), actor?.memberId ?? '') const linesFrom = (room, actor) => { // The floor is one number for the whole read: a room saved before `seq` existed finds its clamp // target with a reduce over the whole log, so asking per line reduced an n-line log n times. @@ -511,6 +512,9 @@ export function install(rt) { rt.displayNameFor = (memberId, fallback) => displayNameFor(state, memberId, fallback) rt.authorOf = (line, room) => authorOf(line, room, state) rt.linesVisibleTo = (room, actor) => linesFrom(room, actor) + // A Bot reads a room through its own seat, not through the human actor `floorRecordOf` resolves, + // so that read needs the same clamp and memo the human reads get. + rt.seatFloor = (room, seat) => clampedFloorOf(room, seat, seat === undefined ? '' : `${seat.kind}:${seat.id}`) rt.deriveParticipants = room => deriveParticipants(room, state) rt.humanOwnersOf = room => humanOwnersOf(room, state) rt.publishedRemoteOf = handle => publishedRemoteOf(state, handle) diff --git a/core/test/room-read-floor-clamp.test.js b/core/test/room-read-floor-clamp.test.js new file mode 100644 index 000000000..81526bb5a --- /dev/null +++ b/core/test/room-read-floor-clamp.test.js @@ -0,0 +1,111 @@ +// P-676: read_group_chat resolves its own floor from the Bot's participant record and filters on it +// directly, so the #2754 clamp — which the room reads of a human actor already use — never runs for a +// Bot's read. A stored join floor above room.seq then hides every line from that Bot alone: the same +// room answers "has no messages yet" here while the pane and every other reader still show lines. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime, provideFlyBot } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const T0 = 1_760_000_000_000 +const line = (seq, who, text) => ({ seq, who, text, time: T0 + seq * 1000 }) +const lines = count => Array.from({ length: count }, (_, i) => line(i + 1, i % 2 === 0 ? 'Chief' : 'Writer', `message ${i + 1}: the merge queue slipped again`)) +const seat = (id, joinedSeq, joinedAt = T0) => ({ kind: 'bot', id, role: id === 'a' ? 'admin' : 'member', displayName: id === 'a' ? 'Chief' : 'Writer', joinedSeq, joinedAt }) + +async function start(t, log, room = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-p676-')) + const seed = { + version: 1, + mainBotIds: ['a'], + bots: { + a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 }, + b: { id: 'b', name: 'Writer', role: 'Write', sessionId: 'b', createdAt: 2 }, + }, + rooms: { r1: { id: 'r1', name: 'Ops', members: ['a', 'b'], mode: 'admin', admin: 'a', named: true, sessions: { a: 'ga', b: 'gb' }, log, seq: (log.at(-1)?.seq ?? 0), seen: {}, ...room } }, + agents: {}, + exchanges: [], + exchangeTurns: {}, + questions: {}, + prefs: {}, + revision: 0, + } + writeFileSync(join(home, 'state.json'), JSON.stringify(seed)) + const ctx = fakeContext() + const warnings = [] + ctx.logger = { ...(ctx.logger ?? {}), warn: (...args) => warnings.push(args) } + const agentOf = id => ({ session: { id, seq: 0, deriveMessages: () => [] }, calls: [], inbox: { nextStep: [], nextTurn: [], remove: () => false }, followup: () => {}, steer: () => {}, cancel() {} }) + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + provideFlyBot(ctx, rt.flybot) + t.after(async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }) + }) + const tool = (name, args, sessionId) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId } }) + await tool('list_bots', {}, 'a') + return { rt, tool, warnings } +} + +const transcript = kept => kept.map(l => `${l.who}: ${l.text}`).join('\n') + +// RED today: the Bot's seat says it joined at 9 in a room whose counter is 5, and this read answers +// "has no messages yet" although the room holds five lines. +test('P-676: a Bot reads a room whose stored join floor is above room.seq', async (t) => { + const five = lines(5) + const host = await start(t, five, { participants: [seat('a', 0), seat('b', 9)] }) + const out = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.doesNotMatch(out, /has no messages yet/, `a room with lines reads as empty: ${out}`) + // Same window #2754 opens for a human actor: from room.seq, the line the seat would have joined at. + assert.equal(out, `Latest in "Ops" (oldest first):\n${transcript(five.slice(-1))}`) +}) + +// The window heals on read, exactly like the human path: the clamped floor is memoised at the seq of +// the first read, so the line it opened on stays shown once the room's counter moves past it. +test('P-676: the clamped window opens onto lines that arrive afterwards', async (t) => { + const five = lines(5) + const host = await start(t, five, { participants: [seat('a', 0), seat('b', 9)] }) + const first = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.equal(first, `Latest in "Ops" (oldest first):\n${transcript(five.slice(-1))}`) + const room = host.rt.state.rooms.r1 + const next = line(6, 'Writer', 'message 6: the queue moved again') + room.log = [...room.log, next] + room.seq = 6 + const out = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.equal(out, `Latest in "Ops" (oldest first):\n${transcript([...five.slice(-1), next])}`, 'the window opens at the seq of the first read') +}) + +// Healthy floors keep their rows: no clamp, no warning, and the read stays windowed on its seat. +test('P-676 guard: a healthy floor is not clamped', async (t) => { + const five = lines(5) + const host = await start(t, five, { participants: [seat('a', 0), seat('b', 3)] }) + const out = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.equal(out, `Latest in "Ops" (oldest first):\n${transcript(five.slice(2))}`) + const founder = await host.tool('read_group_chat', { group: 'Ops' }, 'ga') + assert.equal(founder, `Latest in "Ops" (oldest first):\n${transcript(five)}`) + assert.equal(host.warnings.filter(w => String(w[0]).includes('join floor above')).length, 0) +}) + +// An empty room keeps its sentence. +test('P-676 guard: an empty room is unchanged', async (t) => { + const empty = await start(t, []) + assert.equal(await empty.tool('read_group_chat', { group: 'Ops' }, 'gb'), '"Ops" has no messages yet.') +}) + +// A room saved before `seq` existed has no counter to clamp to: its stored log's last seq is the +// number `ensureLog` would write next, never 0 (0 would hand the Bot lines from before it joined). +test('P-676: a room without seq clamps to its stored log', async (t) => { + const five = lines(5) + const legacy = await start(t, five, { participants: [seat('a', 0), seat('b', 9)] }) + delete legacy.rt.state.rooms.r1.seq + const out = await legacy.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.equal(out, `Latest in "Ops" (oldest first):\n${transcript(five.slice(-1))}`, 'a room without seq clamps to its stored log') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index cdd0a242f..d31d1ec95 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -398,7 +398,9 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - `syncFaceParticipants` / `faceRemoteRecord` keep optional `color` / `avatar` from the Bot Social member payload on the remote participant. The client seeds a distinct generated look from the handle when those fields are absent (see [core-client](core-client.md)). Face-room REST still does not require a person-grant. - `rt.uiLanguage()` defaults to `English` until a browser reports a locale; the memory tools use it to name the language the owner writes in on a fresh home (#173). - Face titles (#172): `core/src/shared/face-title.js` lists every participant owner in stable handle order and `isAutoFaceTitle` marks auto titles, so a roster change refreshes an auto-named room but never a renamed one; both homes derive the same title. A hosted face room that falls off the Bot Social list keeps its local transcript (poll cleanup no longer calls `dropFace`), so Past rooms **Open** can reopen it (#171). -- A member that left and joins again is the same participant record with `leftSeq` cleared and a new `joinedSeq` — not a second live row. `upsertFaceRoom` treats `startedAt` (or an explicit `open`) as an open room so the Friends' Bots card does not stay on the matching countdown after **Enter chat**. A later snapshot whose status is not `open`, `closed`, or `left`, and that omits `startedAt`, does not demote a room that is already open, closed, or left back to `candidate` (#1072). The membership window clamps a stored `joinedSeq` above the room's own `room.seq` to `room.seq` on the read path (to the log's last seq for a room saved before `seq` existed), warns once per room, and never rewrites the saved state; the clamped window is memoised per room and reader and stays open once `room.seq` reaches and passes the stored floor, a rejoin being the one thing that drops it (#2754). The floor is resolved once per read (#2766): `linesFrom` works it out before the filter rather than asking `floorOf` for every line, so a room saved before `seq` existed — whose target is a reduce over the whole log — reads in linear instead of quadratic time, with the same visible lines and the same one-time warning. Covered by `core/test/room-floor-once.test.js`. +- A member that left and joins again is the same participant record with `leftSeq` cleared and a new `joinedSeq` — not a second live row. `upsertFaceRoom` treats `startedAt` (or an explicit `open`) as an open room so the Friends' Bots card does not stay on the matching countdown after **Enter chat**. A later snapshot whose status is not `open`, `closed`, or `left`, and that omits `startedAt`, does not demote a room that is already open, closed, or left back to `candidate` (#1072). The membership window clamps a stored `joinedSeq` above the room's own `room.seq` to `room.seq` on the read path (to the log's last seq for a room saved before `seq` existed), warns once per room, and never rewrites the saved state; the clamped window is memoised per room and reader and stays open once `room.seq` reaches and passes the stored floor, a rejoin being the one thing that drops it (#2754). The floor is resolved once per read (#2766): `linesFrom` works it out before the filter rather than asking `floorOf` for every line, so a room saved before `seq` existed — whose target is a reduce over the whole log — reads in linear instead of quadratic time, with the same visible lines and the same one-time warning. Covered by `core/test/room-floor-once.test.js`. A Bot's own `read_group_chat` takes the same clamp: it resolves its floor from its +seat and asks `rt.seatFloor`, so a stored floor above `room.seq` cannot answer `"" has no messages yet` in a room that still +has lines (#2790, `core/test/room-read-floor-clamp.test.js`). ### FlyBot display copy diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 52f69a1ef..41c288b71 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -2118,14 +2118,16 @@ diff --git a/src/host/groups.js b/src/host/groups.js }, })) -@@ -539,8 +539,14 @@ export function install(rt) { +@@ -539,8 +539,16 @@ export function install(rt) { const { room, error } = rt.pickRoom(args.group, self) if (error) return error ensureRoomLog(room) - const lines = room.log.slice(-ROOM_READ_MAX) - return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}` + const botPart = rt.participantsOf?.(room)?.find(p => p.kind === 'bot' && p.id === self) -+ const from = botPart?.joinedSeq ?? 0 ++ // A seat's stored floor can sit above the room's own counter (#2754); this read took the raw ++ // number, so it hid every line and answered "has no messages yet" in a room others still read. ++ const from = rt.seatFloor?.(room, botPart) ?? botPart?.joinedSeq ?? 0 + const kept = room.log.filter(line => (line.seq ?? 0) >= from) + const lines = kept.slice(-ROOM_READ_MAX) + if (lines.length === 0) return `"${room.name}" has no messages yet.` diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index c2c669ee3..e4953bcc7 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -474,12 +474,12 @@ export function install(rt) { if (typeof room?.seq === 'number') return room.seq return (room?.log ?? []).reduce((max, line) => Math.max(max, line.seq ?? 0), 0) } - const floorOf = (room, actor) => { - const record = floorRecordOf(room, actor, state) + // The clamp over one seat's stored floor. `reader` keys the memo: a human seat and a Bot seat can + // carry the same id, so the kind belongs in the key — a Bot's read uses its own seat. + const clampedFloorOf = (room, record, reader) => { const floor = record?.joinedSeq ?? 0 const joinedAt = record?.joinedAt ?? null const roomId = room?.id ?? '' - const reader = actor?.memberId ?? '' let perRoom = clampedFloors.get(roomId) const memo = perRoom?.get(reader) if (memo) { @@ -496,6 +496,7 @@ export function install(rt) { } return seq } + const floorOf = (room, actor) => clampedFloorOf(room, floorRecordOf(room, actor, state), actor?.memberId ?? '') const linesFrom = (room, actor) => { // The floor is one number for the whole read: a room saved before `seq` existed finds its clamp // target with a reduce over the whole log, so asking per line reduced an n-line log n times. @@ -511,6 +512,9 @@ export function install(rt) { rt.displayNameFor = (memberId, fallback) => displayNameFor(state, memberId, fallback) rt.authorOf = (line, room) => authorOf(line, room, state) rt.linesVisibleTo = (room, actor) => linesFrom(room, actor) + // A Bot reads a room through its own seat, not through the human actor `floorRecordOf` resolves, + // so that read needs the same clamp and memo the human reads get. + rt.seatFloor = (room, seat) => clampedFloorOf(room, seat, seat === undefined ? '' : `${seat.kind}:${seat.id}`) rt.deriveParticipants = room => deriveParticipants(room, state) rt.humanOwnersOf = room => humanOwnersOf(room, state) rt.publishedRemoteOf = handle => publishedRemoteOf(state, handle) diff --git a/patches/ds-bot/files/test/room-read-floor-clamp.test.js b/patches/ds-bot/files/test/room-read-floor-clamp.test.js new file mode 100644 index 000000000..81526bb5a --- /dev/null +++ b/patches/ds-bot/files/test/room-read-floor-clamp.test.js @@ -0,0 +1,111 @@ +// P-676: read_group_chat resolves its own floor from the Bot's participant record and filters on it +// directly, so the #2754 clamp — which the room reads of a human actor already use — never runs for a +// Bot's read. A stored join floor above room.seq then hides every line from that Bot alone: the same +// room answers "has no messages yet" here while the pane and every other reader still show lines. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime, provideFlyBot } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const T0 = 1_760_000_000_000 +const line = (seq, who, text) => ({ seq, who, text, time: T0 + seq * 1000 }) +const lines = count => Array.from({ length: count }, (_, i) => line(i + 1, i % 2 === 0 ? 'Chief' : 'Writer', `message ${i + 1}: the merge queue slipped again`)) +const seat = (id, joinedSeq, joinedAt = T0) => ({ kind: 'bot', id, role: id === 'a' ? 'admin' : 'member', displayName: id === 'a' ? 'Chief' : 'Writer', joinedSeq, joinedAt }) + +async function start(t, log, room = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-p676-')) + const seed = { + version: 1, + mainBotIds: ['a'], + bots: { + a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 }, + b: { id: 'b', name: 'Writer', role: 'Write', sessionId: 'b', createdAt: 2 }, + }, + rooms: { r1: { id: 'r1', name: 'Ops', members: ['a', 'b'], mode: 'admin', admin: 'a', named: true, sessions: { a: 'ga', b: 'gb' }, log, seq: (log.at(-1)?.seq ?? 0), seen: {}, ...room } }, + agents: {}, + exchanges: [], + exchangeTurns: {}, + questions: {}, + prefs: {}, + revision: 0, + } + writeFileSync(join(home, 'state.json'), JSON.stringify(seed)) + const ctx = fakeContext() + const warnings = [] + ctx.logger = { ...(ctx.logger ?? {}), warn: (...args) => warnings.push(args) } + const agentOf = id => ({ session: { id, seq: 0, deriveMessages: () => [] }, calls: [], inbox: { nextStep: [], nextTurn: [], remove: () => false }, followup: () => {}, steer: () => {}, cancel() {} }) + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + provideFlyBot(ctx, rt.flybot) + t.after(async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }) + }) + const tool = (name, args, sessionId) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId } }) + await tool('list_bots', {}, 'a') + return { rt, tool, warnings } +} + +const transcript = kept => kept.map(l => `${l.who}: ${l.text}`).join('\n') + +// RED today: the Bot's seat says it joined at 9 in a room whose counter is 5, and this read answers +// "has no messages yet" although the room holds five lines. +test('P-676: a Bot reads a room whose stored join floor is above room.seq', async (t) => { + const five = lines(5) + const host = await start(t, five, { participants: [seat('a', 0), seat('b', 9)] }) + const out = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.doesNotMatch(out, /has no messages yet/, `a room with lines reads as empty: ${out}`) + // Same window #2754 opens for a human actor: from room.seq, the line the seat would have joined at. + assert.equal(out, `Latest in "Ops" (oldest first):\n${transcript(five.slice(-1))}`) +}) + +// The window heals on read, exactly like the human path: the clamped floor is memoised at the seq of +// the first read, so the line it opened on stays shown once the room's counter moves past it. +test('P-676: the clamped window opens onto lines that arrive afterwards', async (t) => { + const five = lines(5) + const host = await start(t, five, { participants: [seat('a', 0), seat('b', 9)] }) + const first = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.equal(first, `Latest in "Ops" (oldest first):\n${transcript(five.slice(-1))}`) + const room = host.rt.state.rooms.r1 + const next = line(6, 'Writer', 'message 6: the queue moved again') + room.log = [...room.log, next] + room.seq = 6 + const out = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.equal(out, `Latest in "Ops" (oldest first):\n${transcript([...five.slice(-1), next])}`, 'the window opens at the seq of the first read') +}) + +// Healthy floors keep their rows: no clamp, no warning, and the read stays windowed on its seat. +test('P-676 guard: a healthy floor is not clamped', async (t) => { + const five = lines(5) + const host = await start(t, five, { participants: [seat('a', 0), seat('b', 3)] }) + const out = await host.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.equal(out, `Latest in "Ops" (oldest first):\n${transcript(five.slice(2))}`) + const founder = await host.tool('read_group_chat', { group: 'Ops' }, 'ga') + assert.equal(founder, `Latest in "Ops" (oldest first):\n${transcript(five)}`) + assert.equal(host.warnings.filter(w => String(w[0]).includes('join floor above')).length, 0) +}) + +// An empty room keeps its sentence. +test('P-676 guard: an empty room is unchanged', async (t) => { + const empty = await start(t, []) + assert.equal(await empty.tool('read_group_chat', { group: 'Ops' }, 'gb'), '"Ops" has no messages yet.') +}) + +// A room saved before `seq` existed has no counter to clamp to: its stored log's last seq is the +// number `ensureLog` would write next, never 0 (0 would hand the Bot lines from before it joined). +test('P-676: a room without seq clamps to its stored log', async (t) => { + const five = lines(5) + const legacy = await start(t, five, { participants: [seat('a', 0), seat('b', 9)] }) + delete legacy.rt.state.rooms.r1.seq + const out = await legacy.tool('read_group_chat', { group: 'Ops' }, 'gb') + assert.equal(out, `Latest in "Ops" (oldest first):\n${transcript(five.slice(-1))}`, 'a room without seq clamps to its stored log') +})