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
3 changes: 3 additions & 0 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ function faceStatusFrom(spec, current) {
const status = spec?.status
if (status === 'open' || status === 'closed' || status === 'left') return status
if (spec?.startedAt) return 'open'
// A later snapshot that says "candidate" and omits startedAt must not put an
// open, closed, or left room back on the waiting lobby (#1072).
if (current === 'open' || current === 'closed' || current === 'left') return current
if (status) return status
return current || 'candidate'
}
Expand Down
27 changes: 27 additions & 0 deletions core/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,33 @@ test('face-to-face rejoin revives a left participant instead of duplicating', as
assert.equal(room.face.status, 'open')
})

test('#1072: an open face room is not demoted to the waiting lobby when a snapshot omits startedAt', async (t) => {
const { rt } = host(t, TEAM)
await rt.load()
const spec = {
roomId: 'frm_1072',
localId: 'face-1072',
status: 'open',
startedAt: '2026-10-25T01:00:00.000Z',
localBots: [{ id: 'spec-a', handle: '@writer.alice.home', name: 'Writer A' }],
}
const both = [
{ handle: '@writer.alice.home', displayName: 'Writer', ownerName: 'Alice', published: false },
{ handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false },
]
await rt.upsertFaceRoom({ ...spec, members: both })
const room = rt.state.rooms['face-1072']
assert.equal(room.face.status, 'open')
assert.equal(room.id, 'face-1072')
await rt.upsertFaceRoom({ roomId: 'frm_1072', status: 'candidate', members: [both[0]] })
assert.equal(room.face.status, 'open')
assert.equal(room.id, 'face-1072')
const peer = room.participants.filter(p => p.handle === '@coder.bob.home')
assert.equal(peer.length, 1)
assert.ok(peer[0].leftSeq !== undefined)
assert.equal(room.participants.filter(p => p.handle === '@writer.alice.home' && p.leftSeq === undefined).length, 1)
})

test('#304: a face room keeps the number it was joined by in its title, through polls and leave', async (t) => {
const { rt } = host(t, TEAM)
await rt.load()
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
subsystem: core-host
owned_paths:
Expand Down Expand Up @@ -226,7 +226,7 @@
- `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 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).

### FlyBot display copy

Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
subsystem: social
owned_paths:
Expand Down Expand Up @@ -189,7 +189,7 @@

### Inbound shared groups

- Bot Social has no FlyBot-group record (`list_conversations` is 1:1; inbox is `message.received` after a send). The plugin therefore carries a `FLYBOT-ROOM-SHARE` envelope on `send_as_member` text. `pollOnce` calls `flybot.rooms.applyInbound` before 1:1 chat delivery, so the Bot's owner sees the group as soon as they are added. `via: 'room'` / `via: 'room-share'` skip the social outbound hold (the add-to-group click is consent), and a send the host sees come back `pending_approval` clears Bot Social's own blanket hold by itself (`autoSendRoomEnvelope`, `holdIsBlanket()` on `hold_reason` of #865, the allow-list of #737): the invite and the room's lines leave with no card, while a hold with a finding of its own keeps its card. That card reads one sentence about the envelope (`Invite <Bot> to the group "<name>"`, `Group message to <Bot>: <line>`), never the JSON or a local `session-…` id (#847). The invited owner's Bot replies still go through `pending_approval`. A roster fetch that no longer lists a remote, or `social/revoke-grant`, leaves those rooms / hides inbound projections.
- Bot Social has no FlyBot-group record (`list_conversations` is 1:1; inbox is `message.received` after a send). The plugin therefore carries a `FLYBOT-ROOM-SHARE` envelope on `send_as_member` text. `pollOnce` calls `flybot.rooms.applyInbound` before 1:1 chat delivery, so the Bot's owner sees the group as soon as they are added. `via: 'room'` / `via: 'room-share'` skip the social outbound hold (the add-to-group click is consent), and a send the host sees come back `pending_approval` clears Bot Social's own blanket hold by itself (`autoSendRoomEnvelope`, `holdIsBlanket()` on `hold_reason` of #865, the allow-list of #737): the invite and the room's lines leave with no card, while a hold with a finding of its own keeps its card. That card reads one sentence about the envelope (`Invite <Bot> to the group "<name>"`, `Group message to <Bot>: <line>`), never the JSON or a local `session-…` id (#847). The invited owner's Bot replies still go through `pending_approval`. A roster fetch that no longer lists a remote, or `social/revoke-grant`, leaves those rooms / hides inbound projections. Revoking a grant also leaves shared face rooms that still seat that grantee (`leaveSharedFaceRooms`, before the grant delete): the only server call is leaving this home's own seats, so the other home sees a normal departure and does not stay on the waiting lobby. A leave that fails is warned and the revoke still completes. An open room (one already started) whose next snapshot is `candidate` or drops every other home is closed with `peersGone` instead of returning to **Waiting for others**; a room that was only ever a candidate lobby stays one until its window runs out. Peer seats are soft-left (`leftSeq` / `leftAt`), not deleted (#1072).

### A local room's link for a stranger (#883, #884)

Expand Down
3 changes: 3 additions & 0 deletions patches/ds-bot/files/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ function faceStatusFrom(spec, current) {
const status = spec?.status
if (status === 'open' || status === 'closed' || status === 'left') return status
if (spec?.startedAt) return 'open'
// A later snapshot that says "candidate" and omits startedAt must not put an
// open, closed, or left room back on the waiting lobby (#1072).
if (current === 'open' || current === 'closed' || current === 'left') return current
if (status) return status
return current || 'candidate'
}
Expand Down
27 changes: 27 additions & 0 deletions patches/ds-bot/files/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,33 @@ test('face-to-face rejoin revives a left participant instead of duplicating', as
assert.equal(room.face.status, 'open')
})

test('#1072: an open face room is not demoted to the waiting lobby when a snapshot omits startedAt', async (t) => {
const { rt } = host(t, TEAM)
await rt.load()
const spec = {
roomId: 'frm_1072',
localId: 'face-1072',
status: 'open',
startedAt: '2026-10-25T01:00:00.000Z',
localBots: [{ id: 'spec-a', handle: '@writer.alice.home', name: 'Writer A' }],
}
const both = [
{ handle: '@writer.alice.home', displayName: 'Writer', ownerName: 'Alice', published: false },
{ handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false },
]
await rt.upsertFaceRoom({ ...spec, members: both })
const room = rt.state.rooms['face-1072']
assert.equal(room.face.status, 'open')
assert.equal(room.id, 'face-1072')
await rt.upsertFaceRoom({ roomId: 'frm_1072', status: 'candidate', members: [both[0]] })
assert.equal(room.face.status, 'open')
assert.equal(room.id, 'face-1072')
const peer = room.participants.filter(p => p.handle === '@coder.bob.home')
assert.equal(peer.length, 1)
assert.ok(peer[0].leftSeq !== undefined)
assert.equal(room.participants.filter(p => p.handle === '@writer.alice.home' && p.leftSeq === undefined).length, 1)
})

test('#304: a face room keeps the number it was joined by in its title, through polls and leave', async (t) => {
const { rt } = host(t, TEAM)
await rt.load()
Expand Down
36 changes: 31 additions & 5 deletions plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -2667,6 +2667,24 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
void remoteBots({ fresh: true }).catch(() => {})
return result
}))
// Face rooms that still seat this grantee. The only server call is leaving our own seats;
// the other home then sees a normal departure. A room already left or closed is skipped,
// and so is one this home is no longer hosting.
async function leaveSharedFaceRooms(bot, grantee) {
if (!grantee || typeof flybot.rooms?.list !== 'function') return
for (const room of roomsUsing(bot.id, grantee)) {
if (room.origin !== 'face-to-face') continue
const meta = Object.entries(settings.faceRooms ?? {}).find(([, item]) => item?.localRoomId === room.id)
if (!meta) continue
const status = flybot.rooms.ofFace?.(meta[0])?.face?.status
if (status === 'left' || status === 'closed') continue
try {
await leaveFaceRoom({ roomId: room.id, faceRoomId: meta[0] })
} catch (error) {
warn('shared face room not left on revoke', error)
}
}
}
// One grant's delete; the caller fences it and refreshes the roster once afterwards.
async function revokeOne(bot, { grantId, relationshipId, grantee }) {
const current = flybot.grants?.of?.(bot.id) ?? []
Expand All @@ -2676,6 +2694,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
|| (grantee && norm(grant.grantee) === norm(grantee)))
if (!target && !grantId && !relationshipId) throw new Error('which grant?')
const member = memberOf(bot.id)
// The face room seats that person with no person-grant on the room, so deleting the
// grant leaves the other home polling a lobby. Leave first, while the room is still
// healthy, and a failed leave must not block the revoke (#1072).
await leaveSharedFaceRooms(bot, target?.grantee || (typeof grantee === 'string' ? grantee : ''))
await api.revokeGrant({
grantId: grantId || target?.id,
relationshipId: relationshipId || target?.relationshipId,
Expand Down Expand Up @@ -3497,15 +3519,19 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
if (held === 'closed' && status === 'open') return { status: held, peersGone: true }
if (held === 'left' || held === 'closed') return { status: held }
const expiresAt = snapshot?.expiresAt || hosted?.face?.expiresAt
const locals = new Set(bringable().map(bot => bot.handle).filter(Boolean))
const previousRemotes = (hosted?.participants ?? []).filter(p => p.leftSeq === undefined && p.remote === true)
const currentRemotes = (snapshot?.members ?? []).filter(member => member.handle && !locals.has(norm(member.handle)))
// Every other home left an open room: it has ended here, including when the next snapshot
// falls back to the waiting lobby (candidate, startedAt omitted). A room that was only ever
// a candidate lobby stays one until its window runs out (#1072, #304). Checked before the
// expired-candidate return so a started room still leaves on Bot Social.
const wasOpen = held === 'open' || Boolean(hosted?.face?.startedAt)
if ((status === 'open' || wasOpen) && previousRemotes.length > 0 && currentRemotes.length === 0) return { status: 'closed', peersGone: true }
if (status === 'candidate' && expiresAt) {
const at = Date.parse(expiresAt)
if (Number.isFinite(at) && at <= now()) return { status: 'closed' }
}
const locals = new Set(bringable().map(bot => bot.handle).filter(Boolean))
const previousRemotes = (hosted?.participants ?? []).filter(p => p.leftSeq === undefined && p.remote === true)
const currentRemotes = (snapshot?.members ?? []).filter(member => member.handle && !locals.has(norm(member.handle)))
// Every other home left: the room has ended here, but this home did not leave it (#304).
if (status === 'open' && previousRemotes.length > 0 && currentRemotes.length === 0) return { status: 'closed', peersGone: true }
// The joining window ran out and nobody else ever came (#458): the room ends, it is no open chat with yourself.
const started = Date.parse(snapshot?.startedAt || '')
const deadline = Date.parse(expiresAt || '')
Expand Down
64 changes: 64 additions & 0 deletions plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import assert from 'node:assert/strict'
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
Expand Down Expand Up @@ -2483,6 +2483,70 @@
assert.equal(leaves.length, 2, 'not after it succeeded')
})

test('#1072: revoking a grant leaves the shared face room before the grant is deleted', async (t) => {
const { ctx, call, stop } = await team()
t.after(stop)
const server = withFaceRooms(mockServer())
server.tools.revoke_bot_grant = () => ({ ok: true })
const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, {
client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }),
home: { id: 'h1', handle: '@danyang.home' },
})
t.after(() => social.dispose())
await grantTeam(ctx.flybot)
await social.registerAll()
const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief')
server.face.peer('123456')
const joined = (await call('flybot/social/face-join', { start: true, code: '123456', botId: chief.id })).value
await call('flybot/social/face-start', { faceRoomId: 'frm_123456' })
await ctx.flybot.grants.set(chief.id, [{ id: 'g-face', grantee: '@coder.bob.home', name: 'Bob' }])
const before = server.calls.length
const revoked = await call('flybot/social/revoke-grant', { botId: chief.id, grantId: 'g-face', grantee: '@coder.bob.home' })
assert.equal(revoked.ok, true)
const slice = server.calls.slice(before)
const leaveAt = slice.findIndex(c => c.name === 'REST POST /frm_123456/leave')
const revokeAt = slice.findIndex(c => c.name === 'revoke_bot_grant')
assert.ok(leaveAt >= 0, 'the shared face room is left on Bot Social')
assert.ok(revokeAt > leaveAt, 'the leave is recorded before the grant is deleted')
const hosted = ctx.flybot.rooms.ofFace('frm_123456')
assert.equal(hosted.id, joined.roomId, 'the same hosted room')
assert.equal(hosted.face.status, 'left')
const peer = hosted.participants.filter(p => p.handle === '@coder.bob.home')
assert.equal(peer.length, 1, 'the peer seat stays on the record')
assert.equal((ctx.flybot.grants.of(chief.id) ?? []).some(grant => grant.id === 'g-face'), false)
})

test('#1072: an open face room whose next snapshot is a waiting lobby closes instead of sticking', async (t) => {
const { ctx, call, stop } = await team()
t.after(stop)
const server = withFaceRooms(mockServer())
const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, {
client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }),
home: { id: 'h1', handle: '@danyang.home' },
})
t.after(() => social.dispose())
await grantTeam(ctx.flybot)
await social.registerAll()
server.face.peer('123456')
const joined = (await call('flybot/social/face-join', { start: true, code: '123456' })).value
await call('flybot/social/face-start', { faceRoomId: 'frm_123456' })
const room = server.face.rooms.get('frm_123456')
room.status = 'candidate'
delete room.started_at
room.expires_at = new Date(Date.now() + 10 * 60_000).toISOString()
room.members = room.members.filter(member => member.handle !== '@coder.bob.home')
const listed = (await call('flybot/social/face-rooms', { poll: true })).value
const past = listed.rooms.find(item => item.faceRoomId === 'frm_123456')
const hosted = ctx.flybot.rooms.ofFace('frm_123456')
assert.equal(hosted.id, joined.roomId)
assert.equal(hosted.face.status, 'closed')
assert.equal(past.status, 'closed')
const peer = hosted.participants.filter(p => p.handle === '@coder.bob.home')
assert.equal(peer.length, 1)
assert.ok(peer[0].leftSeq !== undefined)
assert.equal(hosted.participants.filter(p => p.handle === '@chief.danyang.home').length, 1)
})

test('face and inbox polls speed up while a live face room is open', async (t) => {
const { ctx, call, stop } = await team()
t.after(stop)
Expand Down
Loading