Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2790-read-group-floor-clamp.fixed.md
Original file line number Diff line number Diff line change
@@ -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 `"<room>" 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)).
4 changes: 3 additions & 1 deletion core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
Expand Down
10 changes: 7 additions & 3 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
Expand All @@ -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)
Expand Down
111 changes: 111 additions & 0 deletions core/test/room-read-floor-clamp.test.js
Original file line number Diff line number Diff line change
@@ -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')
})
4 changes: 3 additions & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<room>" has no messages yet` in a room that still
has lines (#2790, `core/test/room-read-floor-clamp.test.js`).

### FlyBot display copy

Expand Down
6 changes: 4 additions & 2 deletions patches/ds-bot/0012-room-membership.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
Expand Down
10 changes: 7 additions & 3 deletions patches/ds-bot/files/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
Expand All @@ -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)
Expand Down
Loading
Loading