From 64a1873aca55b2bed7ba01b0b2ae47002c2de0cc Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:11:12 +0800 Subject: [PATCH] fix(face-rooms): route the owner's own typed line (drained from the Session inbox) by intent too (#299) --- changelog.d/299-route-live-drain.fixed.md | 1 + core/src/host/groups.js | 38 +++++++++++++---- core/test/rounds.test.js | 48 ++++++++++++++++++++++ docs/subsystems/core-host.md | 2 +- patches/ds-bot/0005-social-seams.patch | 50 ++++++++++++++++++++++- patches/ds-bot/0012-room-membership.patch | 48 +++++++++++++++++----- 6 files changed, 166 insertions(+), 21 deletions(-) create mode 100644 changelog.d/299-route-live-drain.fixed.md diff --git a/changelog.d/299-route-live-drain.fixed.md b/changelog.d/299-route-live-drain.fixed.md new file mode 100644 index 000000000..76eae4699 --- /dev/null +++ b/changelog.d/299-route-live-drain.fixed.md @@ -0,0 +1 @@ +Face-to-face rooms: the one-answer rule for a plain line was not applied to the lines you type yourself (they reach the round through the Session inbox), so every Bot still answered. They are now routed like a friend's line: one answer, the last speaker first for a follow-up, and the first Bot as fallback. (#299) diff --git a/core/src/host/groups.js b/core/src/host/groups.js index 800d969b9..304fc3bbb 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -278,14 +278,26 @@ export function install(rt) { if (lines.length === 0) return 0 const before = round.slots.length const members = room.members.map(rt.botOf).filter(Boolean) - // A line the user adds mid-round is answered by the rule for its own opening, so this round stops routing. + // The owner's own typed line reaches a face round through the Session inbox, not as the round's opener (the opener + // is the quiet "name: text" notice), so it is routed here (#299). A line added to a round that already routes + // is answered by the rule for its own opening, so that round stops routing. + const starting = round.route === undefined && round.slots.length === 0 && lines.length === 1 delete round.route for (const line of lines) { if (!takePosted(room.id, line.text)) appendRoom(room, rt.authoredRoomLine?.(room, { who: line.authorName || 'User', text: line.text, authorId: line.authorId, authorKind: line.authorKind || 'human' }) ?? { who: 'User', text: line.text }) round.replies.push({ kind: 'user', text: line.text, time: Date.now(), authorId: line.authorId, authorKind: line.authorKind, name: line.authorName }) const { all, found } = mentionsIn(line.text, members) const note = all || found.length > 0 ? 'the user mentioned you' : 'new message from the user' - callMembers(round, turns, index - 1, openingSpeakers(room, { text: line.text }), note, ROOM_ADMIN_TURNS_EACH) + let speakers = openingSpeakers(room, { text: line.text }) + if (starting) { + const route = routeFor(room, { text: line.text }, round.startSeq) + if (route) { + round.route = route + round.single = true + speakers = lastFirst(speakers, route) + } + } + callMembers(round, turns, index - 1, speakers, note, ROOM_ADMIN_TURNS_EACH) } // The user spoke in this room, so Bots may start their own rounds again. room.botRounds = 0 @@ -294,6 +306,20 @@ export function install(rt) { return round.slots.length - before } + // The routing of a person's plain line in a face-to-face room (not @all): who decides, whether it is to everyone, + // who spoke last, and whether the user must be answered if every Bot passes (the owner's own line, not a friend's). + function routeFor(room, opener, startSeq) { + if (!isPlainFaceLine(room, opener) || opener.everyone) return undefined + const broadcast = ROOM_EVERYONE.test(opener.text ?? '') + return { broadcast, fallback: opener.plain !== true, last: broadcast ? undefined : recentSpeaker(room, startSeq) } + } + // The Bot that spoke last is asked first. + function lastFirst(speakers, route) { + const at = route?.last && !route.last.remote ? speakers.findIndex(bot => bot.id === route.last.id) : -1 + if (at > 0) speakers.unshift(...speakers.splice(at, 1)) + return speakers + } + // A routed, non-broadcast round where a Bot already posted, is working on it or waits for the owner's approval. const answeredAlready = round => round.route !== undefined && !round.route.broadcast && round.route.forced === undefined && round.slots.some(slot => slot.status === 'posted' || slot.status === 'working' || slot.status === 'waiting-approval') @@ -457,12 +483,8 @@ export function install(rt) { const named = opener.botId === undefined ? [] : mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean)).found // A person's plain line (not @all) in a face-to-face room: the Bots decide who answers (#299). The // owner's own line is never left unanswered; a friend's attested line is answered by their home too. - const routed = isPlainFaceLine(room, opener) && !opener.everyone - const broadcast = routed && ROOM_EVERYONE.test(opener.text ?? '') - const route = routed ? { broadcast, fallback: opener.plain !== true, last: broadcast ? undefined : recentSpeaker(room, startSeq) } : undefined - const speakers = openingSpeakers(room, opener) - const lastAt = route?.last && !route.last.remote ? speakers.findIndex(bot => bot.id === route.last.id) : -1 - if (lastAt > 0) speakers.unshift(...speakers.splice(lastAt, 1)) + const route = routeFor(room, opener, startSeq) + const speakers = lastFirst(openingSpeakers(room, opener), route) const round = { roomId: room.id, members: room.members, diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index 6a105f9d7..26664c8b2 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -832,3 +832,51 @@ test('#299: the first Bot that answers a routed line ends it; a line to everyone await answer(b.sessionId, b.message.source.exchangeId, 'mine too', 3) await all }) + +// How the owner's own line really reaches a face round: `send` logs it and posts the quiet "name: text" notice, and the +// composer's message waits in the Session inbox, so the round opens on the notice and drains the line (#299, live finding). +test('#299: the owner\'s own line, drained from the Session inbox, is routed: one answer, last speaker first, first Bot as fallback', async (t) => { + const env = start() + const { call, emit, relay, answer, stop, sent } = env + t.after(stop) + const { hosted, writer, chief } = await routedRoom(env, 'drain') + const say = async (text, id) => { + await call('send', { sessionId: hosted.id, text }) + await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: 'face-notice', senderName: 'Me', quiet: true, notice: true, human: { authorId: 'owner-1', name: 'Me' } }, content: [{ type: 'text', text: `Me: ${text}` }] } }) + return { running: relay(hosted.id, id) } + } + const { running: one } = await say('文档怎么写', 'drain-1') + const a = await turnFor(sent) + assert.match(promptOf(a), /names no Bot/, 'the drained line is routed') + await answer(a.sessionId, a.message.source.exchangeId, '[PASS]', 1) + const b = await turnFor(sent, a) + assert.equal(b.sessionId, hosted.sessions[writer]) + await answer(b.sessionId, b.message.source.exchangeId, 'Start with an outline. @Chief ok?', 2) + const first = JSON.parse(await one) + assert.deepEqual(first.replies.filter(reply => reply.kind === undefined).map(reply => reply.botId), [writer]) + assert.equal(roomTurns(sent).length, 2, 'the answer\'s @ starts no follow-up') + + // Follow-up: the Writer is first, answers, and the Chief is not asked. + sent.length = 0 + const { running: two } = await say('那你刚才说的大纲具体怎么写', 'drain-2') + const c = await turnFor(sent) + assert.equal(c.sessionId, hosted.sessions[writer]) + await answer(c.sessionId, c.message.source.exchangeId, 'Three headings.', 3) + const second = JSON.parse(await two) + assert.deepEqual(second.replies.filter(reply => reply.kind === undefined).map(reply => reply.text), ['Three headings.']) + assert.equal(roomTurns(sent).length, 1) + + // Everyone passes: the first Bot (the Writer spoke last, so it is first) answers. + sent.length = 0 + const { running: three } = await say('随便问问', 'drain-3') + const d = await turnFor(sent) + await answer(d.sessionId, d.message.source.exchangeId, '[PASS]', 4) + const e = await turnFor(sent, d) + await answer(e.sessionId, e.message.source.exchangeId, '[PASS]', 5) + const f = await turnFor(sent, d, e) + assert.ok(f, 'nobody answered, so the first Bot is asked again') + assert.match(promptOf(f), /nobody else answered/) + await answer(f.sessionId, f.message.source.exchangeId, 'Fine.', 6) + await three + assert.ok(chief) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index d67888039..d13ed90b9 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -111,7 +111,7 @@ credentials and team mode remain separate work under #55/#198. - A face room's auto title (`faceRoomFallbackTitle`) is built from every member the room has had (the poll's snapshot plus stored participants) and the creation time (`faceRoomCreatedAt`: `spec.createdAt`, else the joining deadline minus the 10-minute window, else the start; #194), so every home shows the same title and a member who left is not dropped from it (#182). - Another member's own face-room line arrives through `flybot.rooms.postHuman` (#180): a quiet `postToRoom` post carrying `source.human { authorId, name }`. `group_relay` turns it into a round reply `{ kind: 'user', other: true, name, authorId }` (not the grey `notice`), so it draws as a left-hand bubble from that person; it asks no Bot to answer. `postNotice` stays for system events (joined, left, started). Covered by `core/test/rounds.test.js`. - `flybot.chatApprovals.changed()` saves the team (moving the revision) so `bot.pendingCount` reaches the browser at once when a hold arrives or is decided (#226). Covered by `core/test/flybot.test.js`. -- The DSH composer still prompts the group Session (`user/message` → `group_relay`). Face-to-face turn rule (#167, changed in #287 and #295), `openingSpeakers`: a **plain** line (no `@` naming a Bot) starts one round in which **every** local Bot may answer, the admin first then the room's order, capped at `FACE_PLAIN_SPEAKERS` (6), one at a time and each after the earlier replies; that round is `single` (`isPlainFaceLine`): replies' own `@`s start no follow-up turns inside it. **Routed by intent (#299):** a plain line that is not `@all` (`route` on the round) also tells each Bot in its prompt (`routeNote`) to answer only when the line is theirs — it names them or their role, no other member's role fits better, or it follows up on their own last answer — and to reply `[PASS]` otherwise (the default; no "add something new" licence); the first Bot that posts, works on it or waits for approval ends a non-broadcast routed round (`answeredAlready`): the Bots after it are marked passed without a model call or a line, so three Bots never post three similar answers. A line to everyone (`ROOM_EVERYONE`) is not cut; a pass (`isPass`) posts, logs and shows nothing. A line to everyone (`ROOM_EVERYONE`: 你们/大家/everyone/introduce yourselves…) says each may answer. The Bot that spoke last before the line (`recentSpeaker`, a follow-up) is asked first and named in the note. When every Bot passes on the **owner's own** line (not a friend's attested one, whose home answers it; not a follow-up to a friend's Bot), `fallbackDue` appends one turn so the first Bot that passed answers with no pass option. `everyone` in the post's source marks a friend's `@all` (not routed). Covered by `core/test/rounds.test.js`. `@Name`, the label the picker shows (`Friend's Chief`, `Writer · Friend's Bot`) or a handle picks that local Bot (`face-mentions.js`); `@all` is everyone; a line that only @s a friend's Bot starts none here; A friend's line (`flybot.rooms.postHuman`, only when the server attests `typed_by_owner`, passed as `attested`) names my Bot by label or handle (`callIds` in the post's source, read by `openingSpeakers` before the quiet rule), or is plain or `@all` and then carries `plain: true`, so my Bots answer it in the same single pass (#295); a line that only names a friend's Bot, or one from a server that cannot attest, stays quiet; a bare `@Chief` from a friend whose own Chief is in the room calls nobody here. A Bot's post in a face room still starts only the Bots it @s, so Bots in two homes cannot set each other off. The reply is held / sent under the usual approval and Auto-send rules. An `@` that names nobody in the room adds one quiet notice line (`No one in this room is called @X …`). A face-room line the send path already posted (`rt.notePostedLine`) is not appended or sent again when the round reads it from the Session. Covered by `core/test/rounds.test.js`, `core/test/face-mentions.test.js` and `plugins/social/test/social.test.js`. +- The DSH composer still prompts the group Session (`user/message` → `group_relay`). Face-to-face turn rule (#167, changed in #287 and #295), `openingSpeakers`: a **plain** line (no `@` naming a Bot) starts one round in which **every** local Bot may answer, the admin first then the room's order, capped at `FACE_PLAIN_SPEAKERS` (6), one at a time and each after the earlier replies; that round is `single` (`isPlainFaceLine`): replies' own `@`s start no follow-up turns inside it. **Routed by intent (#299):** a plain line that is not `@all` (`route` on the round) also tells each Bot in its prompt (`routeNote`) to answer only when the line is theirs — it names them or their role, no other member's role fits better, or it follows up on their own last answer — and to reply `[PASS]` otherwise (the default; no "add something new" licence); the first Bot that posts, works on it or waits for approval ends a non-broadcast routed round (`answeredAlready`): the Bots after it are marked passed without a model call or a line, so three Bots never post three similar answers. The owner's own typed line reaches a face round through the Session inbox (the round's opener is the quiet `name: text` notice), so `drainRoomInbox` routes it (`routeFor`, `single`, `lastFirst`) when it starts the round; a friend's attested line routes at the opener. A line to everyone (`ROOM_EVERYONE`) is not cut; a pass (`isPass`) posts, logs and shows nothing. A line to everyone (`ROOM_EVERYONE`: 你们/大家/everyone/introduce yourselves…) says each may answer. The Bot that spoke last before the line (`recentSpeaker`, a follow-up) is asked first and named in the note. When every Bot passes on the **owner's own** line (not a friend's attested one, whose home answers it; not a follow-up to a friend's Bot), `fallbackDue` appends one turn so the first Bot that passed answers with no pass option. `everyone` in the post's source marks a friend's `@all` (not routed). Covered by `core/test/rounds.test.js`. `@Name`, the label the picker shows (`Friend's Chief`, `Writer · Friend's Bot`) or a handle picks that local Bot (`face-mentions.js`); `@all` is everyone; a line that only @s a friend's Bot starts none here; A friend's line (`flybot.rooms.postHuman`, only when the server attests `typed_by_owner`, passed as `attested`) names my Bot by label or handle (`callIds` in the post's source, read by `openingSpeakers` before the quiet rule), or is plain or `@all` and then carries `plain: true`, so my Bots answer it in the same single pass (#295); a line that only names a friend's Bot, or one from a server that cannot attest, stays quiet; a bare `@Chief` from a friend whose own Chief is in the room calls nobody here. A Bot's post in a face room still starts only the Bots it @s, so Bots in two homes cannot set each other off. The reply is held / sent under the usual approval and Auto-send rules. An `@` that names nobody in the room adds one quiet notice line (`No one in this room is called @X …`). A face-room line the send path already posted (`rt.notePostedLine`) is not appended or sent again when the round reads it from the Session. Covered by `core/test/rounds.test.js`, `core/test/face-mentions.test.js` and `plugins/social/test/social.test.js`. - Group Session models (#289): `applyModel` does not trust `appliedModel` while a Bot is `modelStale`. A failed model selection in a group Session (from `applyModel` or from `room-participant.js` `open()`) marks the Bot stale; the next apply (model-list change, restart, or the Bot's next turn in a known group Session) selects every Session again and clears the mark once all took it. Covered by `core/test/models.test.js`. ### Face-to-face participant look diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 40a4aac18..b679ff372 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -941,7 +941,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a message sent between rounds still opens a full round', async (t) => { const { call, emit, userMessage, relay, answer, stop, sent } = start() t.after(stop) -@@ -347,3 +347,227 @@ test('a message sent between rounds still opens a full round', async (t) => { +@@ -347,3 +347,275 @@ test('a message sent between rounds still opens a full round', async (t) => { const result = JSON.parse(await running) assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer']) }) @@ -1169,3 +1169,51 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + await answer(b.sessionId, b.message.source.exchangeId, 'mine too', 3) + await all +}) ++ ++// How the owner's own line really reaches a face round: `send` logs it and posts the quiet "name: text" notice, and the ++// composer's message waits in the Session inbox, so the round opens on the notice and drains the line (#299, live finding). ++test('#299: the owner\'s own line, drained from the Session inbox, is routed: one answer, last speaker first, first Bot as fallback', async (t) => { ++ const env = start() ++ const { call, emit, relay, answer, stop, sent } = env ++ t.after(stop) ++ const { hosted, writer, chief } = await routedRoom(env, 'drain') ++ const say = async (text, id) => { ++ await call('send', { sessionId: hosted.id, text }) ++ await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: 'face-notice', senderName: 'Me', quiet: true, notice: true, human: { authorId: 'owner-1', name: 'Me' } }, content: [{ type: 'text', text: `Me: ${text}` }] } }) ++ return { running: relay(hosted.id, id) } ++ } ++ const { running: one } = await say('文档怎么写', 'drain-1') ++ const a = await turnFor(sent) ++ assert.match(promptOf(a), /names no Bot/, 'the drained line is routed') ++ await answer(a.sessionId, a.message.source.exchangeId, '[PASS]', 1) ++ const b = await turnFor(sent, a) ++ assert.equal(b.sessionId, hosted.sessions[writer]) ++ await answer(b.sessionId, b.message.source.exchangeId, 'Start with an outline. @Chief ok?', 2) ++ const first = JSON.parse(await one) ++ assert.deepEqual(first.replies.filter(reply => reply.kind === undefined).map(reply => reply.botId), [writer]) ++ assert.equal(roomTurns(sent).length, 2, 'the answer\'s @ starts no follow-up') ++ ++ // Follow-up: the Writer is first, answers, and the Chief is not asked. ++ sent.length = 0 ++ const { running: two } = await say('那你刚才说的大纲具体怎么写', 'drain-2') ++ const c = await turnFor(sent) ++ assert.equal(c.sessionId, hosted.sessions[writer]) ++ await answer(c.sessionId, c.message.source.exchangeId, 'Three headings.', 3) ++ const second = JSON.parse(await two) ++ assert.deepEqual(second.replies.filter(reply => reply.kind === undefined).map(reply => reply.text), ['Three headings.']) ++ assert.equal(roomTurns(sent).length, 1) ++ ++ // Everyone passes: the first Bot (the Writer spoke last, so it is first) answers. ++ sent.length = 0 ++ const { running: three } = await say('随便问问', 'drain-3') ++ const d = await turnFor(sent) ++ await answer(d.sessionId, d.message.source.exchangeId, '[PASS]', 4) ++ const e = await turnFor(sent, d) ++ await answer(e.sessionId, e.message.source.exchangeId, '[PASS]', 5) ++ const f = await turnFor(sent, d, e) ++ assert.ok(f, 'nobody answered, so the first Bot is asked again') ++ assert.match(promptOf(f), /nobody else answered/) ++ await answer(f.sessionId, f.message.source.exchangeId, 'Fine.', 6) ++ await three ++ assert.ok(chief) ++}) diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index a21783a3f..330797548 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -747,11 +747,14 @@ diff --git a/src/host/groups.js b/src/host/groups.js // A called member moves up if it is still waiting, or gets one more turn if it // already spoke; either way it goes right after slot `index`. `cap` is the caller's // per-member turn bound for the round; `by` is the calling member, if a member called. -@@ -291,9 +291,11 @@ export function install(rt) { +@@ -291,12 +291,26 @@ export function install(rt) { if (lines.length === 0) return 0 const before = round.slots.length const members = room.members.map(rt.botOf).filter(Boolean) -+ // A line the user adds mid-round is answered by the rule for its own opening, so this round stops routing. ++ // The owner's own typed line reaches a face round through the Session inbox, not as the round's opener (the opener ++ // is the quiet "name: text" notice), so it is routed here (#299). A line added to a round that already routes ++ // is answered by the rule for its own opening, so that round stops routing. ++ const starting = round.route === undefined && round.slots.length === 0 && lines.length === 1 + delete round.route for (const line of lines) { - appendRoom(room, { who: 'User', text: line.text }) @@ -760,11 +763,38 @@ diff --git a/src/host/groups.js b/src/host/groups.js + round.replies.push({ kind: 'user', text: line.text, time: Date.now(), authorId: line.authorId, authorKind: line.authorKind, name: line.authorName }) const { all, found } = mentionsIn(line.text, members) const note = all || found.length > 0 ? 'the user mentioned you' : 'new message from the user' - callMembers(round, turns, index - 1, openingSpeakers(room, { text: line.text }), note, ROOM_ADMIN_TURNS_EACH) -@@ -305,14 +305,48 @@ export function install(rt) { +- callMembers(round, turns, index - 1, openingSpeakers(room, { text: line.text }), note, ROOM_ADMIN_TURNS_EACH) ++ let speakers = openingSpeakers(room, { text: line.text }) ++ if (starting) { ++ const route = routeFor(room, { text: line.text }, round.startSeq) ++ if (route) { ++ round.route = route ++ round.single = true ++ speakers = lastFirst(speakers, route) ++ } ++ } ++ callMembers(round, turns, index - 1, speakers, note, ROOM_ADMIN_TURNS_EACH) + } + // The user spoke in this room, so Bots may start their own rounds again. + room.botRounds = 0 +@@ -305,14 +305,62 @@ export function install(rt) { return round.slots.length - before } ++ // The routing of a person's plain line in a face-to-face room (not @all): who decides, whether it is to everyone, ++ // who spoke last, and whether the user must be answered if every Bot passes (the owner's own line, not a friend's). ++ function routeFor(room, opener, startSeq) { ++ if (!isPlainFaceLine(room, opener) || opener.everyone) return undefined ++ const broadcast = ROOM_EVERYONE.test(opener.text ?? '') ++ return { broadcast, fallback: opener.plain !== true, last: broadcast ? undefined : recentSpeaker(room, startSeq) } ++ } ++ // The Bot that spoke last is asked first. ++ function lastFirst(speakers, route) { ++ const at = route?.last && !route.last.remote ? speakers.findIndex(bot => bot.id === route.last.id) : -1 ++ if (at > 0) speakers.unshift(...speakers.splice(at, 1)) ++ return speakers ++ } ++ + // A routed, non-broadcast round where a Bot already posted, is working on it or waits for the owner's approval. + const answeredAlready = round => round.route !== undefined && !round.route.broadcast && round.route.forced === undefined + && round.slots.some(slot => slot.status === 'posted' || slot.status === 'working' || slot.status === 'waiting-approval') @@ -877,18 +907,14 @@ diff --git a/src/host/groups.js b/src/host/groups.js } if (posts.some(post => !post.botId && !post.kickoff)) { room.botRounds = 0 -@@ -418,17 +418,28 @@ export function install(rt) { +@@ -418,17 +418,24 @@ export function install(rt) { const references = posts.flatMap(post => post.references ?? []) // A member a Bot's post calls by name works for that Bot. const named = opener.botId === undefined ? [] : mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean)).found + // A person's plain line (not @all) in a face-to-face room: the Bots decide who answers (#299). The + // owner's own line is never left unanswered; a friend's attested line is answered by their home too. -+ const routed = isPlainFaceLine(room, opener) && !opener.everyone -+ const broadcast = routed && ROOM_EVERYONE.test(opener.text ?? '') -+ const route = routed ? { broadcast, fallback: opener.plain !== true, last: broadcast ? undefined : recentSpeaker(room, startSeq) } : undefined -+ const speakers = openingSpeakers(room, opener) -+ const lastAt = route?.last && !route.last.remote ? speakers.findIndex(bot => bot.id === route.last.id) : -1 -+ if (lastAt > 0) speakers.unshift(...speakers.splice(lastAt, 1)) ++ const route = routeFor(room, opener, startSeq) ++ const speakers = lastFirst(openingSpeakers(room, opener), route) const round = { roomId: room.id, members: room.members,